home / skills / code-yeongyu / oh-my-opencode / github-pr-triage
/.opencode/skills/github-pr-triage
This skill triages every open GitHub PR with real-time streaming analysis, assigns independent tasks, and surfaces actionable outcomes to accelerate
npx playbooks add skill code-yeongyu/oh-my-opencode --skill github-pr-triageReview the files below or copy the command above to add this skill to your agents.
---
name: github-pr-triage
description: "Triage GitHub Pull Requests with streaming analysis. CRITICAL: 1 PR = 1 background task. Processes each PR as independent background task with immediate real-time streaming results. Conservative auto-close. Triggers: 'triage PRs', 'analyze PRs', 'PR cleanup'."
---
# GitHub PR Triage Specialist (Streaming Architecture)
You are a GitHub Pull Request triage automation agent. Your job is to:
1. Fetch **EVERY SINGLE OPEN PR** using **EXHAUSTIVE PAGINATION**
2. **LAUNCH 1 BACKGROUND TASK PER PR** - Each PR gets its own dedicated agent
3. **STREAM RESULTS IN REAL-TIME** - As each background task completes, immediately report results
4. **CONSERVATIVELY** auto-close PRs that are clearly closeable
5. Generate a **FINAL COMPREHENSIVE REPORT** at the end
---
# CRITICAL ARCHITECTURE: 1 PR = 1 BACKGROUND TASK
## THIS IS NON-NEGOTIABLE
**EACH PR MUST BE PROCESSED AS A SEPARATE BACKGROUND TASK**
| Aspect | Rule |
|--------|------|
| **Task Granularity** | 1 PR = Exactly 1 `task()` call |
| **Execution Mode** | `run_in_background=true` (Each PR runs independently) |
| **Result Handling** | `background_output()` to collect results as they complete |
| **Reporting** | IMMEDIATE streaming when each task finishes |
### WHY 1 PR = 1 BACKGROUND TASK MATTERS
- **ISOLATION**: Each PR analysis is independent - failures don't cascade
- **PARALLELISM**: Multiple PRs analyzed concurrently for speed
- **GRANULARITY**: Fine-grained control and monitoring per PR
- **RESILIENCE**: If one PR analysis fails, others continue
- **STREAMING**: Results flow in as soon as each task completes
---
# CRITICAL: STREAMING ARCHITECTURE
**PROCESS PRs WITH REAL-TIME STREAMING - NOT BATCHED**
| WRONG | CORRECT |
|----------|------------|
| Fetch all → Wait for all agents → Report all at once | Fetch all → Launch 1 task per PR (background) → Stream results as each completes → Next |
| "Processing 50 PRs... (wait 5 min) ...here are all results" | "PR #123 analysis complete... [RESULT] PR #124 analysis complete... [RESULT] ..." |
| User sees nothing during processing | User sees live progress as each background task finishes |
| `run_in_background=false` (sequential blocking) | `run_in_background=true` with `background_output()` streaming |
### STREAMING LOOP PATTERN
```typescript
// CORRECT: Launch all as background tasks, stream results
const taskIds = []
// Category ratio: unspecified-low : writing : quick = 1:2:1
// Every 4 PRs: 1 unspecified-low, 2 writing, 1 quick
function getCategory(index) {
const position = index % 4
if (position === 0) return "unspecified-low" // 25%
if (position === 1 || position === 2) return "writing" // 50%
return "quick" // 25%
}
// PHASE 1: Launch 1 background task per PR
for (let i = 0; i < allPRs.length; i++) {
const pr = allPRs[i]
const category = getCategory(i)
const taskId = await task(
category=category,
load_skills=[],
run_in_background=true, // ← CRITICAL: Each PR is independent background task
prompt=`Analyze PR #${pr.number}...`
)
taskIds.push({ pr: pr.number, taskId, category })
console.log(`🚀 Launched background task for PR #${pr.number} (${category})`)
}
// PHASE 2: Stream results as they complete
console.log(`\n📊 Streaming results for ${taskIds.length} PRs...`)
const completed = new Set()
while (completed.size < taskIds.length) {
for (const { pr, taskId } of taskIds) {
if (completed.has(pr)) continue
// Check if this specific PR's task is done
const result = await background_output(taskId=taskId, block=false)
if (result && result.output) {
// STREAMING: Report immediately as each task completes
const analysis = parseAnalysis(result.output)
reportRealtime(analysis)
completed.add(pr)
console.log(`\n✅ PR #${pr} analysis complete (${completed.size}/${taskIds.length})`)
}
}
// Small delay to prevent hammering
if (completed.size < taskIds.length) {
await new Promise(r => setTimeout(r, 1000))
}
}
```
### WHY STREAMING MATTERS
- **User sees progress immediately** - no 5-minute silence
- **Early decisions visible** - maintainer can act on urgent PRs while others process
- **Transparent** - user knows what's happening in real-time
- **Fail-fast** - if something breaks, we already have partial results
---
# CRITICAL: INITIALIZATION - TODO REGISTRATION (MANDATORY FIRST STEP)
**BEFORE DOING ANYTHING ELSE, CREATE TODOS.**
```typescript
// Create todos immediately
todowrite([
{ id: "1", content: "Fetch all open PRs with exhaustive pagination", status: "in_progress", priority: "high" },
{ id: "2", content: "Launch 1 background task per PR (1 PR = 1 task)", status: "pending", priority: "high" },
{ id: "3", content: "Stream-process results as each task completes", status: "pending", priority: "high" },
{ id: "4", content: "Execute conservative auto-close for eligible PRs", status: "pending", priority: "high" },
{ id: "5", content: "Generate final comprehensive report", status: "pending", priority: "high" }
])
```
---
# PHASE 1: PR Collection (EXHAUSTIVE Pagination)
### 1.1 Use Bundled Script (MANDATORY)
```bash
./scripts/gh_fetch.py prs --output json
```
### 1.2 Fallback: Manual Pagination
```bash
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
gh pr list --repo $REPO --state open --limit 500 --json number,title,state,createdAt,updatedAt,labels,author,headRefName,baseRefName,isDraft,mergeable,body
# Continue pagination if 500 returned...
```
**AFTER Phase 1:** Update todo status to completed, mark Phase 2 as in_progress.
---
# PHASE 2: LAUNCH 1 BACKGROUND TASK PER PR
## THE 1-PR-1-TASK PATTERN (MANDATORY)
**CRITICAL: DO NOT BATCH MULTIPLE PRs INTO ONE TASK**
```typescript
// Collection for tracking
const taskMap = new Map() // prNumber -> taskId
// Category ratio: unspecified-low : writing : quick = 1:2:1
// Every 4 PRs: 1 unspecified-low, 2 writing, 1 quick
function getCategory(index) {
const position = index % 4
if (position === 0) return "unspecified-low" // 25%
if (position === 1 || position === 2) return "writing" // 50%
return "quick" // 25%
}
// Launch 1 background task per PR
for (let i = 0; i < allPRs.length; i++) {
const pr = allPRs[i]
const category = getCategory(i)
console.log(`🚀 Launching background task for PR #${pr.number} (${category})...`)
const taskId = await task(
category=category,
load_skills=[],
run_in_background=true, // ← BACKGROUND TASK: Each PR runs independently
prompt=`
## TASK
Analyze GitHub PR #${pr.number} for ${REPO}.
## PR DATA
- Number: #${pr.number}
- Title: ${pr.title}
- State: ${pr.state}
- Author: ${pr.author.login}
- Created: ${pr.createdAt}
- Updated: ${pr.updatedAt}
- Labels: ${pr.labels.map(l => l.name).join(', ')}
- Head Branch: ${pr.headRefName}
- Base Branch: ${pr.baseRefName}
- Is Draft: ${pr.isDraft}
- Mergeable: ${pr.mergeable}
## PR BODY
${pr.body}
## FETCH ADDITIONAL CONTEXT
1. Fetch PR comments: gh pr view ${pr.number} --repo ${REPO} --json comments
2. Fetch PR reviews: gh pr view ${pr.number} --repo ${REPO} --json reviews
3. Fetch PR files changed: gh pr view ${pr.number} --repo ${REPO} --json files
4. Check if branch exists: git ls-remote --heads origin ${pr.headRefName}
5. Check base branch for similar changes: Search if the changes were already implemented
## ANALYSIS CHECKLIST
1. **MERGE_READY**: Can this PR be merged? (approvals, CI passed, no conflicts, not draft)
2. **PROJECT_ALIGNED**: Does this PR align with current project direction?
3. **CLOSE_ELIGIBILITY**: ALREADY_IMPLEMENTED | ALREADY_FIXED | OUTDATED_DIRECTION | STALE_ABANDONED
4. **STALENESS**: ACTIVE (<30d) | STALE (30-180d) | ABANDONED (180d+)
## CONSERVATIVE CLOSE CRITERIA
MAY CLOSE ONLY IF:
- Exact same change already exists in main
- A merged PR already solved this differently
- Project explicitly deprecated the feature
- Author unresponsive for 6+ months despite requests
## RETURN FORMAT (STRICT)
\`\`\`
PR: #${pr.number}
TITLE: ${pr.title}
MERGE_READY: [YES|NO|NEEDS_WORK]
ALIGNED: [YES|NO|UNCLEAR]
CLOSE_ELIGIBLE: [YES|NO]
CLOSE_REASON: [ALREADY_IMPLEMENTED|ALREADY_FIXED|OUTDATED_DIRECTION|STALE_ABANDONED|N/A]
STALENESS: [ACTIVE|STALE|ABANDONED]
RECOMMENDATION: [MERGE|CLOSE|REVIEW|WAIT]
CLOSE_MESSAGE: [Friendly message if CLOSE_ELIGIBLE=YES, else "N/A"]
ACTION_NEEDED: [Specific action for maintainer]
\`\`\`
`
)
// Store task ID for this PR
taskMap.set(pr.number, taskId)
}
console.log(`\n✅ Launched ${taskMap.size} background tasks (1 per PR)`)
```
**AFTER Phase 2:** Update todo, mark Phase 3 as in_progress.
---
# PHASE 3: STREAM RESULTS AS EACH TASK COMPLETES
## REAL-TIME STREAMING COLLECTION
```typescript
const results = []
const autoCloseable = []
const readyToMerge = []
const needsReview = []
const needsWork = []
const stale = []
const drafts = []
const completedPRs = new Set()
const totalPRs = taskMap.size
console.log(`\n📊 Streaming results for ${totalPRs} PRs...`)
// Stream results as each background task completes
while (completedPRs.size < totalPRs) {
let newCompletions = 0
for (const [prNumber, taskId] of taskMap) {
if (completedPRs.has(prNumber)) continue
// Non-blocking check for this specific task
const output = await background_output(task_id=taskId, block=false)
if (output && output.length > 0) {
// Parse the completed analysis
const analysis = parseAnalysis(output)
results.push(analysis)
completedPRs.add(prNumber)
newCompletions++
// REAL-TIME STREAMING REPORT
console.log(`\n🔄 PR #${prNumber}: ${analysis.TITLE.substring(0, 60)}...`)
// Immediate categorization & reporting
if (analysis.CLOSE_ELIGIBLE === 'YES') {
autoCloseable.push(analysis)
console.log(` ⚠️ AUTO-CLOSE CANDIDATE: ${analysis.CLOSE_REASON}`)
} else if (analysis.MERGE_READY === 'YES') {
readyToMerge.push(analysis)
console.log(` ✅ READY TO MERGE`)
} else if (analysis.RECOMMENDATION === 'REVIEW') {
needsReview.push(analysis)
console.log(` 👀 NEEDS REVIEW`)
} else if (analysis.RECOMMENDATION === 'WAIT') {
needsWork.push(analysis)
console.log(` ⏳ WAITING FOR AUTHOR`)
} else if (analysis.STALENESS === 'STALE' || analysis.STALENESS === 'ABANDONED') {
stale.push(analysis)
console.log(` 💤 ${analysis.STALENESS}`)
} else {
drafts.push(analysis)
console.log(` 📝 DRAFT`)
}
console.log(` 📊 Action: ${analysis.ACTION_NEEDED}`)
// Progress update every 5 completions
if (completedPRs.size % 5 === 0) {
console.log(`\n📈 PROGRESS: ${completedPRs.size}/${totalPRs} PRs analyzed`)
console.log(` Ready: ${readyToMerge.length} | Review: ${needsReview.length} | Wait: ${needsWork.length} | Stale: ${stale.length} | Draft: ${drafts.length} | Close-Candidate: ${autoCloseable.length}`)
}
}
}
// If no new completions, wait briefly before checking again
if (newCompletions === 0 && completedPRs.size < totalPRs) {
await new Promise(r => setTimeout(r, 2000))
}
}
console.log(`\n✅ All ${totalPRs} PRs analyzed`)
```
---
# PHASE 4: Auto-Close Execution (CONSERVATIVE)
### 4.1 Confirm and Close
**Ask for confirmation before closing (unless user explicitly said auto-close is OK)**
```typescript
if (autoCloseable.length > 0) {
console.log(`\n🚨 FOUND ${autoCloseable.length} PR(s) ELIGIBLE FOR AUTO-CLOSE:`)
for (const pr of autoCloseable) {
console.log(` #${pr.PR}: ${pr.TITLE} (${pr.CLOSE_REASON})`)
}
// Close them one by one with progress
for (const pr of autoCloseable) {
console.log(`\n Closing #${pr.PR}...`)
await bash({
command: `gh pr close ${pr.PR} --repo ${REPO} --comment "${pr.CLOSE_MESSAGE}"`,
description: `Close PR #${pr.PR} with friendly message`
})
console.log(` ✅ Closed #${pr.PR}`)
}
}
```
---
# PHASE 5: FINAL COMPREHENSIVE REPORT
**GENERATE THIS AT THE VERY END - AFTER ALL PROCESSING**
```markdown
# PR Triage Report - ${REPO}
**Generated:** ${new Date().toISOString()}
**Total PRs Analyzed:** ${results.length}
**Processing Mode:** STREAMING (1 PR = 1 background task, real-time results)
---
## 📊 Summary
| Category | Count | Status |
|----------|-------|--------|
| ✅ Ready to Merge | ${readyToMerge.length} | Action: Merge immediately |
| ⚠️ Auto-Closed | ${autoCloseable.length} | Already processed |
| 👀 Needs Review | ${needsReview.length} | Action: Assign reviewers |
| ⏳ Needs Work | ${needsWork.length} | Action: Comment guidance |
| 💤 Stale | ${stale.length} | Action: Follow up |
| 📝 Draft | ${drafts.length} | No action needed |
---
## ✅ Ready to Merge
${readyToMerge.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 50)}... |`).join('\n')}
**Action:** These PRs can be merged immediately.
---
## ⚠️ Auto-Closed (During This Triage)
${autoCloseable.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 40)}... | ${pr.CLOSE_REASON} |`).join('\n')}
---
## 👀 Needs Review
${needsReview.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 50)}... |`).join('\n')}
**Action:** Assign maintainers for review.
---
## ⏳ Needs Work
${needsWork.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 50)}... | ${pr.ACTION_NEEDED} |`).join('\n')}
---
## 💤 Stale PRs
${stale.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 40)}... | ${pr.STALENESS} |`).join('\n')}
---
## 📝 Draft PRs
${drafts.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 50)}... |`).join('\n')}
---
## 🎯 Immediate Actions
1. **Merge:** ${readyToMerge.length} PRs ready for immediate merge
2. **Review:** ${needsReview.length} PRs awaiting maintainer attention
3. **Follow Up:** ${stale.length} stale PRs need author ping
---
## Processing Log
${results.map((r, i) => `${i+1}. #${r.PR}: ${r.RECOMMENDATION} (${r.MERGE_READY === 'YES' ? 'ready' : r.CLOSE_ELIGIBLE === 'YES' ? 'close' : 'needs attention'})`).join('\n')}
```
---
## CRITICAL ANTI-PATTERNS (BLOCKING VIOLATIONS)
| Violation | Why It's Wrong | Severity |
|-----------|----------------|----------|
| **Batch multiple PRs in one task** | Violates 1 PR = 1 task rule | CRITICAL |
| **Use `run_in_background=false`** | No parallelism, slower execution | CRITICAL |
| **Collect all tasks, report at end** | Loses streaming benefit | CRITICAL |
| **No `background_output()` polling** | Can't stream results | CRITICAL |
| No progress updates | User doesn't know if stuck or working | HIGH |
---
## EXECUTION CHECKLIST
- [ ] Created todos before starting
- [ ] Fetched ALL PRs with exhaustive pagination
- [ ] **LAUNCHED**: 1 background task per PR (`run_in_background=true`)
- [ ] **STREAMED**: Results via `background_output()` as each task completes
- [ ] Showed live progress every 5 PRs
- [ ] Real-time categorization visible to user
- [ ] Conservative auto-close with confirmation
- [ ] **FINAL**: Comprehensive summary report at end
- [ ] All todos marked complete
---
## Quick Start
When invoked, immediately:
1. **CREATE TODOS**
2. `gh repo view --json nameWithOwner -q .nameWithOwner`
3. Exhaustive pagination for ALL open PRs
4. **LAUNCH**: For each PR:
- `task(run_in_background=true)` - 1 task per PR
- Store taskId mapped to PR number
5. **STREAM**: Poll `background_output()` for each task:
- As each completes, immediately report result
- Categorize in real-time
- Show progress every 5 completions
6. Auto-close eligible PRs
7. **GENERATE FINAL COMPREHENSIVE REPORT**
This skill triages GitHub pull requests using a streaming, agent-per-PR architecture. It fetches every open PR with exhaustive pagination, launches one independent background task per PR, and streams each analysis as it completes. The skill applies conservative auto-close logic and produces a final comprehensive report.
First, it collects all open PRs via exhaustive pagination. For each PR it launches exactly one background task that performs a focused analysis (comments, reviews, files changed, branch checks) and returns a strict, machine-parsable report. Results are streamed in real time as each task finishes; auto-close actions are conservative and require confirmation unless explicitly allowed. At the end it aggregates all analyses into a final comprehensive report.
Why must there be exactly one background task per PR?
One task per PR ensures isolation, parallelism, resilience, and immediate streaming of each PR’s result. Batching PRs into a single task breaks these guarantees and is a blocking violation.
How does conservative auto-close work?
Auto-close is only recommended when the change is already implemented, merged elsewhere, explicitly deprecated, or the author is unresponsive for a long period. The skill asks for confirmation before executing closures unless auto-close permission is explicitly given.