.opencode to .agents
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
---
|
||||
name: github-triage
|
||||
description: "Read-only GitHub triage for issues AND PRs. 1 item = 1 background task (category: quick). Analyzes all open items and writes evidence-backed reports to /tmp/{datetime}/. Every claim requires a GitHub permalink as proof. NEVER takes any action on GitHub - no comments, no merges, no closes, no labels. Reports only. Triggers: 'triage', 'triage issues', 'triage PRs', 'github triage'."
|
||||
---
|
||||
|
||||
# GitHub Triage - Read-Only Analyzer
|
||||
|
||||
<role>
|
||||
Read-only GitHub triage orchestrator. Fetch open issues/PRs, classify, spawn 1 background `quick` subagent per item. Each subagent analyzes and writes a report file. ZERO GitHub mutations.
|
||||
</role>
|
||||
|
||||
## Architecture
|
||||
|
||||
**1 ISSUE/PR = 1 `task_create` = 1 `quick` SUBAGENT (background). NO EXCEPTIONS.**
|
||||
|
||||
| Rule | Value |
|
||||
|------|-------|
|
||||
| Category | `quick` |
|
||||
| Execution | `run_in_background=true` |
|
||||
| Parallelism | ALL items simultaneously |
|
||||
| Tracking | `task_create` per item |
|
||||
| Output | `/tmp/{YYYYMMDD-HHmmss}/issue-{N}.md` or `pr-{N}.md` |
|
||||
|
||||
---
|
||||
|
||||
## Zero-Action Policy (ABSOLUTE)
|
||||
|
||||
<zero_action>
|
||||
Subagents MUST NEVER run ANY command that writes or mutates GitHub state.
|
||||
|
||||
**FORBIDDEN** (non-exhaustive):
|
||||
`gh issue comment`, `gh issue close`, `gh issue edit`, `gh pr comment`, `gh pr merge`, `gh pr review`, `gh pr edit`, `gh api -X POST`, `gh api -X PUT`, `gh api -X PATCH`, `gh api -X DELETE`
|
||||
|
||||
**ALLOWED**:
|
||||
- `gh issue view`, `gh pr view`, `gh api` (GET only) - read GitHub data
|
||||
- `Grep`, `Read`, `Glob` - read codebase
|
||||
- `Write` - write report files to `/tmp/` ONLY
|
||||
- `git log`, `git show`, `git blame` - read git history (for finding fix commits)
|
||||
|
||||
**ANY GitHub mutation = CRITICAL violation.**
|
||||
</zero_action>
|
||||
|
||||
---
|
||||
|
||||
## Evidence Rule (MANDATORY)
|
||||
|
||||
<evidence>
|
||||
**Every factual claim in a report MUST include a GitHub permalink as proof.**
|
||||
|
||||
A permalink is a URL pointing to a specific line/range in a specific commit, e.g.:
|
||||
`https://github.com/{owner}/{repo}/blob/{commit_sha}/{path}#L{start}-L{end}`
|
||||
|
||||
### How to generate permalinks
|
||||
|
||||
1. Find the relevant file and line(s) via Grep/Read.
|
||||
2. Get the current commit SHA: `git rev-parse HEAD`
|
||||
3. Construct: `https://github.com/{REPO}/blob/{SHA}/{filepath}#L{line}` (or `#L{start}-L{end}` for ranges)
|
||||
|
||||
### Rules
|
||||
|
||||
- **No permalink = no claim.** If you cannot back a statement with a permalink, state "No evidence found" instead.
|
||||
- Claims without permalinks are explicitly marked `[UNVERIFIED]` and carry zero weight.
|
||||
- Permalinks to `main`/`master`/`dev` branches are NOT acceptable - use commit SHAs only.
|
||||
- For bug analysis: permalink to the problematic code. For fix verification: permalink to the fixing commit diff.
|
||||
</evidence>
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Setup
|
||||
|
||||
```bash
|
||||
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
|
||||
REPORT_DIR="/tmp/$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
```
|
||||
|
||||
Pass `REPO`, `REPORT_DIR`, and `COMMIT_SHA` to every subagent.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Fetch All Open Items (CORRECTED)
|
||||
|
||||
**IMPORTANT:** `body` and `comments` fields may contain control characters that break jq parsing. Fetch basic metadata first, then fetch full details per-item in subagents.
|
||||
|
||||
```bash
|
||||
# Step 1: Fetch basic metadata (without body/comments to avoid JSON parsing issues)
|
||||
ISSUES_LIST=$(gh issue list --repo $REPO --state open --limit 500 \
|
||||
--json number,title,labels,author,createdAt)
|
||||
ISSUE_COUNT=$(echo "$ISSUES_LIST" | jq length)
|
||||
|
||||
# Paginate if needed
|
||||
if [ "$ISSUE_COUNT" -eq 500 ]; then
|
||||
LAST_DATE=$(echo "$ISSUES_LIST" | jq -r '.[-1].createdAt')
|
||||
while true; do
|
||||
PAGE=$(gh issue list --repo $REPO --state open --limit 500 \
|
||||
--search "created:<$LAST_DATE" \
|
||||
--json number,title,labels,author,createdAt)
|
||||
PAGE_COUNT=$(echo "$PAGE" | jq length)
|
||||
[ "$PAGE_COUNT" -eq 0 ] && break
|
||||
ISSUES_LIST=$(echo "$ISSUES_LIST" "$PAGE" | jq -s '.[0] + .[1] | unique_by(.number)')
|
||||
ISSUE_COUNT=$(echo "$ISSUES_LIST" | jq length)
|
||||
[ "$PAGE_COUNT" -lt 500 ] && break
|
||||
LAST_DATE=$(echo "$PAGE" | jq -r '.[-1].createdAt')
|
||||
done
|
||||
fi
|
||||
|
||||
# Same for PRs
|
||||
PRS_LIST=$(gh pr list --repo $REPO --state open --limit 500 \
|
||||
--json number,title,labels,author,headRefName,baseRefName,isDraft,createdAt)
|
||||
PR_COUNT=$(echo "$PRS_LIST" | jq length)
|
||||
|
||||
if [ "$PR_COUNT" -eq 500 ]; then
|
||||
LAST_DATE=$(echo "$PRS_LIST" | jq -r '.[-1].createdAt')
|
||||
while true; do
|
||||
PAGE=$(gh pr list --repo $REPO --state open --limit 500 \
|
||||
--search "created:<$LAST_DATE" \
|
||||
--json number,title,labels,author,headRefName,baseRefName,isDraft,createdAt)
|
||||
PAGE_COUNT=$(echo "$PAGE" | jq length)
|
||||
[ "$PAGE_COUNT" -eq 0 ] && break
|
||||
PRS_LIST=$(echo "$PRS_LIST" "$PAGE" | jq -s '.[0] + .[1] | unique_by(.number)')
|
||||
PR_COUNT=$(echo "$PRS_LIST" | jq length)
|
||||
[ "$PAGE_COUNT" -lt 500 ] && break
|
||||
LAST_DATE=$(echo "$PAGE" | jq -r '.[-1].createdAt')
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Total issues: $ISSUE_COUNT, Total PRs: $PR_COUNT"
|
||||
```
|
||||
|
||||
**LARGE REPOSITORY HANDLING:**
|
||||
If total items exceeds 50, you MUST process ALL items. Use the pagination code above to fetch every single open issue and PR.
|
||||
**DO NOT** sample or limit to 50 items - process the entire backlog.
|
||||
|
||||
Example: If there are 500 open issues, spawn 500 subagents. If there are 1000 open PRs, spawn 1000 subagents.
|
||||
|
||||
**Note:** Background task system will queue excess tasks automatically.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Classify
|
||||
|
||||
| Type | Detection |
|
||||
|------|-----------|
|
||||
| `ISSUE_QUESTION` | `[Question]`, `[Discussion]`, `?`, "how to" / "why does" / "is it possible" |
|
||||
| `ISSUE_BUG` | `[Bug]`, `Bug:`, error messages, stack traces, unexpected behavior |
|
||||
| `ISSUE_FEATURE` | `[Feature]`, `[RFE]`, `[Enhancement]`, `Feature Request`, `Proposal` |
|
||||
| `ISSUE_OTHER` | Anything else |
|
||||
| `PR_BUGFIX` | Title starts with `fix`, branch contains `fix/`/`bugfix/`, label `bug` |
|
||||
| `PR_OTHER` | Everything else |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Spawn Subagents (Individual Tool Calls)
|
||||
|
||||
**CRITICAL: Create tasks ONE BY ONE using individual `task_create` tool calls. NEVER batch or script.**
|
||||
|
||||
For each item, execute these steps sequentially:
|
||||
|
||||
### Step 3.1: Create Task Record
|
||||
```typescript
|
||||
task_create(
|
||||
subject="Triage: #{number} {title}",
|
||||
description="GitHub {issue|PR} triage analysis - {type}",
|
||||
metadata={"type": "{ISSUE_QUESTION|ISSUE_BUG|ISSUE_FEATURE|ISSUE_OTHER|PR_BUGFIX|PR_OTHER}", "number": {number}}
|
||||
)
|
||||
```
|
||||
|
||||
### Step 3.2: Spawn Analysis Subagent (Background)
|
||||
```typescript
|
||||
task(
|
||||
category="quick",
|
||||
run_in_background=true,
|
||||
load_skills=[],
|
||||
prompt=SUBAGENT_PROMPT
|
||||
)
|
||||
```
|
||||
|
||||
**ABSOLUTE RULES for Subagents:**
|
||||
- **ONLY ANALYZE** - Never take action on GitHub (no comments, merges, closes)
|
||||
- **READ-ONLY** - Use tools only for reading code/GitHub data
|
||||
- **WRITE REPORT ONLY** - Output goes to `{REPORT_DIR}/{issue|pr}-{number}.md` via Write tool
|
||||
- **EVIDENCE REQUIRED** - Every claim must have GitHub permalink as proof
|
||||
|
||||
```
|
||||
For each item:
|
||||
1. task_create(subject="Triage: #{number} {title}")
|
||||
2. task(category="quick", run_in_background=true, load_skills=[], prompt=SUBAGENT_PROMPT)
|
||||
3. Store mapping: item_number -> { task_id, background_task_id }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Subagent Prompts
|
||||
|
||||
### Common Preamble (include in ALL subagent prompts)
|
||||
|
||||
```
|
||||
CONTEXT:
|
||||
- Repository: {REPO}
|
||||
- Report directory: {REPORT_DIR}
|
||||
- Current commit SHA: {COMMIT_SHA}
|
||||
|
||||
PERMALINK FORMAT:
|
||||
Every factual claim MUST include a permalink: https://github.com/{REPO}/blob/{COMMIT_SHA}/{filepath}#L{start}-L{end}
|
||||
No permalink = no claim. Mark unverifiable claims as [UNVERIFIED].
|
||||
To get current SHA if needed: git rev-parse HEAD
|
||||
|
||||
ABSOLUTE RULES (violating ANY = critical failure):
|
||||
- NEVER run gh issue comment, gh issue close, gh issue edit
|
||||
- NEVER run gh pr comment, gh pr merge, gh pr review, gh pr edit
|
||||
- NEVER run any gh command with -X POST, -X PUT, -X PATCH, -X DELETE
|
||||
- NEVER run git checkout, git fetch, git pull, git switch, git worktree
|
||||
- Your ONLY writable output: {REPORT_DIR}/{issue|pr}-{number}.md via the Write tool
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
### ISSUE_QUESTION
|
||||
|
||||
```
|
||||
You are analyzing issue #{number} for {REPO}.
|
||||
|
||||
ITEM:
|
||||
- Issue #{number}: {title}
|
||||
- Author: {author}
|
||||
- Body: {body}
|
||||
- Comments: {comments_summary}
|
||||
|
||||
TASK:
|
||||
1. Understand the question.
|
||||
2. Search the codebase (Grep, Read) for the answer.
|
||||
3. For every finding, construct a permalink: https://github.com/{REPO}/blob/{COMMIT_SHA}/{path}#L{N}
|
||||
4. Write report to {REPORT_DIR}/issue-{number}.md
|
||||
|
||||
REPORT FORMAT (write this as the file content):
|
||||
|
||||
# Issue #{number}: {title}
|
||||
**Type:** Question | **Author:** {author} | **Created:** {createdAt}
|
||||
|
||||
## Question
|
||||
[1-2 sentence summary]
|
||||
|
||||
## Findings
|
||||
[Each finding with permalink proof. Example:]
|
||||
- The config is parsed in [`src/config/loader.ts#L42-L58`](https://github.com/{REPO}/blob/{SHA}/src/config/loader.ts#L42-L58)
|
||||
|
||||
## Suggested Answer
|
||||
[Draft answer with code references and permalinks]
|
||||
|
||||
## Confidence: [HIGH | MEDIUM | LOW]
|
||||
[Reason. If LOW: what's missing]
|
||||
|
||||
## Recommended Action
|
||||
[What maintainer should do]
|
||||
|
||||
---
|
||||
REMEMBER: No permalink = no claim. Every code reference needs a permalink.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ISSUE_BUG
|
||||
|
||||
```
|
||||
You are analyzing bug report #{number} for {REPO}.
|
||||
|
||||
ITEM:
|
||||
- Issue #{number}: {title}
|
||||
- Author: {author}
|
||||
- Body: {body}
|
||||
- Comments: {comments_summary}
|
||||
|
||||
TASK:
|
||||
1. Understand: expected behavior, actual behavior, reproduction steps.
|
||||
2. Search the codebase for relevant code. Trace the logic.
|
||||
3. Determine verdict: CONFIRMED_BUG, NOT_A_BUG, ALREADY_FIXED, or UNCLEAR.
|
||||
4. For ALREADY_FIXED: find the fixing commit using git log/git blame. Include the commit SHA and what changed.
|
||||
5. For every finding, construct a permalink.
|
||||
6. Write report to {REPORT_DIR}/issue-{number}.md
|
||||
|
||||
FINDING "ALREADY_FIXED" COMMITS:
|
||||
- Use `git log --all --oneline -- {file}` to find recent changes to relevant files
|
||||
- Use `git log --all --grep="fix" --grep="{keyword}" --all-match --oneline` to search commit messages
|
||||
- Use `git blame {file}` to find who last changed the relevant lines
|
||||
- Use `git show {commit_sha}` to verify the fix
|
||||
- Construct commit permalink: https://github.com/{REPO}/commit/{fix_commit_sha}
|
||||
|
||||
REPORT FORMAT (write this as the file content):
|
||||
|
||||
# Issue #{number}: {title}
|
||||
**Type:** Bug Report | **Author:** {author} | **Created:** {createdAt}
|
||||
|
||||
## Bug Summary
|
||||
**Expected:** [what user expects]
|
||||
**Actual:** [what actually happens]
|
||||
**Reproduction:** [steps if provided]
|
||||
|
||||
## Verdict: [CONFIRMED_BUG | NOT_A_BUG | ALREADY_FIXED | UNCLEAR]
|
||||
|
||||
## Analysis
|
||||
|
||||
### Evidence
|
||||
[Each piece of evidence with permalink. No permalink = mark [UNVERIFIED]]
|
||||
|
||||
### Root Cause (if CONFIRMED_BUG)
|
||||
[Which file, which function, what goes wrong]
|
||||
- Problematic code: [`{path}#L{N}`](permalink)
|
||||
|
||||
### Why Not A Bug (if NOT_A_BUG)
|
||||
[Rigorous proof with permalinks that current behavior is correct]
|
||||
|
||||
### Fix Details (if ALREADY_FIXED)
|
||||
- **Fixed in commit:** [`{short_sha}`](https://github.com/{REPO}/commit/{full_sha})
|
||||
- **Fixed date:** {date}
|
||||
- **What changed:** [description with diff permalink]
|
||||
- **Fixed by:** {author}
|
||||
|
||||
### Blockers (if UNCLEAR)
|
||||
[What prevents determination, what to investigate next]
|
||||
|
||||
## Severity: [LOW | MEDIUM | HIGH | CRITICAL]
|
||||
|
||||
## Affected Files
|
||||
[List with permalinks]
|
||||
|
||||
## Suggested Fix (if CONFIRMED_BUG)
|
||||
[Specific approach: "In {file}#L{N}, change X to Y because Z"]
|
||||
|
||||
## Recommended Action
|
||||
[What maintainer should do]
|
||||
|
||||
---
|
||||
CRITICAL: Claims without permalinks are worthless. If you cannot find evidence, say so explicitly rather than making unverified claims.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ISSUE_FEATURE
|
||||
|
||||
```
|
||||
You are analyzing feature request #{number} for {REPO}.
|
||||
|
||||
ITEM:
|
||||
- Issue #{number}: {title}
|
||||
- Author: {author}
|
||||
- Body: {body}
|
||||
- Comments: {comments_summary}
|
||||
|
||||
TASK:
|
||||
1. Understand the request.
|
||||
2. Search codebase for existing (partial/full) implementations.
|
||||
3. Assess feasibility.
|
||||
4. Write report to {REPORT_DIR}/issue-{number}.md
|
||||
|
||||
REPORT FORMAT (write this as the file content):
|
||||
|
||||
# Issue #{number}: {title}
|
||||
**Type:** Feature Request | **Author:** {author} | **Created:** {createdAt}
|
||||
|
||||
## Request Summary
|
||||
[What the user wants]
|
||||
|
||||
## Existing Implementation: [YES_FULLY | YES_PARTIALLY | NO]
|
||||
[If exists: where, with permalinks to the implementation]
|
||||
|
||||
## Feasibility: [EASY | MODERATE | HARD | ARCHITECTURAL_CHANGE]
|
||||
|
||||
## Relevant Files
|
||||
[With permalinks]
|
||||
|
||||
## Implementation Notes
|
||||
[Approach, pitfalls, dependencies]
|
||||
|
||||
## Recommended Action
|
||||
[What maintainer should do]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ISSUE_OTHER
|
||||
|
||||
```
|
||||
You are analyzing issue #{number} for {REPO}.
|
||||
|
||||
ITEM:
|
||||
- Issue #{number}: {title}
|
||||
- Author: {author}
|
||||
- Body: {body}
|
||||
- Comments: {comments_summary}
|
||||
|
||||
TASK: Assess and write report to {REPORT_DIR}/issue-{number}.md
|
||||
|
||||
REPORT FORMAT (write this as the file content):
|
||||
|
||||
# Issue #{number}: {title}
|
||||
**Type:** [QUESTION | BUG | FEATURE | DISCUSSION | META | STALE]
|
||||
**Author:** {author} | **Created:** {createdAt}
|
||||
|
||||
## Summary
|
||||
[1-2 sentences]
|
||||
|
||||
## Needs Attention: [YES | NO]
|
||||
## Suggested Label: [if any]
|
||||
## Recommended Action: [what maintainer should do]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PR_BUGFIX
|
||||
|
||||
```
|
||||
You are reviewing PR #{number} for {REPO}.
|
||||
|
||||
ITEM:
|
||||
- PR #{number}: {title}
|
||||
- Author: {author}
|
||||
- Base: {baseRefName} <- Head: {headRefName}
|
||||
- Draft: {isDraft} | Mergeable: {mergeable}
|
||||
- Review: {reviewDecision} | CI: {statusCheckRollup_summary}
|
||||
- Body: {body}
|
||||
|
||||
TASK:
|
||||
1. Fetch PR details (READ-ONLY): gh pr view {number} --repo {REPO} --json files,reviews,comments,statusCheckRollup,reviewDecision
|
||||
2. Read diff: gh api repos/{REPO}/pulls/{number}/files
|
||||
3. Search codebase to verify fix correctness.
|
||||
4. Write report to {REPORT_DIR}/pr-{number}.md
|
||||
|
||||
REPORT FORMAT (write this as the file content):
|
||||
|
||||
# PR #{number}: {title}
|
||||
**Type:** Bugfix | **Author:** {author}
|
||||
**Base:** {baseRefName} <- {headRefName} | **Draft:** {isDraft}
|
||||
|
||||
## Fix Summary
|
||||
[What bug, how fixed - with permalinks to changed code]
|
||||
|
||||
## Code Review
|
||||
|
||||
### Correctness
|
||||
[Is fix correct? Root cause addressed? Evidence with permalinks]
|
||||
|
||||
### Side Effects
|
||||
[Risky changes, breaking changes - with permalinks if any]
|
||||
|
||||
### Code Quality
|
||||
[Style, patterns, test coverage]
|
||||
|
||||
## Merge Readiness
|
||||
|
||||
| Check | Status |
|
||||
|-------|--------|
|
||||
| CI | [PASS / FAIL / PENDING] |
|
||||
| Review | [APPROVED / CHANGES_REQUESTED / PENDING / NONE] |
|
||||
| Mergeable | [YES / NO / CONFLICTED] |
|
||||
| Draft | [YES / NO] |
|
||||
| Correctness | [VERIFIED / CONCERNS / UNCLEAR] |
|
||||
| Risk | [NONE / LOW / MEDIUM / HIGH] |
|
||||
|
||||
## Files Changed
|
||||
[List with brief descriptions]
|
||||
|
||||
## Recommended Action: [MERGE | REQUEST_CHANGES | NEEDS_REVIEW | WAIT]
|
||||
[Reasoning with evidence]
|
||||
|
||||
---
|
||||
NEVER merge. NEVER comment. NEVER review. Write to file ONLY.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PR_OTHER
|
||||
|
||||
```
|
||||
You are reviewing PR #{number} for {REPO}.
|
||||
|
||||
ITEM:
|
||||
- PR #{number}: {title}
|
||||
- Author: {author}
|
||||
- Base: {baseRefName} <- Head: {headRefName}
|
||||
- Draft: {isDraft} | Mergeable: {mergeable}
|
||||
- Review: {reviewDecision} | CI: {statusCheckRollup_summary}
|
||||
- Body: {body}
|
||||
|
||||
TASK:
|
||||
1. Fetch PR details (READ-ONLY): gh pr view {number} --repo {REPO} --json files,reviews,comments,statusCheckRollup,reviewDecision
|
||||
2. Read diff: gh api repos/{REPO}/pulls/{number}/files
|
||||
3. Write report to {REPORT_DIR}/pr-{number}.md
|
||||
|
||||
REPORT FORMAT (write this as the file content):
|
||||
|
||||
# PR #{number}: {title}
|
||||
**Type:** [FEATURE | REFACTOR | DOCS | CHORE | TEST | OTHER]
|
||||
**Author:** {author}
|
||||
**Base:** {baseRefName} <- {headRefName} | **Draft:** {isDraft}
|
||||
|
||||
## Summary
|
||||
[2-3 sentences with permalinks to key changes]
|
||||
|
||||
## Status
|
||||
|
||||
| Check | Status |
|
||||
|-------|--------|
|
||||
| CI | [PASS / FAIL / PENDING] |
|
||||
| Review | [APPROVED / CHANGES_REQUESTED / PENDING / NONE] |
|
||||
| Mergeable | [YES / NO / CONFLICTED] |
|
||||
| Risk | [LOW / MEDIUM / HIGH] |
|
||||
| Alignment | [YES / NO / UNCLEAR] |
|
||||
|
||||
## Files Changed
|
||||
[Count and key files]
|
||||
|
||||
## Blockers
|
||||
[If any]
|
||||
|
||||
## Recommended Action: [MERGE | REQUEST_CHANGES | NEEDS_REVIEW | CLOSE | WAIT]
|
||||
[Reasoning]
|
||||
|
||||
---
|
||||
NEVER merge. NEVER comment. NEVER review. Write to file ONLY.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Collect & Update
|
||||
|
||||
Poll `background_output()` per task. As each completes:
|
||||
1. Parse report.
|
||||
2. `task_update(id=task_id, status="completed", description=REPORT_SUMMARY)`
|
||||
3. Stream to user immediately.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Final Summary
|
||||
|
||||
Write to `{REPORT_DIR}/SUMMARY.md` AND display to user:
|
||||
|
||||
```markdown
|
||||
# GitHub Triage Report - {REPO}
|
||||
|
||||
**Date:** {date} | **Commit:** {COMMIT_SHA}
|
||||
**Items Processed:** {total}
|
||||
**Report Directory:** {REPORT_DIR}
|
||||
|
||||
## Issues ({issue_count})
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| Bug Confirmed | {n} |
|
||||
| Bug Already Fixed | {n} |
|
||||
| Not A Bug | {n} |
|
||||
| Needs Investigation | {n} |
|
||||
| Question Analyzed | {n} |
|
||||
| Feature Assessed | {n} |
|
||||
| Other | {n} |
|
||||
|
||||
## PRs ({pr_count})
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| Bugfix Reviewed | {n} |
|
||||
| Other PR Reviewed | {n} |
|
||||
|
||||
## Items Requiring Attention
|
||||
[Each item: number, title, verdict, 1-line summary, link to report file]
|
||||
|
||||
## Report Files
|
||||
[All generated files with paths]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Violation | Severity |
|
||||
|-----------|----------|
|
||||
| ANY GitHub mutation (comment/close/merge/review/label/edit) | **CRITICAL** |
|
||||
| Claim without permalink | **CRITICAL** |
|
||||
| Using category other than `quick` | CRITICAL |
|
||||
| Batching multiple items into one task | CRITICAL |
|
||||
| `run_in_background=false` | CRITICAL |
|
||||
| `git checkout` on PR branch | CRITICAL |
|
||||
| Guessing without codebase evidence | HIGH |
|
||||
| Not writing report to `{REPORT_DIR}` | HIGH |
|
||||
| Using branch name instead of commit SHA in permalink | HIGH |
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "typer>=0.12.0",
|
||||
# "rich>=13.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
GitHub Issues/PRs Fetcher with Exhaustive Pagination.
|
||||
|
||||
Fetches ALL issues and/or PRs from a GitHub repository using gh CLI.
|
||||
Implements proper pagination to ensure no items are missed.
|
||||
|
||||
Usage:
|
||||
./gh_fetch.py issues # Fetch all issues
|
||||
./gh_fetch.py prs # Fetch all PRs
|
||||
./gh_fetch.py all # Fetch both issues and PRs
|
||||
./gh_fetch.py issues --hours 48 # Issues from last 48 hours
|
||||
./gh_fetch.py prs --state open # Only open PRs
|
||||
./gh_fetch.py all --repo owner/repo # Specify repository
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, TaskID
|
||||
from rich.table import Table
|
||||
|
||||
app = typer.Typer(
|
||||
name="gh_fetch",
|
||||
help="Fetch GitHub issues/PRs with exhaustive pagination.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
console = Console()
|
||||
|
||||
BATCH_SIZE = 500 # Maximum allowed by GitHub API
|
||||
|
||||
|
||||
class ItemState(str, Enum):
|
||||
ALL = "all"
|
||||
OPEN = "open"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class OutputFormat(str, Enum):
|
||||
JSON = "json"
|
||||
TABLE = "table"
|
||||
COUNT = "count"
|
||||
|
||||
|
||||
async def run_gh_command(args: list[str]) -> tuple[str, str, int]:
|
||||
"""Run gh CLI command asynchronously."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"gh",
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
return stdout.decode(), stderr.decode(), proc.returncode or 0
|
||||
|
||||
|
||||
async def get_current_repo() -> str:
|
||||
"""Get the current repository from gh CLI."""
|
||||
stdout, stderr, code = await run_gh_command(
|
||||
["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]
|
||||
)
|
||||
if code != 0:
|
||||
console.print(f"[red]Error getting current repo: {stderr}[/red]")
|
||||
raise typer.Exit(1)
|
||||
return stdout.strip()
|
||||
|
||||
|
||||
async def fetch_items_page(
|
||||
repo: str,
|
||||
item_type: str, # "issue" or "pr"
|
||||
state: str,
|
||||
limit: int,
|
||||
search_filter: str = "",
|
||||
) -> list[dict]:
|
||||
"""Fetch a single page of issues or PRs."""
|
||||
cmd = [
|
||||
item_type,
|
||||
"list",
|
||||
"--repo",
|
||||
repo,
|
||||
"--state",
|
||||
state,
|
||||
"--limit",
|
||||
str(limit),
|
||||
"--json",
|
||||
"number,title,state,createdAt,updatedAt,labels,author,body",
|
||||
]
|
||||
if search_filter:
|
||||
cmd.extend(["--search", search_filter])
|
||||
|
||||
stdout, stderr, code = await run_gh_command(cmd)
|
||||
if code != 0:
|
||||
console.print(f"[red]Error fetching {item_type}s: {stderr}[/red]")
|
||||
return []
|
||||
|
||||
try:
|
||||
return json.loads(stdout) if stdout.strip() else []
|
||||
except json.JSONDecodeError:
|
||||
console.print(f"[red]Error parsing {item_type} response[/red]")
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_all_items(
|
||||
repo: str,
|
||||
item_type: str,
|
||||
state: str,
|
||||
hours: int | None,
|
||||
progress: Progress,
|
||||
task_id: TaskID,
|
||||
) -> list[dict]:
|
||||
"""Fetch ALL items with exhaustive pagination."""
|
||||
all_items: list[dict] = []
|
||||
page = 1
|
||||
|
||||
progress.update(task_id, description=f"[cyan]Fetching {item_type}s page {page}...")
|
||||
items = await fetch_items_page(repo, item_type, state, BATCH_SIZE)
|
||||
fetched_count = len(items)
|
||||
all_items.extend(items)
|
||||
|
||||
console.print(f"[dim]Page {page}: fetched {fetched_count} {item_type}s[/dim]")
|
||||
|
||||
while fetched_count == BATCH_SIZE:
|
||||
page += 1
|
||||
progress.update(
|
||||
task_id, description=f"[cyan]Fetching {item_type}s page {page}..."
|
||||
)
|
||||
|
||||
last_created = all_items[-1].get("createdAt", "")
|
||||
if not last_created:
|
||||
break
|
||||
|
||||
search_filter = f"created:<{last_created}"
|
||||
items = await fetch_items_page(
|
||||
repo, item_type, state, BATCH_SIZE, search_filter
|
||||
)
|
||||
fetched_count = len(items)
|
||||
|
||||
if fetched_count == 0:
|
||||
break
|
||||
|
||||
existing_numbers = {item["number"] for item in all_items}
|
||||
new_items = [item for item in items if item["number"] not in existing_numbers]
|
||||
all_items.extend(new_items)
|
||||
|
||||
console.print(
|
||||
f"[dim]Page {page}: fetched {fetched_count}, added {len(new_items)} new (total: {len(all_items)})[/dim]"
|
||||
)
|
||||
|
||||
if page > 20:
|
||||
console.print("[yellow]Safety limit reached (20 pages)[/yellow]")
|
||||
break
|
||||
|
||||
if hours is not None:
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=hours)
|
||||
cutoff_str = cutoff.isoformat()
|
||||
|
||||
original_count = len(all_items)
|
||||
all_items = [
|
||||
item
|
||||
for item in all_items
|
||||
if item.get("createdAt", "") >= cutoff_str
|
||||
or item.get("updatedAt", "") >= cutoff_str
|
||||
]
|
||||
filtered_count = original_count - len(all_items)
|
||||
if filtered_count > 0:
|
||||
console.print(
|
||||
f"[dim]Filtered out {filtered_count} items older than {hours} hours[/dim]"
|
||||
)
|
||||
|
||||
return all_items
|
||||
|
||||
|
||||
def display_table(items: list[dict], item_type: str) -> None:
|
||||
"""Display items in a Rich table."""
|
||||
table = Table(title=f"{item_type.upper()}s ({len(items)} total)")
|
||||
table.add_column("#", style="cyan", width=6)
|
||||
table.add_column("Title", style="white", max_width=50)
|
||||
table.add_column("State", style="green", width=8)
|
||||
table.add_column("Author", style="yellow", width=15)
|
||||
table.add_column("Labels", style="magenta", max_width=30)
|
||||
table.add_column("Updated", style="dim", width=12)
|
||||
|
||||
for item in items[:50]:
|
||||
labels = ", ".join(label.get("name", "") for label in item.get("labels", []))
|
||||
updated = item.get("updatedAt", "")[:10]
|
||||
author = item.get("author", {}).get("login", "unknown")
|
||||
|
||||
table.add_row(
|
||||
str(item.get("number", "")),
|
||||
(item.get("title", "")[:47] + "...")
|
||||
if len(item.get("title", "")) > 50
|
||||
else item.get("title", ""),
|
||||
item.get("state", ""),
|
||||
author,
|
||||
(labels[:27] + "...") if len(labels) > 30 else labels,
|
||||
updated,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if len(items) > 50:
|
||||
console.print(f"[dim]... and {len(items) - 50} more items[/dim]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def issues(
|
||||
repo: Annotated[
|
||||
str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")
|
||||
] = None,
|
||||
state: Annotated[
|
||||
ItemState, typer.Option("--state", "-s", help="Issue state filter")
|
||||
] = ItemState.ALL,
|
||||
hours: Annotated[
|
||||
int | None,
|
||||
typer.Option(
|
||||
"--hours", "-h", help="Only issues from last N hours (created or updated)"
|
||||
),
|
||||
] = None,
|
||||
output: Annotated[
|
||||
OutputFormat, typer.Option("--output", "-o", help="Output format")
|
||||
] = OutputFormat.TABLE,
|
||||
) -> None:
|
||||
"""Fetch all issues with exhaustive pagination."""
|
||||
|
||||
async def async_main() -> None:
|
||||
target_repo = repo or await get_current_repo()
|
||||
|
||||
console.print(f"""
|
||||
[cyan]Repository:[/cyan] {target_repo}
|
||||
[cyan]State:[/cyan] {state.value}
|
||||
[cyan]Time filter:[/cyan] {f"Last {hours} hours" if hours else "All time"}
|
||||
""")
|
||||
|
||||
with Progress(console=console) as progress:
|
||||
task: TaskID = progress.add_task("[cyan]Fetching issues...", total=None)
|
||||
items = await fetch_all_items(
|
||||
target_repo, "issue", state.value, hours, progress, task
|
||||
)
|
||||
progress.update(
|
||||
task, description="[green]Complete!", completed=100, total=100
|
||||
)
|
||||
|
||||
console.print(
|
||||
Panel(f"[green]Found {len(items)} issues[/green]", border_style="green")
|
||||
)
|
||||
|
||||
if output == OutputFormat.JSON:
|
||||
console.print(json.dumps(items, indent=2, ensure_ascii=False))
|
||||
elif output == OutputFormat.TABLE:
|
||||
display_table(items, "issue")
|
||||
else:
|
||||
console.print(f"Total issues: {len(items)}")
|
||||
|
||||
asyncio.run(async_main())
|
||||
|
||||
|
||||
@app.command()
|
||||
def prs(
|
||||
repo: Annotated[
|
||||
str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")
|
||||
] = None,
|
||||
state: Annotated[
|
||||
ItemState, typer.Option("--state", "-s", help="PR state filter")
|
||||
] = ItemState.OPEN,
|
||||
hours: Annotated[
|
||||
int | None,
|
||||
typer.Option(
|
||||
"--hours", "-h", help="Only PRs from last N hours (created or updated)"
|
||||
),
|
||||
] = None,
|
||||
output: Annotated[
|
||||
OutputFormat, typer.Option("--output", "-o", help="Output format")
|
||||
] = OutputFormat.TABLE,
|
||||
) -> None:
|
||||
"""Fetch all PRs with exhaustive pagination."""
|
||||
|
||||
async def async_main() -> None:
|
||||
target_repo = repo or await get_current_repo()
|
||||
|
||||
console.print(f"""
|
||||
[cyan]Repository:[/cyan] {target_repo}
|
||||
[cyan]State:[/cyan] {state.value}
|
||||
[cyan]Time filter:[/cyan] {f"Last {hours} hours" if hours else "All time"}
|
||||
""")
|
||||
|
||||
with Progress(console=console) as progress:
|
||||
task: TaskID = progress.add_task("[cyan]Fetching PRs...", total=None)
|
||||
items = await fetch_all_items(
|
||||
target_repo, "pr", state.value, hours, progress, task
|
||||
)
|
||||
progress.update(
|
||||
task, description="[green]Complete!", completed=100, total=100
|
||||
)
|
||||
|
||||
console.print(
|
||||
Panel(f"[green]Found {len(items)} PRs[/green]", border_style="green")
|
||||
)
|
||||
|
||||
if output == OutputFormat.JSON:
|
||||
console.print(json.dumps(items, indent=2, ensure_ascii=False))
|
||||
elif output == OutputFormat.TABLE:
|
||||
display_table(items, "pr")
|
||||
else:
|
||||
console.print(f"Total PRs: {len(items)}")
|
||||
|
||||
asyncio.run(async_main())
|
||||
|
||||
|
||||
@app.command(name="all")
|
||||
def fetch_all(
|
||||
repo: Annotated[
|
||||
str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")
|
||||
] = None,
|
||||
state: Annotated[
|
||||
ItemState, typer.Option("--state", "-s", help="State filter")
|
||||
] = ItemState.ALL,
|
||||
hours: Annotated[
|
||||
int | None,
|
||||
typer.Option(
|
||||
"--hours", "-h", help="Only items from last N hours (created or updated)"
|
||||
),
|
||||
] = None,
|
||||
output: Annotated[
|
||||
OutputFormat, typer.Option("--output", "-o", help="Output format")
|
||||
] = OutputFormat.TABLE,
|
||||
) -> None:
|
||||
"""Fetch all issues AND PRs with exhaustive pagination."""
|
||||
|
||||
async def async_main() -> None:
|
||||
target_repo = repo or await get_current_repo()
|
||||
|
||||
console.print(f"""
|
||||
[cyan]Repository:[/cyan] {target_repo}
|
||||
[cyan]State:[/cyan] {state.value}
|
||||
[cyan]Time filter:[/cyan] {f"Last {hours} hours" if hours else "All time"}
|
||||
[cyan]Fetching:[/cyan] Issues AND PRs
|
||||
""")
|
||||
|
||||
with Progress(console=console) as progress:
|
||||
issues_task: TaskID = progress.add_task(
|
||||
"[cyan]Fetching issues...", total=None
|
||||
)
|
||||
prs_task: TaskID = progress.add_task("[cyan]Fetching PRs...", total=None)
|
||||
|
||||
issues_items, prs_items = await asyncio.gather(
|
||||
fetch_all_items(
|
||||
target_repo, "issue", state.value, hours, progress, issues_task
|
||||
),
|
||||
fetch_all_items(
|
||||
target_repo, "pr", state.value, hours, progress, prs_task
|
||||
),
|
||||
)
|
||||
|
||||
progress.update(
|
||||
issues_task,
|
||||
description="[green]Issues complete!",
|
||||
completed=100,
|
||||
total=100,
|
||||
)
|
||||
progress.update(
|
||||
prs_task, description="[green]PRs complete!", completed=100, total=100
|
||||
)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Found {len(issues_items)} issues and {len(prs_items)} PRs[/green]",
|
||||
border_style="green",
|
||||
)
|
||||
)
|
||||
|
||||
if output == OutputFormat.JSON:
|
||||
result = {"issues": issues_items, "prs": prs_items}
|
||||
console.print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
elif output == OutputFormat.TABLE:
|
||||
display_table(issues_items, "issue")
|
||||
console.print("")
|
||||
display_table(prs_items, "pr")
|
||||
else:
|
||||
console.print(f"Total issues: {len(issues_items)}")
|
||||
console.print(f"Total PRs: {len(prs_items)}")
|
||||
|
||||
asyncio.run(async_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
Reference in New Issue
Block a user