/ src / content / posts / turning-issues-into-posts.mdx
turning-issues-into-posts.mdx
  1  ---
  2  title: Turning issues into posts
  3  date: 2025-04-10
  4  description: Github issues combined with a simple workflow script to manage contents
  5  tags:
  6      - website
  7  ---
  8  
  9  If you don't know already, I'm using [Astro](https://astro.build/) to build this website. The code is hosted publicly at [elianiva/elianiva.my.id](https://github.com/elianiva/elianiva.my.id) if you want to check it out.
 10  
 11  The posts are written in [markdown (.mdx) files](https://github.com/elianiva/elianiva.my.id/blob/master/src/content/posts/) so if I want to add a new post, I have to create a new file, make a commit, and then push it to trigger the deployment. Unlike a traditional CMS where you have this fancy dashboard that you can use, this one is pretty barebones, and I like it this way. It allows me to use any text editors I want to edit the contents--oh, I use [Neovim](https://neovim.io), btw.
 12  
 13  Now, the problem is that not all of my posts are long-form. I have a few bookmarks / TILs. They're basically just small notes that don't warrant a full blog post.
 14  
 15  In case you don't know what that is, here are a few examples from other people:
 16  
 17  - https://www.visualmode.dev/a-decade-of-tils
 18  - https://simonwillison.net/2021/May/2/one-year-of-tils/
 19  - https://github.com/jbranchaud/til
 20  - https://blog.reinaldyrafli.com/til
 21  
 22  Oh, wanna see mine? Try hitting the yellow square at the top left corner ;)
 23  
 24  ## When it starts to get painful
 25  
 26  Like I said before, the contents of this website are all in markdown files and it involves me going through several steps to publish contents in it. If I'm using my laptop then sure it's easy to do, but when I'm on mobile it's such a hassle. I have to set up Termux, git clone--not to mention having to set up my git credentials as well--write the markdown file, make a commit, and then push. That's too much work for my lazy ass.
 27  
 28  Sometimes, I just want to jot down a quick note and save it. I'm not always in front of my laptop, so I need some easy way to do this from my phone without having to make a dedicated platform myself--which, I almost did.
 29  
 30  ## Down into the rabbit hole
 31  
 32  I've been trying several note-taking apps in the past. I tried [Notion](https://notion.so), but it didn't end very well because I ended up overengineering how it works instead of just taking notes. I also tried [Obsidian](https://obsidian.md), which is a bit better, but again, I got sucked into tweaking it too much.
 33  
 34  I mean, Neovim itself is already a rabbit hole, but I've come to a point where I no longer do too many things with it. I've had my fair share of going through that rabbit hole already in the past. Now I just have a solid config and _actually use_ the editor.
 35  
 36  ## The solution
 37  
 38  I had a discussion on Discord the other day on how should we approach this. I was thinking of just using a separate platform that has the feature built-in, such as Obsidian which has [Obsidian Publish](https://obsidian.md/publish). There's also Notion.
 39  
 40  Although they are simple to use, they're still too overkill for this simple task. Each TIL item is just a short-form text, mostly 1-2 paragraphs, maybe a few links here and there. If you're already using Obsidian or Notion then yeah, this is the easiest option. For those who prefer rolling stuff out by themself, it's too much.
 41  
 42  Another option is to use email. You can just send an email to a dedicated account which will then get picked up, do the thing, and so on and so forth. It could work, but email? Really? Nah, that ain't zoomer enough.
 43  
 44  Finally, there's Github issues. This is great because for one, it's free and it's integrated into the platform that I'm already using to host the code, and two, it's relatively easy to automate stuff on Github. You can just _do_ things.
 45  
 46  ## Github Issues came in clutch
 47  
 48  The idea is to use Github Issues to manage the content. Since the contents are markdown files and Github supports markdown syntax, it's pretty easy to start writing, press submit, and let the Github Action take over and do the tedious thing for you.
 49  
 50  I didn't come up with this solution, it was [aldy505](https://github.com/aldy505) who did it first and I just adjusted it to fit my use case better. My version is a bit different because I need to also handle `bookmark` which is basically just some links I found useful.
 51  
 52  Here's a simple workflow that he uses, which might fit your use case better because it's simple and you can build upon it.
 53  
 54  ```yaml
 55  name: Issue to TIL
 56  
 57  on:
 58    issues:
 59      types: [opened]
 60  
 61  concurrency:
 62    group: ${{ github.workflow }}-${{ github.ref }}
 63    cancel-in-progress: false
 64  
 65  jobs:
 66    build:
 67      runs-on: ubuntu-latest
 68      permissions:                # Job-level permissions configuration starts here
 69        contents: write           # 'write' access to repository contents
 70        pull-requests: write      # 'write' access to pull requests
 71      steps:
 72        - uses: actions/checkout@v4
 73        - uses: actions/setup-node@v4
 74          with:
 75            node-version: 22
 76        - name: Install dependencies
 77          run: npm install
 78        - name: Install ULID
 79          run: npm install ulid
 80        - name: Grab the issue content body
 81          uses: actions/github-script@v7
 82          with:
 83            retries: 3
 84            script: |
 85              const { ulid } = require('ulid');
 86              const fs = require('fs');
 87  
 88              const issue = {
 89                body: "${{ github.event.issue.body }}",
 90              };
 91  
 92              const id = ulid();
 93              const date = new Date().toISOString();
 94  
 95              fs.writeFileSync(`./src/content/tils/${id}.md`, `---
 96              id: ${id}
 97              date: '${date}'
 98              ---
 99              ${issue.body}`);
100        - name: Create a commit
101          run: |
102            git config --local user.email "action@github.com"
103            git config --local user.name "GitHub Action"
104            git add .
105            git commit -m "Add TIL from #${{ github.event.issue.number }}"
106        - name: Push to repository
107          uses: ad-m/github-push-action@master
108          with:
109            github_token: ${{ github.token }}
110            branch: master
111        - name: Close Issue
112          run: gh issue close --comment "Auto-closing issue" "${{ github.event.issue.number }}"
113          env:
114            GH_TOKEN: ${{ github.token }}
115  ```
116  <small class="block opacity-70 -mt-4 mb-4">Yeah, he vibe coded this, that's why it looks a bit dodgy, he probably improved it or something in his repo but this is the one I took</small>
117  
118  Basically, it does the following:
119  
120  1. Grab the issue content
121  2. Write the content in `src/content/tils` with a unique ID as the filename
122  3. Create a commit with the new file
123  4. Push the commit to the repository
124  5. Close the issue
125  
126  I basically just stole it :p -- and adjusted it to also handle a few different things.
127  
128  ### Checks for author
129  
130  Since his repository is private, he doesn't have to care about people creating issues and then accidentally publish it. My repo, on the other hand, is public. So I need a way to limit it to myself.
131  
132  It's pretty simple to do, I just have to add this one line for the job section.
133  
134  ```yaml
135  if: github.actor == github.repository_owner
136  ````
137  
138  It checks if the actor--the person who created this issue--is the same as the owner of the repository. If it's the same, then it will run the job. Otherwise, it won't. Pretty straight forward.
139  
140  ### Handling different types
141  
142  My version needs to handle both TIL and bookmark, so I need to adjust it a bit. I finally came up with this.
143  
144  ```javascript
145  const issue = context.payload.issue;
146  if (!issue) {
147    core.setFailed("Could not get issue details");
148    return;
149  }
150  
151  const date = new Date().toISOString().split("T")[0];
152  const title = issue.title;
153  const body = issue.body || "";
154  const type = issue.labels?.find(label => label.name.startsWith("type:"))?.name.split(":")[1] || "";
155  
156  if (body.length === 0) {
157    core.setFailed("Issue body is empty");
158    return;
159  }
160  
161  if (type.length === 0) {
162    core.setFailed("Issue labels do not contain a type");
163    return;
164  }
165  ```
166  
167  This part prepares the issue information that I need. I mark the issue as TIL or bookmark based on the issue label. If it has a label that starts with `type:` then it will be used. The rest are just handling some edge cases
168  
169  ```javascript
170  const [content, links] = body.split("---").map(part => part.trim());
171  const linksArray = links ? links.split("\n").map(link => link.trim()) : [];
172  
173  const markdown = `---\n` +
174    `title: ${title}\n` +
175    `date: ${date}\n` +
176    `type: ${type}\n` +
177    (linksArray.length > 0 ? `links:\n${linksArray.map(link => `  - url: ${link}`).join("\n")}\n` : '') +
178    `---\n\n${content}\n`;
179  ```
180  
181  This part just prepares the markdown file. The reason why I split the content by `---` is because I have a dedicated `links` field so that I can display them on their own container.
182  
183  An example issue content would look something like this:
184  
185  ```markdown
186  This is the content body, nothing too special about it.
187  ---
188  https://elianiva.my.id/posts/today-i-learned
189  ```
190  
191  It will take the first part as the content body and parse the second part as an array of links.
192  
193  ```javascript
194  const snakeCasedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
195  const filePath = path.join('${{ github.workspace }}', "src", "content", "bookmarks", `${snakeCasedTitle}.mdx`);
196  
197  fs.writeFileSync(filePath, markdown);
198  console.log(`Successfully created TIL file: ${filePath}`);
199  ```
200  
201  This last part is basically writing the issue content to the file. I converted the issue title into snake-case and then used it for the file name to match the other files I already have.
202  
203  That's pretty much the adjustment I made, the rest is just changing the file name, commit message, etc.
204  
205  ### Putting it all together
206  
207  Now, putting them all together, it should look something like this:
208  
209  ```yaml
210  name: Create TIL/Bookmark from Issue
211  
212  on:
213    issues:
214      types: [opened]
215  
216  concurrency:
217    group: ${{ github.workflow }}-${{ github.ref }}
218    cancel-in-progress: false
219  
220  jobs:
221    publish:
222      if: github.actor == github.repository_owner
223      runs-on: ubuntu-latest
224      permissions:
225        contents: write
226        issues: write
227      steps:
228        - name: Checkout repository
229          uses: actions/checkout@v4
230  
231        - name: Create TIL markdown file from issue
232          uses: actions/github-script@v7
233          id: create-til
234          with:
235            retries: 3
236            script: |
237              const fs = require("fs");
238              const path = require("path");
239  
240              const issue = context.payload.issue;
241              if (!issue) {
242                core.setFailed("Could not get issue details");
243                return;
244              }
245  
246              const date = new Date().toISOString().split("T")[0];
247              const title = issue.title;
248              const body = issue.body || "";
249              const type = issue.labels?.find(label => label.name.startsWith("type:"))?.name.split(":")[1] || "";
250  
251              if (body.length === 0) {
252                core.setFailed("Issue body is empty");
253                return;
254              }
255  
256              if (type.length === 0) {
257                core.setFailed("Issue labels do not contain a type");
258                return;
259              }
260  
261              const [content, links] = body.split("---").map(part => part.trim());
262              const linksArray = links ? links.split("\n").map(link => link.trim()) : [];
263  
264              const markdown = `---\n` +
265                `title: ${title}\n` +
266                `date: ${date}\n` +
267                `type: ${type}\n` +
268                (linksArray.length > 0 ? `links:\n${linksArray.map(link => `  - url: ${link}`).join("\n")}\n` : '') +
269                `---\n\n${content}\n`;
270  
271              const snakeCasedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
272              const filePath = path.join('${{ github.workspace }}', "src", "content", "bookmarks", `${snakeCasedTitle}.mdx`);
273  
274              fs.writeFileSync(filePath, markdown);
275              console.log(`Successfully created TIL file: ${filePath}`);
276  
277        - name: Commit and push TIL file
278          run: |
279            git config --local user.email "action@github.com"
280            git config --local user.name "GitHub Action"
281            git add ./src/content/bookmarks/*.mdx
282            if git diff --staged --quiet; then
283              echo "No changes to commit."
284            else
285              git commit -m "bookmark/til: add content from #${{ github.event.issue.number }} [automated]"
286              echo "Commit created."
287            fi
288  
289        - name: Push changes to repository
290          uses: ad-m/github-push-action@master
291          with:
292            github_token: ${{ secrets.GITHUB_TOKEN }}
293            branch: ${{ github.ref_name }}
294  
295        - name: Close Issue
296          if: success()
297          run: gh issue close "${{ github.event.issue.number }}" --comment "Bookmark/TIL created and pushed. Auto-closing issue."
298          env:
299            GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
300  ```
301  
302  Honestly, I don't really like the way it looks because there's a piece of Javascript code shoved _inside_ YAML file. That's such a nightmare to work with.
303  
304  Initially, I wanted to separate it into its own file--docs says you can--but kept getting errors about `module is not defined` or something like that :p
305  
306  [fail-1]: https://github.com/elianiva/elianiva.my.id/issues/42
307  [fail-2]: https://github.com/elianiva/elianiva.my.id/issues/43
308  [fail-3]: https://github.com/elianiva/elianiva.my.id/issues/44
309  [fail-4]: https://github.com/elianiva/elianiva.my.id/issues/45
310  [fail-5]: https://github.com/elianiva/elianiva.my.id/issues/46
311  
312  It actually [took][fail-1] [me][fail-2] [5][fail-3] [failed][fail-4] [attempts][fail-5] before finally got it working. I just gave up and inline the script to the YAML file. It works, and I'm not going to touch it anyway once I get it working.
313  
314  ## Called it a wrap
315  
316  There's not much to it, really. It's pretty simple and it works really well. Now I can just submit contents from my phone by creating a Github issue.
317  
318  I didn't handle a lot of edge cases. For example, what would happen when I re-open the issue or something like that. I find it useless because I'm the only one who's going to use it, and if I ever needed something like that I can just do it manually :p
319  
320  Anyway, if you want to do something similar, here are some links that might help you get there.
321  
322  - [Github Actions Documentation](https://docs.github.com/en/actions)
323  - [Github Actions Script Documentation](https://github.com/actions/github-script)
324  - [My Workflow File](https://github.com/elianiva/elianiva.my.id/blob/master/.github/workflows/bookmark-til.yml)
325  
326  There are maybe a few missing stuff that I don't really talk about in this post, in that case, feel free to leave a comment :)
327  
328  Until next time! 👋