Building gh-saved-issues using ChatGPT Codex
I spend a lot of time on the /issues page on GitHub. Working across multiple repos in multiple orgs, it can be difficult to keep up to date on what's happening. /issues allows me to see everything on a single page.
The /issues page allows you to create saved searches and view them when needed. Here are a couple of examples that I use every day:
bash# My PRs that are awaiting reviewis:pr author:@me state:open org:Kong -draft:true
bash# Issues open on my personal projects (excluding dependency updates + my blog)state:open archived:false sort:updated-desc user:mheap -author:app/dependabot -repo:mheap/michaelheap.com is:issue
I also started out with a "all PRs awaiting review from me filter":
bash# Awaiting review from me within the Kong orgis:pr state:open org:Kong review-requested:@me
However, I found that I wasn't looking at it as there were regularly too many items to review in a single session. So instead, I started segmenting my reviews by context:
bash# All Terraform PRsis:pr state:open -author:app/renovate (repo:Kong/terraform-provider-konnect OR repo:Kong/terraform-provider-konnect-beta OR repo:Kong/terraform-provider-kong-gateway)
This worked much better, but then I realised that I'd need to keep 90% of the search consistent, but change the repos being shown for various different contexts.
So I did what anyone would - I started reverse engineering the GraphQL queries needed to configure saved searches and building a configuration file with template support.
If you're just looking for the tool, you can find it at mheap/gh-saved-issues.
This tool is my first attempt at building something with an agent. Keep reading to see my experience.
Building with Codex
I did some research before prompting Codex, including extracting curl commands from developer tools. I pasted these commands into my initial prompt
You are an expert Go developer. You have been tasked to build a GitHub CLI extension to configure saved searches on GitHub. The CLI layer should be very thin, and all of the logic for working with the API must be included in library files.
The tool should accept a configuration file (located at $XDG_CONFIG_HOME/.github-searches.yaml) in the following format:
yamlsearches:- name: Assigned to me (Kong)query: "state:open archived:false assignee:@me sort:updated-desc org:Kong"- name: Assigned to Steve (All Orgs)query: "state:open archived:false assignee:steve.doe sort:updated-desc"- name: Work from Alicetemplate: recent-workvars:user: alice.jonestime: 7d- name: Work from Bobtemplate: recent-workvars:user: bob.smithtemplates:recent-work:query: "org:Kong ((assignee:{{ user }} AND is:issue) OR (is:pr AND author:{{ user }})) (is:open OR updated:>@today-{{ default(time, "30d") }}) sort:updated-desc"
If the search has an id associated with it, update the existing rule. Otherwise create a new one then update the configuration file to set the id value.
To create a new saved issue search, make the following request (but use Go, don't shell out to curl):
bashcurl 'https://github.com/_graphql' \--data-raw '{"query":"c06c5627e09922bd28c6d34ff91d0530","variables":{"input":{"color":"GRAY","icon":"BOOKMARK","name":"Untitled view","query":"","searchType":"ISSUES"}}}'
To update a search, use this request (shortcutId is the id from the config):
bashcurl 'https://github.com/_graphql' \--data-raw '{"query":"379dbe4cf68c3485e48df2f699f5ae75","variables":{"input":{"color":"GRAY","description":"Test","icon":"BOOKMARK","name":"Demo123","query":"user:mheap","scopingRepository":null,"shortcutId":"SSC_kgDOAB3rrw"}}}'
If a user sets remove: true on a query, you can delete the search using the following request:
bashcurl 'https://github.com/_graphql' \--data-raw '{"query":"2939ea7192de2c6284da481de6737322","variables":{"input":{"shortcutId":"SSC_kgDOAB3rsg"}}}'
Build the CLI, and also bootstrap test cases for all library code.
At this point most of the work was done, except it didn't work. This graphql endpoint is not accessible using a GitHub token, it needs a cookie session to be specified
In addition to req.Header.Set("Authorization", "Bearer "+c.token), allow me to specify a cookie like:
bash-b '_device_id=ID_HERE; user_session=SESSION_HERE' \
This made the API requests work, but I realised that updating an existing config wasn't working due to a bug in findShortcutId.
Fix findShortcutId
The response looks like the following:
json{"data":{"createDashboardSearchShortcut":{"dashboard":{"shortcuts":{"totalCount":4,"nodes":[{"id":"SSC_kgDOAA2otQ","name":"mheap: Open Issues","query":"state:open archived:false sort:updated-desc user:mheap -author:app/dependabot -repo:mheap/michaelheap.com is:issue ","icon":"BOOKMARK","color":"GRAY","description":"","scopingRepository":null},{"id":"SSC_kgDOABdOWA","name":"Kong: My Open PRs","query":"is:pr author:@me state:open org:Kong -draft:true","icon":"BOOKMARK","color":"GRAY","description":"","scopingRepository":null},{"id":"SSC_kgDOABdOZw","name":"Kong: Awaiting Review from Me","query":"is:pr state:open org:Kong review-requested:@me -repo:Kong/platform-api ","icon":"BOOKMARK","color":"GRAY","description":"","scopingRepository":null},{"id":"SSC_kgDOABdOaA","name":"mheap: Awaiting Review from Me","query":"state:open archived:false sort:updated-desc user:mheap -author:app/dependabot -repo:mheap/michaelheap.com is:pr ","icon":"BOOKMARK","color":"GRAY","description":"","scopingRepository":null}}
Search for "name: Kong: My Open PRs" and extact the id field from that value
This worked, but was inefficient. Codex walked every node in the tree looking for IDs.
You don't have to walk the tree. The data is always in data.createDashboardSearchShortcut.dashboard.shortcuts.nodes
Success! Now let's add some new capabilities
Now group the searches by section. For each section, create an empty query with the title "== $SECTION ==" above the list of items
This worked, but didn't feel right to me.
Actually, I changed my mind. Don't use section in a config. Instead, sections will be defined explicitly e.g.
yamlsearches:- section: Demo- id: SSC_kgDOAB5MiAname: Test 1query: state:open archived:false assignee:@me sort:updated-desc org:Kongtemplate: ""vars: {}remove: false- id: SSC_kgDOAB5MiQname: Work from Alicequery: ""template: recent-workvars:time: 30duser: alice.jonesremove: falsetemplates:recent-work:query: org:Kong ((assignee:{{ user }} AND is:issue) OR (is:pr AND author:{{ user }})) (is:open OR updated:>@today-{{ default(time, "30d") }}) sort:updated-desc
Much better! But my section configs were being given a query entry unexpectedly in the updated config like this:
yaml- id: SSC_kgDOAB5MiAsection: Demoname: ""query: ""template: ""vars: {}remove: false
I wanted it to look like this:
yaml- id: SSC_kgDOAB5MiAsection: Demo
Do not replace section configs with a full query object. It should contain only id and section
Everything looked good at this point. I wanted to try recreating everything so that the order in my config file is respected (right now, the section is the last item as it's the most recently created)
Add a --force flag to the CLI that deletes all existing configs and recreates them, even if an ID is set
It worked, but I realised that the flag name should really be --recreate.
Rename the force flag to --recreate
How about use cases where I want to remove all saved searches without recreating them?
Also add a --reset flag that removes all entries without creating anything
At this point, the CLI was finished. It can create saved searches, with section markers and recreate all saved searches to enforce ordering.
But I wasn't done. I had an idea for another template, but needed a way to provide multiple repos as a list:
Add support for the join function in templates. Example usage:
yaml- name: Terraform PRstemplate: repo-prsvars:repos:- repo:Kong/terraform-provider-konnect- repo:Kong/terraform-provider-konnect-beta- repo:Kong/terraform-provider-kong-gateway
yamlrepo-prs:query: is:pr state:open ({{ join(repos, "OR") }}) -author:app/renovate draft:false {{ additional }}
Uh oh! There's a bug. I used {{ additional }} in the template to add additional filters using a free-form string. If there are no additional parameters, I expected it to resolve to an empty string (meaning "no additional filters").
However, if a config didn't specify an empty string we got an error. I pasted the error message into Codex to try and fix it:
template: query:1: function "additional" not defined - can the template code treat missing values as null?
All working as expected, but how do people know how to use it?
Produce a README.md teaching people how to use this tool
The README mentions a --config flag, but it doesn't exist. Sounds like a good idea to me
Implement the --config flag like the README indicates
At this point, I had something I wanted to ship 🎉
Reflection
Honestly, I had a great time building this tool. I'm not sure it was faster than writing it by hand, but I did it in the background while doing something else and it ended up saving me a ton of time. If I had to write it by hand, I probably wouldn't have finished this project.
I did do a little bit extra by hand after the session above. I added a GitHub Action to build and release the extension, which means you can run it with gh extension install https://github.com/mheap/gh-saved-issues.