CtrlK
BlogDocsLog inGet started
Tessl Logo

github-issues

Create, search, triage, label, assign, comment on, and close GitHub issues using the gh CLI, with a curl REST fallback.

65

Quality

80%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Medium

Suggest reviewing before use

Fix and improve this skill with Tessl

tessl review fix ./skills/dev/github-issues/SKILL.md
SKILL.md
Quality
Evals
Security

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 log directly.

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 login in 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 APIgh 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.

  1. 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}. Use absPath below so git reads the real file regardless of the working directory.

  2. Push the image to issue-assets with pure git plumbing — this never touches the working tree, index, or current branch. git commit-tree takes 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 just mktree the 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-assets
  3. Verify 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 200

    Body markdown: ![alt](https://raw.githubusercontent.com/$OWNER/$REPO/issue-assets/<filename>.png).

  4. Create or comment as usualgh 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:

  1. List the untriaged setgh issue list --label "needs-triage" --state open.
  2. Read each issue in full (gh issue view <n>) to understand whether it's a bug, a feature, or a question.
  3. Apply a type + priority label and remove needs-triage (see Labels above).
  4. Assign when the owner is obvious; otherwise leave it for a human.
  5. 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

Actionghcurl endpoint
Listgh issue listGET /repos/{o}/{r}/issues
Viewgh issue view NGET /repos/{o}/{r}/issues/N
Searchgh issue list --search "…"GET /search/issues?q=…+repo:{o}/{r}
Creategh issue create …POST /repos/{o}/{r}/issues
Add labelsgh issue edit N --add-label …POST /repos/{o}/{r}/issues/N/labels
Assigngh issue edit N --add-assignee …POST /repos/{o}/{r}/issues/N/assignees
Commentgh issue comment N --body …POST /repos/{o}/{r}/issues/N/comments
Closegh issue close N --reason …PATCH /repos/{o}/{r}/issues/N
Reopengh issue reopen NPATCH /repos/{o}/{r}/issues/N

Limitations

  • The REST /issues collection also returns pull requests; filter with select(.pull_request|not) (curl) — gh issue already excludes them.
  • Search is eventually consistent: a just-created issue may not appear in /search/issues for 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-assets branch + raw URL (see "Attaching an image to an issue").

Rules

  1. Prefer gh when it is authenticated; only drop to curl when gh cannot be installed or authenticated. If gh is missing, install it yourself (Setup step 1) — don't ask the user to. Only the interactive gh auth login (Setup step 2) goes to the user; don't try to drive that sign-in headlessly.
  2. Confirm intent before any bulk close/relabel or any single destructive change — these are side-effecting and have no undo.
  3. Never put a token on a command line or in an issue/comment body; let gh use its stored credential, or pass GITHUB_TOKEN via the environment for curl.
  4. Use closing keywords (Closes #N) in PR bodies rather than manually closing an issue that a merge will resolve.
  5. Quote issue numbers verbatim in replies (#128) so GitHub deeplinks resolve them.
Repository
Open-Curiosity/gini-agent
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.