feat(skills): add github-issue-triage skill#20
Conversation
Adds a process skill for auditing open GitHub issues to identify stale, completed, or superseded items via parallel subagent codebase cross-referencing. Includes: - SKILL.md with enumerate→batch→fan-out→synthesize protocol - scripts/fetch-issues.sh: download all open issues to JSON - scripts/explore-issue.sh: cross-reference a single issue against a repo - scripts/close-issues.sh: bulk-close or bulk-comment with --dry-run guard - references/verdict-examples.md: annotated LIKELY/POSSIBLY/STILL examples Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new github-issue-triage skill under the process category, which includes documentation and three bash scripts (fetch-issues.sh, explore-issue.sh, and close-issues.sh) to automate auditing and triaging open GitHub issues. The review feedback highlights several critical robustness improvements for the scripts, such as preventing script crashes from SIGPIPE errors under set -o pipefail when using head, improving keyword extraction logic to handle short or missing terms, using printf instead of echo for portable newline expansion in comments, and handling carriage returns or trailing whitespace in input issue lists.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| BODY=$(jq -r --argjson n "${ISSUE_NUM}" \ | ||
| '.[] | select(.number == $n) | .body' "${ISSUES_JSON}" | head -c 600) |
There was a problem hiding this comment.
Using head -c 600 in a pipeline under set -o pipefail will cause the script to crash with a SIGPIPE error (exit code 141) if the issue body is longer than 600 characters. This is because head exits early after reading 600 bytes, closing the pipe while jq is still writing.
We can avoid this completely and improve performance by truncating the body directly within jq using string slicing.
| BODY=$(jq -r --argjson n "${ISSUE_NUM}" \ | |
| '.[] | select(.number == $n) | .body' "${ISSUES_JSON}" | head -c 600) | |
| BODY=$(jq -r --argjson n "${ISSUE_NUM}" \\ | |
| '.[] | select(.number == $n) | (.body // "") | .[0:600]' "${ISSUES_JSON}") |
| # Derive search keywords from the title (lower-case words >=5 chars) | ||
| KEYWORDS=$(echo "${TITLE}" | tr '[:upper:]' '[:lower:]' \ | ||
| | grep -oE '[a-z]{5,}' | sort -u | head -6 | tr '\n' '|' | sed 's/|$//') | ||
|
|
||
| echo "--- Keywords extracted: ${KEYWORDS} ---" | ||
| echo |
There was a problem hiding this comment.
There are two issues with the current keyword extraction logic:
- If the issue title has no words of 5 or more characters (e.g., "Fix UI"),
KEYWORDSwill be empty. An emptyKEYWORDSpattern causes subsequentgit log --grep=""andgrep -iE ""commands to match every commit and branch, flooding the output with irrelevant results. - Restricting keywords to 5 or more characters and only alphabetic characters (
[a-z]{5,}) discards highly specific technical terms and acronyms (e.g.,auth,cors,saml,ipv6,imds,v2).
We can lower the minimum length to 4, allow alphanumeric characters, and handle the empty KEYWORDS case gracefully in subsequent searches.
| # Derive search keywords from the title (lower-case words >=5 chars) | |
| KEYWORDS=$(echo "${TITLE}" | tr '[:upper:]' '[:lower:]' \ | |
| | grep -oE '[a-z]{5,}' | sort -u | head -6 | tr '\n' '|' | sed 's/|$//') | |
| echo "--- Keywords extracted: ${KEYWORDS} ---" | |
| echo | |
| # Derive search keywords from the title (lower-case alphanumeric words >=4 chars) | |
| KEYWORDS=$(echo "${TITLE}" | tr '[:upper:]' '[:lower:]' \\ | |
| | grep -oE '[a-z0-9]{4,}' | sort -u | head -6 | tr '\\n' '|' | sed 's/|$//') | |
| echo "--- Keywords extracted: ${KEYWORDS:-None} ---" | |
| echo |
| echo "--- All branches mentioning keywords ---" | ||
| git -C "${REPO_PATH}" branch -r 2>/dev/null \ | ||
| | grep -iE "${KEYWORDS}" | head -10 || echo " (none)" | ||
| echo |
There was a problem hiding this comment.
Under set -o pipefail, if git branch -r returns more than 10 matching branches, head -10 will exit early and cause grep to fail with a SIGPIPE (exit code 141). This makes the entire pipeline fail, which triggers the || echo " (none)" fallback. As a result, instead of showing the first 10 branches, the script will incorrectly print (none).
We can fix this by capturing the output into a variable with || true inside the subshell to safely handle the pipe termination.
| echo "--- All branches mentioning keywords ---" | |
| git -C "${REPO_PATH}" branch -r 2>/dev/null \ | |
| | grep -iE "${KEYWORDS}" | head -10 || echo " (none)" | |
| echo | |
| echo "--- All branches mentioning keywords ---" | |
| BRANCHES=$(git -C "${REPO_PATH}" branch -r 2>/dev/null | grep -iE "${KEYWORDS}" | head -10 || true) | |
| if [[ -n "${BRANCHES}" ]]; then | |
| echo "${BRANCHES}" | |
| else | |
| echo " (none)" | |
| fi |
| # echo "1749\n822\n407" | ./close-issues.sh --dry-run <owner/repo> | ||
| # echo "1749\n822\n407" | ./close-issues.sh --close <owner/repo> |
There was a problem hiding this comment.
In standard bash, echo "1749\n822\n407" does not expand \n by default. It will output the literal string 1749\n822\n407 on a single line, which will fail the numeric validation check in the script.
Using printf is much more portable and behaves consistently across different shells and platforms.
| # echo "1749\n822\n407" | ./close-issues.sh --dry-run <owner/repo> | |
| # echo "1749\n822\n407" | ./close-issues.sh --close <owner/repo> | |
| # printf "1749\\n822\\n407\\n" | ./close-issues.sh --dry-run <owner/repo> | |
| # printf "1749\\n822\\n407\\n" | ./close-issues.sh --close <owner/repo> |
| while IFS= read -r ISSUE_NUM; do | ||
| [[ -z "${ISSUE_NUM}" ]] && continue | ||
| [[ "${ISSUE_NUM}" =~ ^[0-9]+$ ]] || { echo "SKIP: '${ISSUE_NUM}' is not a number" >&2; continue; } |
There was a problem hiding this comment.
If the input list of issue numbers contains trailing whitespace or carriage returns (common when files are saved with Windows/DOS line endings \r\n), the regex check [[ "${ISSUE_NUM}" =~ ^[0-9]+$ ]] will fail and skip valid issue numbers.
We can make the input processing more robust by stripping carriage returns and trimming whitespace using tr and xargs.
| while IFS= read -r ISSUE_NUM; do | |
| [[ -z "${ISSUE_NUM}" ]] && continue | |
| [[ "${ISSUE_NUM}" =~ ^[0-9]+$ ]] || { echo "SKIP: '${ISSUE_NUM}' is not a number" >&2; continue; } | |
| while IFS= read -r line; do | |
| ISSUE_NUM=$(echo "${line}" | tr -d '\\r' | xargs) | |
| [[ -z "${ISSUE_NUM}" ]] && continue | |
| [[ "${ISSUE_NUM}" =~ ^[0-9]+$ ]] || { echo "SKIP: '${ISSUE_NUM}' is not a number" >&2; continue; } |
…ath mapping Some organisations use a catch-all issue tracker repo (e.g. mitodl/hq) where product labels route each issue to the actual codebase to check. Without handling this, explore-issue.sh would search the empty tracker repo and produce no evidence, making all verdicts unreliable. Changes: - SKILL.md: adds repo mode table (direct vs tracker), Phase 1b to build label→path map from product label namespace, annotate issues with resolved_path, and updated agent prompt template instructing agents to search resolved_path rather than the tracker repo; adds 'tracker repo issue with no product label' common pattern - explore-issue.sh: accepts optional 4th arg (label-map JSON); resolves the target repo from the issue's labels before searching; falls back to the provided repo-path with a warning when no label matches or the resolved directory doesn't exist; also falls back to repo root when src/ subdirectory is absent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new workflow skill (process/github-issue-triage) to help audit open GitHub issues for staleness/completion/supersession using an enumerate → batch → fan-out → synthesize approach, backed by helper scripts.
Changes:
- Adds a new
github-issue-triageskill definition with a phased triage workflow and verdict rubric. - Introduces three supporting shell scripts to fetch issues, explore a single issue against a repo, and bulk comment/close issues.
- Updates skills indexes and adds example verdict write-ups from a real triage run.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| skills/README.md | Adds the new skill to the top-level skills index. |
| skills/process/README.md | Adds the new skill to the process skills index. |
| skills/process/github-issue-triage/SKILL.md | Defines the triage workflow, phases, and command examples for agents/users. |
| skills/process/github-issue-triage/scripts/fetch-issues.sh | Script to download open issues into a normalized JSON array. |
| skills/process/github-issue-triage/scripts/explore-issue.sh | Script to cross-reference a single issue against local git/code/branches/PRs. |
| skills/process/github-issue-triage/scripts/close-issues.sh | Script to bulk comment/close issues read from STDIN. |
| skills/process/github-issue-triage/references/verdict-examples.md | Example evidence patterns and verdicts from a prior triage run. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| TITLE=$(jq -r --argjson n "${ISSUE_NUM}" \ | ||
| '.[] | select(.number == $n) | .title' "${ISSUES_JSON}") | ||
| CREATED=$(jq -r --argjson n "${ISSUE_NUM}" \ | ||
| '.[] | select(.number == $n) | .createdAt' "${ISSUES_JSON}") | ||
| BODY=$(jq -r --argjson n "${ISSUE_NUM}" \ | ||
| '.[] | select(.number == $n) | .body' "${ISSUES_JSON}" | head -c 600) | ||
| LABELS=$(jq -r --argjson n "${ISSUE_NUM}" \ | ||
| '[.[] | select(.number == $n) | .labels[]] | join(", ")' "${ISSUES_JSON}") | ||
|
|
| echo "--- Recent commits mentioning these keywords ---" | ||
| git -C "${REPO_PATH}" log --oneline --since="${CREATED}" \ | ||
| --grep="${KEYWORDS}" --regexp-ignore-case 2>/dev/null | head -15 || true | ||
| echo |
| rg -r --include="*.py" --include="*.yaml" --include="*.yml" \ | ||
| -l "${kw}" "${SRC_DIR}" 2>/dev/null | head -5 || true |
| echo "--- Remote branches mentioning keywords ---" | ||
| git -C "${REPO_PATH}" branch -r 2>/dev/null \ | ||
| | grep -iE "${KEYWORDS}" | head -10 || echo " (none)" | ||
| echo |
| REPO="${1:?Usage: $0 <owner/repo> [outfile]}" | ||
| OUTFILE="${2:-./issues_full.json}" | ||
|
|
||
| echo "Fetching open issues from ${REPO}..." >&2 | ||
|
|
||
| gh issue list \ | ||
| --repo "${REPO}" \ | ||
| --state open \ | ||
| --limit 500 \ | ||
| --json number,title,body,labels,createdAt,updatedAt \ | ||
| | jq '[.[] | { | ||
| number, | ||
| title, | ||
| body, | ||
| labels: [.labels[].name], | ||
| createdAt: .createdAt[:10], | ||
| updatedAt: .updatedAt[:10] | ||
| }]' \ | ||
| > "${OUTFILE}" |
| ```bash | ||
| # Did the expected artifact get created? | ||
| ls <resolved_path>/src/<expected-path>/ 2>/dev/null | ||
| rg -r "<keyword>" <resolved_path>/src --include="*.py" -l |
| rg -r "<old-tool>" <resolved_path>/src -l | head # expect empty | ||
| rg -r "<new-tool>" <resolved_path>/src -l | head # expect populated |
| # Close or comment on a list of GitHub issues from a triage report. | ||
| # | ||
| # Reads issue numbers from STDIN (one per line) and either: | ||
| # --dry-run prints the actions that would be taken (default) |
What are the relevant tickets?
N/A
Description (What does it do?)
Adds a new
process/github-issue-triageskill for auditing open GitHub issues to identify which are outdated, already completed, or superseded by newer issues.The skill codifies the enumerate → batch → fan-out → synthesize pattern: fetch all open issues, group them by theme, dispatch parallel subagents to cross-reference each batch against the live codebase and git history, then synthesize the results into a tiered close/keep report.
New files:
skills/process/github-issue-triage/SKILL.md— main skill definition covering all five phases, the verdict rubric (LIKELY_OUTDATED / POSSIBLY_OUTDATED / STILL_RELEVANT), and common patterns (technology replaced, superseded epic, active feature branch, bot-maintained issue)scripts/fetch-issues.sh— downloads all open issues from a GitHub repo to a JSON file viagh issue listscripts/explore-issue.sh— cross-references a single issue against a local git repo: derives keywords from the title, runsgit log,rgcode search, remote branch search, and closed PR lookupscripts/close-issues.sh— bulk-closes or bulk-comments on a list of issue numbers; defaults to--dry-run; supports per-groupISSUE_TRIAGE_REASONoverridereferences/verdict-examples.md— annotated examples from a real triage run (59 issues, mitodl/ol-infrastructure) showing what evidence supports each verdict tierThe skill was developed and validated against an actual triage run that identified 14 strong close candidates and 2 trivial quick-win code changes out of 59 open issues.
How can this be tested?
fetch-issues.sh <owner/repo>— verify it produces a valid JSON array.explore-issue.sh <number> <repo-path> issues_full.json— verify it prints commits, code references, and branch hits.echo "9999" | close-issues.sh --dry-run <owner/repo>— verify it prints the dry-run line without touching GitHub.Additional Context
The
explore-issue.shscript derives search keywords automatically from the issue title (lowercase words ≥5 chars) so it requires no manual configuration per issue. Theclose-issues.shscript usesgh issue close --reason completedwhich maps to GitHub's "completed" close reason, keeping the close distinct from "not planned."