Building gh-saved-issues using ChatGPT Codex

12 Dec 2025 in Tech

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 review
is: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 org
is: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 PRs
is: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

📝 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:

yaml
searches:
- 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 Alice
template: recent-work
vars:
user: alice.jones
time: 7d
- name: Work from Bob
template: recent-work
vars:
user: bob.smith
templates:
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):

bash
curl '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):

bash
curl '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:

bash
curl '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

📝 Prompt

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.

📝 Prompt

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.

📝 Prompt

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

📝 Prompt

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.

📝 Prompt

Actually, I changed my mind. Don't use section in a config. Instead, sections will be defined explicitly e.g.

yaml
searches:
- section: Demo
- id: SSC_kgDOAB5MiA
name: Test 1
query: state:open archived:false assignee:@me sort:updated-desc org:Kong
template: ""
vars: {}
remove: false
- id: SSC_kgDOAB5MiQ
name: Work from Alice
query: ""
template: recent-work
vars:
time: 30d
user: alice.jones
remove: false
templates:
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_kgDOAB5MiA
section: Demo
name: ""
query: ""
template: ""
vars: {}
remove: false

I wanted it to look like this:

yaml
- id: SSC_kgDOAB5MiA
section: Demo
📝 Prompt

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)

📝 Prompt

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.

📝 Prompt

Rename the force flag to --recreate

How about use cases where I want to remove all saved searches without recreating them?

📝 Prompt

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:

📝 Prompt

Add support for the join function in templates. Example usage:

yaml
- name: Terraform PRs
template: repo-prs
vars:
repos:
- repo:Kong/terraform-provider-konnect
- repo:Kong/terraform-provider-konnect-beta
- repo:Kong/terraform-provider-kong-gateway
yaml
repo-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:

📝 Prompt

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?

📝 Prompt

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

📝 Prompt

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.