GitHub Issues
Manage GitHub issues end to end: list, search, view, create, label,
assign, comment, close, reopen, triage, and run bulk operations. Every
section leads with the gh CLI (the preferred path) and follows with a
curl REST fallback for environments where gh is unavailable.
When To Use
- User asks to file, view, search, or update a GitHub issue.
- User asks to triage a backlog ("label the untriaged issues", "assign the open bugs to me").
- User asks for the status of issues on a repo ("what's open on owner/repo", "show me the bugs I'm assigned").
When NOT to Use
- Pull-request review or merge flows → not covered here; use
gh pr …. - Linear, Jira, or other trackers → use their dedicated skill.
- Plain local git history questions → answer with
git logdirectly.
Setup
gh is the preferred path. It must be installed and authenticated before
the first issue operation. Installing is non-interactive — do it
yourself (it's a side-effecting command, so it runs through the approval
seam; you propose it, you don't hand it to the user). Only the interactive
sign-in in step 2 needs the user.
1. Install gh if it's missing
Check for it, and if it's absent, detect the platform and run the matching install command yourself:
command -v gh >/dev/null 2>&1 && echo "gh present: $(gh --version | head -1)"
If that prints nothing, install it:
brew install gh # macOS / Linuxbrew
sudo dnf install -y gh # Fedora / RHEL
sudo pacman -S --noconfirm github-cli # Arch
winget install --id GitHub.cli # Windows
Debian/Ubuntu need the GitHub CLI apt repo added first — run this chain (don't ask the user to):
(type -p wget >/dev/null || (sudo apt update && sudo apt install -y wget)) \
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
&& wget -nv -O- https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
| sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null \
&& sudo apt update && sudo apt install -y gh
Only if the host genuinely can't install gh (no supported package
manager, no sudo) fall back to the curl/REST path in step 3.
2. Authenticate gh if it isn't signed in
gh auth status >/dev/null 2>&1 && echo "gh authenticated" || echo "gh NOT authenticated"
If it's not authenticated, tell the user in one line and stop — sign-in is interactive, so they run it, not you. Say something like:
Run
gh auth loginin your terminal to sign in, then I can file the issue.
Pick up where you left off once they confirm; re-run gh auth status to
verify before continuing.
3. Resolve the auth mode and target repo
# Prefer the gh CLI when it's present and signed in.
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
AUTH="gh"
else
AUTH="curl"
: "${GITHUB_TOKEN:?gh is unavailable — set GITHUB_TOKEN, or install and auth gh (steps 1-2)}"
fi
# Derive owner/repo from the origin remote (needed for the curl path).
OWNER_REPO=$(git remote get-url origin 2>/dev/null \
| sed -E 's|.*github\.com[:/]||; s|\.git$||')
OWNER=${OWNER_REPO%%/*}
REPO=${OWNER_REPO##*/}
gh reads $OWNER/$REPO from the remote automatically; pass
--repo owner/repo to any gh issue command to target a different repo.
For curl, the REST base is https://api.github.com/repos/$OWNER/$REPO.
1. Listing and Searching
gh:
gh issue list
gh issue list --state open --label "needs-triage" --limit 30
gh issue list --assignee @me --json number,title,labels
gh issue list --search "in:title timeout sort:created-desc"
gh issue view 128
gh issue view 128 --comments
curl:
# Open issues (the REST /issues endpoint also returns PRs — filter them out).
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues?state=open&per_page=30" \
| jq -r '.[] | select(.pull_request|not)
| "#\(.number) \(.state) \([.labels[].name]|join(",")) \(.title)"'
# A single issue with its metadata.
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues/128" \
| jq -r '"#\(.number): \(.title)\nState: \(.state) Labels: \([.labels[].name]|join(", "))\n\n\(.body)"'
# Full-text search scoped to the repo.
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/search/issues?q=timeout+in:title+repo:$OWNER/$REPO" \
| jq -r '.items[] | "#\(.number) \(.state) \(.title)"'
2. Creating Issues
gh:
gh issue create \
--title "Webhook retries drop the Idempotency-Key header" \
--body "$(cat <<'EOF'
## Summary
Retried webhook deliveries are sent without the Idempotency-Key header,
so the downstream consumer treats each retry as a distinct event.
## Impact
Duplicate charges when a delivery is retried after a transient 5xx.
## Steps to Reproduce
1. Trigger a webhook whose first delivery returns 503.
2. Wait for the automatic retry.
3. Inspect the retried request headers.
## Expected
The retry carries the same Idempotency-Key as the original delivery.
EOF
)" \
--label "bug,webhooks" \
--assignee "@me"
curl:
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues" \
-d "$(jq -nc \
--arg title "Webhook retries drop the Idempotency-Key header" \
--arg body $'## Summary\nRetried deliveries omit the Idempotency-Key header.\n\n## Impact\nDuplicate charges on retry.' \
'{title:$title, body:$body, labels:["bug","webhooks"], assignees:["octocat"]}')"
Body templates
Two fillable templates ship alongside this skill:
templates/bug-report.md— Summary, Impact, Steps to Reproduce, Expected vs Actual, Environment, Error Output, Additional Context.templates/feature-request.md— Problem, Who hits this, Proposed direction, Alternatives considered, Out of scope, Additional Context.
Fill one in, then pass it straight to gh instead of inlining a heredoc.
The templates sit next to this SKILL.md — under the repo at
skills/dev/github-issues/templates/ for the bundled skill, or under
~/.gini/instances/<inst>/skills/dev/github-issues/templates/ for a
user-installed copy.
gh issue create --title "…" --label "bug" \
--body-file skills/dev/github-issues/templates/bug-report.md
With curl, read the file into the body field: jq -nc --arg b "$(cat templates/bug-report.md)" '{title:"…", body:$b}'. Edit the placeholders
before submitting — don't file an issue with the <!-- … --> hints intact.
Attaching an image to an issue
GitHub exposes no public attachment-upload API — gh issue, REST, and
GraphQL all lack an image upload, and the web drag-drop hits a private,
browser-only endpoint. To embed an image, commit it to a dedicated
issue-assets branch and reference its raw URL from the issue body.
Materialize the chat upload to disk. The upload id comes from the user message marker (
Attached image uploads (in order): - <id> (<mime>, <bytes> bytes)).skill_run({skill: "attachments", script: "materialize", args: {uploadId: "<id-from-marker>"}})→{path, absPath}. UseabsPathbelow so git reads the real file regardless of the working directory.Push the image to
issue-assetswith pure git plumbing — this never touches the working tree, index, or current branch.git commit-treetakes an explicit tree object; the parent it's given is history only and does not contribute its tree. So to add an asset to the existing branch you can't justmktreethe new blob — you have to rebuild a tree that contains both the prior assets and the new one. Do that in a throwaway index (GIT_INDEX_FILE) so the real index stays untouched: seed it from the prior tip's tree, add the new blob, then write the combined tree.# Find the existing issue-assets tip on the remote — empty the first time, # before the branch exists. Don't read FETCH_HEAD instead: a failed fetch # leaves it pointing at an unrelated ref (e.g. the clone's default branch), # which would fork that whole history into the asset branch. PARENT=$(git ls-remote origin refs/heads/issue-assets | awk '{print $1}') [ -n "$PARENT" ] && git fetch origin issue-assets # make that commit's objects local BLOB=$(git hash-object -w "<absPath>") # mktemp reserves the path; remove the empty file so git creates a fresh # empty index there when there's no parent to read-tree (the first asset, # before the issue-assets branch exists). TMPIDX=$(mktemp); rm -f "$TMPIDX" # Seed the temp index from the prior tip's tree so old assets are retained. [ -n "$PARENT" ] && GIT_INDEX_FILE="$TMPIDX" git read-tree "$PARENT" GIT_INDEX_FILE="$TMPIDX" git update-index --add --cacheinfo 100644 "$BLOB" "<filename>.png" TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree) rm -f "$TMPIDX" COMMIT=$(git commit-tree "$TREE" ${PARENT:+-p "$PARENT"} -m "Add issue asset: <desc>") git push origin "$COMMIT":refs/heads/issue-assetsVerify the raw URL resolves, then reference it in the issue body:
curl -s -o /dev/null -w '%{http_code}\n' \ "https://raw.githubusercontent.com/$OWNER/$REPO/issue-assets/<filename>.png" # expect 200Body markdown:
.Create or comment as usual —
gh issue create --body-file …/gh issue comment …(Sections 2 and 3) with that markdown in the body.
3. Managing Issues
Labels
gh:
gh issue edit 128 --add-label "priority:high" --add-label "webhooks"
gh issue edit 128 --remove-label "needs-triage"
gh label list # what labels exist on this repo
curl:
# Add labels.
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues/128/labels" \
-d '{"labels":["priority:high","webhooks"]}'
# Remove one label.
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues/128/labels/needs-triage"
# List the labels defined on the repo.
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/labels?per_page=100" \
| jq -r '.[] | "\(.name)\t\(.description // "")"'
Confirm a label exists (gh label list) before applying it — GitHub
returns a generic 422 for an unknown label rather than creating it.
Assignment
gh:
gh issue edit 128 --add-assignee @me
gh issue edit 128 --add-assignee octocat --remove-assignee former-owner
curl:
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues/128/assignees" \
-d '{"assignees":["octocat"]}'
Comments
gh:
gh issue comment 128 --body "Root cause is in the retry middleware — it rebuilds the request without copying idempotency headers. Fix in progress."
curl:
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues/128/comments" \
-d "$(jq -nc --arg b "Root cause is in the retry middleware. Fix in progress." '{body:$b}')"
Closing and reopening
gh:
gh issue close 128 --reason completed --comment "Fixed in #131."
gh issue close 142 --reason "not planned"
gh issue reopen 128
curl:
# state_reason is one of: completed | not_planned | reopened
curl -s -X PATCH -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues/128" \
-d '{"state":"closed","state_reason":"completed"}'
curl -s -X PATCH -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues/128" \
-d '{"state":"open"}'
Linking issues to PRs
A PR that includes a closing keyword in its body auto-closes the issue
when it merges: Closes #128, Fixes #128, or Resolves #128.
Start a branch directly from an issue:
gh issue develop 128 --checkout # gh creates and checks out the branch
# manual equivalent:
git switch main && git pull && git switch -c fix/issue-128-idempotency-key
4. Triage Workflow
When asked to triage:
- List the untriaged set —
gh issue list --label "needs-triage" --state open. - Read each issue in full (
gh issue view <n>) to understand whether it's a bug, a feature, or a question. - Apply a type + priority label and remove
needs-triage(see Labels above). - Assign when the owner is obvious; otherwise leave it for a human.
- Leave a short triage note as a comment when the categorization isn't self-explanatory.
5. Bulk Operations
There is no batch endpoint — loop over issue numbers client-side.
gh:
# Close every issue carrying the "wontfix" label as not-planned.
gh issue list --label "wontfix" --state open --json number --jq '.[].number' \
| xargs -I {} gh issue close {} --reason "not planned"
curl:
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues?labels=wontfix&state=open&per_page=100" \
| jq -r '.[] | select(.pull_request|not) | .number' \
| while read -r n; do
curl -s -X PATCH -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues/$n" \
-d '{"state":"closed","state_reason":"not_planned"}' >/dev/null
echo "closed #$n"
done
Quick Reference
| Action | gh | curl endpoint |
|---|---|---|
| List | gh issue list |
GET /repos/{o}/{r}/issues |
| View | gh issue view N |
GET /repos/{o}/{r}/issues/N |
| Search | gh issue list --search "…" |
GET /search/issues?q=…+repo:{o}/{r} |
| Create | gh issue create … |
POST /repos/{o}/{r}/issues |
| Add labels | gh issue edit N --add-label … |
POST /repos/{o}/{r}/issues/N/labels |
| Assign | gh issue edit N --add-assignee … |
POST /repos/{o}/{r}/issues/N/assignees |
| Comment | gh issue comment N --body … |
POST /repos/{o}/{r}/issues/N/comments |
| Close | gh issue close N --reason … |
PATCH /repos/{o}/{r}/issues/N |
| Reopen | gh issue reopen N |
PATCH /repos/{o}/{r}/issues/N |
Limitations
- The REST
/issuescollection also returns pull requests; filter withselect(.pull_request|not)(curl) —gh issuealready excludes them. - Search is eventually consistent: a just-created issue may not appear in
/search/issuesfor a few seconds. List endpoints reflect it immediately. - No batch mutation endpoint — bulk changes loop one issue at a time and are subject to the REST secondary rate limit.
- No public attachment-upload API — embed images via the
issue-assetsbranch + raw URL (see "Attaching an image to an issue").
Rules
- Prefer
ghwhen it is authenticated; only drop to curl whenghcannot be installed or authenticated. Ifghis missing, install it yourself (Setup step 1) — don't ask the user to. Only the interactivegh auth login(Setup step 2) goes to the user; don't try to drive that sign-in headlessly. - Confirm intent before any bulk close/relabel or any single destructive change — these are side-effecting and have no undo.
- Never put a token on a command line or in an issue/comment body; let
ghuse its stored credential, or passGITHUB_TOKENvia the environment for curl. - Use closing keywords (
Closes #N) in PR bodies rather than manually closing an issue that a merge will resolve. - Quote issue numbers verbatim in replies (
#128) so GitHub deeplinks resolve them.