spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

Unify issue lifecycle labeling and sweep into a single system (#25352)Browse files
* Unify issue lifecycle labeling and sweep into a single system

Consolidate issue triage, stale detection, and lifecycle enforcement into
two components: a Claude-powered triage workflow and a unified sweep script.

Triage workflow changes:
- Add issue_comment trigger so Claude can re-evaluate lifecycle labels when
  someone responds to a needs-repro/needs-info issue
- Add concurrency group per issue with cancel-in-progress to avoid pile-up
- Filter out bot comments to prevent sweep/dedupe triggering re-triage
- Hardcode allowed label whitelist to prevent label sprawl (was discovering
  labels via gh label list, leading to junk variants like 'needs repro' vs
  'needs-repro')
- Replace MCP GitHub server with gh CLI — simpler, no Docker dependency,
  chaining is caught by the action so permissions are equivalent
- Add lifecycle labels (needs-repro, needs-info) for bugs missing info
- Add invalid label for off-topic issues (Claude API, billing, etc.)
- Add anti-patterns to prevent false positives (don't require specific
  format, model behavior issues don't need traditional repro, etc.)

Sweep script changes:
- Absorb stale issue detection (was separate stale-issue-manager workflow)
- Mark issues as stale after 14 days of inactivity
- Skip assigned issues (team is working on it internally)
- Skip enhancements with 10+ thumbs up (community wants it)
- Add invalid label with 3-day timeout
- Add autoclose label support to drain 200+ legacy issues
- Drop needs-votes (stale handles inactive enhancements)
- Unify close messages into a single template with per-label reasons
- Run 2x daily instead of once

Delete stale-issue-manager.yml — its logic is now in sweep.ts.

## Test plan

Dry-run sweep locally:
GITHUB_TOKEN=$(gh auth token) GITHUB_REPOSITORY_OWNER=anthropics   GITHUB_REPOSITORY_NAME=claude-code bun run scripts/sweep.ts --dry-run

Triage workflow will be tested by opening a test issue after merge.

* Update .github/workflows/claude-issue-triage.yml

Co-authored-by: Ashwin Bhat <ashwin@anthropic.com>

---------

Co-authored-by: Ashwin Bhat <ashwin@anthropic.com>
Chris Lloyd authored on Feb 12, 2026 · GitHub committed on Feb 12, 2026 parent 0931fb7 · commit a93966285e5d
4 changed files+141 -242compared with 0931fb7
M.github/workflows/claude-issue-triage.yml
D.github/workflows/stale-issue-manager.yml
M.github/workflows/sweep.yml
Mscripts/sweep.ts
@@ -1,13 +1,20 @@
11 name: Claude Issue Triage
2-description: Automatically triage GitHub issues using Claude Code
32 on:
43 issues:
54 types: [opened]
5+ issue_comment:
6+ types: [created]
67
78 jobs:
89 triage-issue:
910 runs-on: ubuntu-latest
1011 timeout-minutes: 10
12+ if: >-
13+ github.event_name == 'issues' ||
14+ (github.event_name == 'issue_comment' && !github.event.issue.pull_request && github.event.comment.user.type != 'Bot')
15+ concurrency:
16+ group: issue-triage-${{ github.event.issue.number }}
17+ cancel-in-progress: true
1118 permissions:
1219 contents: read
1320 issues: write
@@ -17,30 +24,6 @@
1724 - name: Checkout repository
1825 uses: actions/checkout@v4
1926
20- - name: Setup GitHub MCP Server
21- run: |
22- mkdir -p /tmp/mcp-config
23- cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
24- {
25- "mcpServers": {
26- "github": {
27- "command": "docker",
28- "args": [
29- "run",
30- "-i",
31- "--rm",
32- "-e",
33- "GITHUB_PERSONAL_ACCESS_TOKEN",
34- "ghcr.io/github/github-mcp-server:sha-7aced2b"
35- ],
36- "env": {
37- "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
38- }
39- }
40- }
41- }
42- EOF
43-
4427 - name: Run Claude Code for Issue Triage
4528 timeout-minutes: 5
4629 uses: anthropics/claude-code-action@v1
@@ -50,56 +33,72 @@
5033 github_token: ${{ secrets.GITHUB_TOKEN }}
5134 allowed_non_write_users: "*"
5235 prompt: |
53- You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
36+ You're an issue triage assistant. Analyze the issue and manage labels.
5437
55- IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
38+ IMPORTANT: Don't post any comments or messages to the issue. Your only actions are adding or removing labels.
5639
57- Issue Information:
40+ Context:
5841 - REPO: ${{ github.repository }}
5942 - ISSUE_NUMBER: ${{ github.event.issue.number }}
43+ - EVENT: ${{ github.event_name }}
6044
61- TASK OVERVIEW:
45+ ALLOWED LABELS — you may ONLY use labels from this list. Never invent new labels.
6246
63- 1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
47+ Type: bug, enhancement, question, documentation, duplicate, invalid
48+ Lifecycle: needs-repro, needs-info
49+ Platform: platform:linux, platform:macos, platform:windows, platform:wsl, platform:ios, platform:android, platform:vscode, platform:intellij, platform:web, platform:aws-bedrock
50+ API: api:bedrock, api:vertex
6451
65- 2. Next, use the GitHub tools to get context about the issue:
66- - You have access to these tools:
67- - mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
68- - mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
69- - mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
70- - mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
71- - mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
72- - Start by using mcp__github__get_issue to get the issue details
52+ TOOLS:
53+ - `gh issue view NUMBER`: Read the issue title, body, and labels
54+ - `gh issue view NUMBER --comments`: Read the conversation
55+ - `gh search issues QUERY`: Find similar or duplicate issues
56+ - `gh issue edit NUMBER --add-label` / `--remove-label`: Add or remove labels
7357
74- 3. Analyze the issue content, considering:
75- - The issue title and description
76- - The type of issue (bug report, feature request, question, etc.)
77- - Technical areas mentioned
78- - Severity or priority indicators
79- - User impact
80- - Components affected
58+ TASK:
8159
82- 4. Select appropriate labels from the available labels list provided above:
83- - Choose labels that accurately reflect the issue's nature
84- - Be specific but comprehensive
85- - Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
86- - Consider platform labels (android, ios) if applicable
87- - If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
60+ 1. Run `gh issue view ${{ github.event.issue.number }}` to read the issue details.
61+ 2. Run `gh issue view ${{ github.event.issue.number }} --comments` to read the conversation.
8862
89- 5. Apply the selected labels:
90- - Use mcp__github__update_issue to apply your selected labels
91- - DO NOT post any comments explaining your decision
92- - DO NOT communicate directly with users
93- - If no labels are clearly applicable, do not apply any labels
63+ **If EVENT is "issues" (new issue):**
9464
95- IMPORTANT GUIDELINES:
96- - Be thorough in your analysis
97- - Only select labels from the provided list above
65+ 3. First, check if this issue is actually about Claude Code (the CLI/IDE tool). Issues about the Claude API, claude.ai, the Claude app, Anthropic billing, or other Anthropic products should be labeled `invalid`. If invalid, apply only that label and stop.
66+
67+ 4. Analyze and apply category labels:
68+ - Type (bug, enhancement, question, etc.)
69+ - Technical areas and platform
70+ - Check for duplicates with `gh search issues`. Only mark as duplicate of OPEN issues.
71+
72+ 5. Evaluate lifecycle labels:
73+ - `needs-repro` (bugs only, 7 days): Bug reports without clear steps to reproduce. A good repro has specific, followable steps that someone else could use to see the same issue.
74+ Do NOT apply if the user already provided error messages, logs, file paths, or a description of what they did. Don't require a specific format — narrative descriptions count.
75+ For model behavior issues (e.g. "Claude does X when it should do Y"), don't require traditional repro steps — examples and patterns are sufficient.
76+ - `needs-info` (bugs only, 7 days): The issue needs something from the community before it can progress — e.g. error messages, versions, environment details, or answers to follow-up questions. Don't apply to questions or enhancements.
77+ Do NOT apply if the user already provided version, environment, and error details. If the issue just needs engineering investigation, that's not `needs-info`.
78+
79+ Issues with these labels are automatically closed after the timeout if there's no response.
80+ The goal is to avoid issues lingering without a clear next step.
81+
82+ 6. Apply all selected labels:
83+ `gh issue edit ${{ github.event.issue.number }} --add-label "label1" --add-label "label2"`
84+
85+ **If EVENT is "issue_comment" (comment on existing issue):**
86+
87+ 3. Evaluate lifecycle labels based on the full conversation:
88+ - If the issue has `needs-repro` or `needs-info` and the missing information has now been provided, remove the label:
89+ `gh issue edit ${{ github.event.issue.number }} --remove-label "needs-repro"`
90+ - If the issue doesn't have lifecycle labels but clearly needs them (e.g., a maintainer asked for repro steps or more details), add the appropriate label.
91+ - Comments like "+1", "me too", "same here", or emoji reactions are NOT the missing information. Only remove labels when substantive details are actually provided.
92+ - Do NOT add or remove category labels (bug, enhancement, etc.) on comment events.
93+
94+ GUIDELINES:
95+ - ONLY use labels from the ALLOWED LABELS list above — never create or guess label names
9896 - DO NOT post any comments to the issue
99- - Your ONLY action should be to apply labels using mcp__github__update_issue
97+ - Be conservative with lifecycle labels — only apply when clearly warranted
98+ - Only apply lifecycle labels (`needs-repro`, `needs-info`) to bugs — never to questions or enhancements
99+ - When in doubt, don't apply a lifecycle label — false positives are worse than missing labels
100100 - It's okay to not add any labels if none are clearly applicable
101101 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
102102 claude_args: |
103- --model claude-sonnet-4-5-20250929
104- --mcp-config /tmp/mcp-config/mcp-servers.json
105- --allowedTools "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
103+ --model claude-opus-4-6
104+ --allowedTools "Bash(gh issue view:*),Bash(gh issue edit:*),Bash(gh search issues:*)"
D.github/workflows/stale-issue-manager.yml+0 -157
@@ -1,157 +1,0 @@
1-name: "Manage Stale Issues"
2-
3-on:
4- schedule:
5- # 2am Pacific = 9am UTC (10am UTC during DST)
6- - cron: "0 10 * * *"
7- workflow_dispatch:
8-
9-permissions:
10- issues: write
11-
12-concurrency:
13- group: stale-issue-manager
14-
15-jobs:
16- manage-stale-issues:
17- runs-on: ubuntu-latest
18- steps:
19- - name: Manage stale issues
20- uses: actions/github-script@v7
21- with:
22- script: |
23- const oneMonthAgo = new Date();
24- oneMonthAgo.setDate(oneMonthAgo.getDate() - 30);
25-
26- const twoMonthsAgo = new Date();
27- twoMonthsAgo.setDate(twoMonthsAgo.getDate() - 60);
28-
29- const warningComment = `This issue has been inactive for 30 days. If the issue is still occurring, please comment to let us know. Otherwise, this issue will be automatically closed in 30 days for housekeeping purposes.`;
30-
31- const closingComment = `This issue has been automatically closed due to 60 days of inactivity. If you're still experiencing this issue, please open a new issue with updated information.`;
32-
33- let page = 1;
34- let hasMore = true;
35- let totalWarned = 0;
36- let totalClosed = 0;
37- let totalLabeled = 0;
38-
39- while (hasMore) {
40- // Get open issues sorted by last updated (oldest first)
41- const { data: issues } = await github.rest.issues.listForRepo({
42- owner: context.repo.owner,
43- repo: context.repo.repo,
44- state: 'open',
45- sort: 'updated',
46- direction: 'asc',
47- per_page: 100,
48- page: page
49- });
50-
51- if (issues.length === 0) {
52- hasMore = false;
53- break;
54- }
55-
56- for (const issue of issues) {
57- // Skip if already locked
58- if (issue.locked) continue;
59-
60- // Skip pull requests
61- if (issue.pull_request) continue;
62-
63- // Check if updated more recently than 30 days ago
64- const updatedAt = new Date(issue.updated_at);
65- if (updatedAt > oneMonthAgo) {
66- // Since issues are sorted by updated_at ascending,
67- // once we hit a recent issue, all remaining will be recent too
68- hasMore = false;
69- break;
70- }
71-
72- // Check if issue has autoclose label
73- const hasAutocloseLabel = issue.labels.some(label =>
74- typeof label === 'object' && label.name === 'autoclose'
75- );
76-
77- try {
78- // Get comments to check for existing warning
79- const { data: comments } = await github.rest.issues.listComments({
80- owner: context.repo.owner,
81- repo: context.repo.repo,
82- issue_number: issue.number,
83- per_page: 100
84- });
85-
86- // Find the last comment from github-actions bot
87- const botComments = comments.filter(comment =>
88- comment.user && comment.user.login === 'github-actions[bot]' &&
89- comment.body && comment.body.includes('inactive for 30 days')
90- );
91-
92- const lastBotComment = botComments[botComments.length - 1];
93-
94- if (lastBotComment) {
95- // Check if the bot comment is older than 30 days (total 60 days of inactivity)
96- const botCommentDate = new Date(lastBotComment.created_at);
97- if (botCommentDate < oneMonthAgo) {
98- // Close the issue - it's been stale for 60+ days
99- console.log(`Closing issue #${issue.number} (stale for 60+ days): ${issue.title}`);
100-
101- // Post closing comment
102- await github.rest.issues.createComment({
103- owner: context.repo.owner,
104- repo: context.repo.repo,
105- issue_number: issue.number,
106- body: closingComment
107- });
108-
109- // Close the issue
110- await github.rest.issues.update({
111- owner: context.repo.owner,
112- repo: context.repo.repo,
113- issue_number: issue.number,
114- state: 'closed',
115- state_reason: 'not_planned'
116- });
117-
118- totalClosed++;
119- }
120- // If bot comment exists but is recent, issue already has warning
121- } else if (updatedAt < oneMonthAgo) {
122- // No bot warning yet, issue is 30+ days old
123- console.log(`Warning issue #${issue.number} (stale for 30+ days): ${issue.title}`);
124-
125- // Post warning comment
126- await github.rest.issues.createComment({
127- owner: context.repo.owner,
128- repo: context.repo.repo,
129- issue_number: issue.number,
130- body: warningComment
131- });
132-
133- totalWarned++;
134-
135- // Add autoclose label if not present
136- if (!hasAutocloseLabel) {
137- await github.rest.issues.addLabels({
138- owner: context.repo.owner,
139- repo: context.repo.repo,
140- issue_number: issue.number,
141- labels: ['autoclose']
142- });
143- totalLabeled++;
144- }
145- }
146- } catch (error) {
147- console.error(`Failed to process issue #${issue.number}: ${error.message}`);
148- }
149- }
150-
151- page++;
152- }
153-
154- console.log(`Summary:`);
155- console.log(`- Issues warned (30 days stale): ${totalWarned}`);
156- console.log(`- Issues labeled with autoclose: ${totalLabeled}`);
157- console.log(`- Issues closed (60 days stale): ${totalClosed}`);
@@ -1,8 +1,8 @@
1-name: "Daily Issue Sweep"
1+name: "Issue Sweep"
22
33 on:
44 schedule:
5- - cron: "0 10 * * *" # 2am Pacific
5+ - cron: "0 10,22 * * *"
66 workflow_dispatch:
77
88 permissions:
@@ -4,21 +4,20 @@
44
55 const NEW_ISSUE = "https://github.com/anthropics/claude-code/issues/new/choose";
66 const DRY_RUN = process.argv.includes("--dry-run");
7+const STALE_DAYS = 14;
8+const STALE_UPVOTE_THRESHOLD = 10;
79
10+const CLOSE_MESSAGE = (reason: string) =>
11+ `Closing for now — ${reason}. Please [open a new issue](${NEW_ISSUE}) if this is still relevant.`;
12+
813 const lifecycle = [
9- { label: "needs-repro", days: 7 },
10- { label: "needs-info", days: 7 },
11- { label: "needs-votes", days: 30 },
12- { label: "stale", days: 30 },
14+ { label: "invalid", days: 3, reason: "this doesn't appear to be about Claude Code" },
15+ { label: "needs-repro", days: 7, reason: "we still need reproduction steps to investigate" },
16+ { label: "needs-info", days: 7, reason: "we still need a bit more information to move forward" },
17+ { label: "stale", days: 14, reason: "inactive for too long" },
18+ { label: "autoclose", days: 14, reason: "inactive for too long" },
1319 ];
1420
15-const closeMessages: Record<string, string> = {
16- "needs-repro": `Closing — we weren't able to get the reproduction steps needed to investigate.\n\nIf this is still a problem, please [open a new issue](${NEW_ISSUE}) with steps to reproduce.`,
17- "needs-info": `Closing — we didn't receive the information needed to move forward.\n\nIf this is still a problem, please [open a new issue](${NEW_ISSUE}) with the requested details.`,
18- "needs-votes": `Closing this feature request — it didn't get enough community support to prioritize.\n\nIf you'd still like to see this, please [open a new feature request](${NEW_ISSUE}) with more context about the use case.`,
19- stale: `Closing due to inactivity.\n\nIf this is still a problem, please [open a new issue](${NEW_ISSUE}) with up-to-date information.`,
20-};
21-
2221 // --
2322
2423 async function githubRequest<T>(
@@ -51,17 +50,59 @@
5150
5251 // --
5352
54-async function main() {
55- const owner = process.env.GITHUB_REPOSITORY_OWNER;
56- const repo = process.env.GITHUB_REPOSITORY_NAME;
57- if (!owner || !repo)
58- throw new Error("GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required");
53+async function markStale(owner: string, repo: string) {
54+ const cutoff = new Date();
55+ cutoff.setDate(cutoff.getDate() - STALE_DAYS);
5956
60- if (DRY_RUN) console.log("DRY RUN — no issues will be closed\n");
57+ let labeled = 0;
6158
59+ console.log(`\n=== marking stale (${STALE_DAYS}d inactive) ===`);
60+
61+ for (let page = 1; page <= 10; page++) {
62+ const issues = await githubRequest<any[]>(
63+ `/repos/${owner}/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`
64+ );
65+ if (issues.length === 0) break;
66+
67+ for (const issue of issues) {
68+ if (issue.pull_request) continue;
69+ if (issue.locked) continue;
70+ if (issue.assignees?.length > 0) continue;
71+
72+ const updatedAt = new Date(issue.updated_at);
73+ if (updatedAt > cutoff) return labeled;
74+
75+ const alreadyStale = issue.labels?.some(
76+ (l: any) => l.name === "stale" || l.name === "autoclose"
77+ );
78+ if (alreadyStale) continue;
79+
80+ const isEnhancement = issue.labels?.some(
81+ (l: any) => l.name === "enhancement"
82+ );
83+ const thumbsUp = issue.reactions?.["+1"] ?? 0;
84+ if (isEnhancement && thumbsUp >= STALE_UPVOTE_THRESHOLD) continue;
85+
86+ const base = `/repos/${owner}/${repo}/issues/${issue.number}`;
87+
88+ if (DRY_RUN) {
89+ const age = Math.floor((Date.now() - updatedAt.getTime()) / 86400000);
90+ console.log(`#${issue.number}: would label stale (${age}d inactive) — ${issue.title}`);
91+ } else {
92+ await githubRequest(`${base}/labels`, "POST", { labels: ["stale"] });
93+ console.log(`#${issue.number}: labeled stale — ${issue.title}`);
94+ }
95+ labeled++;
96+ }
97+ }
98+
99+ return labeled;
100+}
101+
102+async function closeExpired(owner: string, repo: string) {
62103 let closed = 0;
63104
64- for (const { label, days } of lifecycle) {
105+ for (const { label, days, reason } of lifecycle) {
65106 const cutoff = new Date();
66107 cutoff.setDate(cutoff.getDate() - days);
67108 console.log(`\n=== ${label} (${days}d timeout) ===`);
@@ -89,7 +130,7 @@
89130 const age = Math.floor((Date.now() - labeledAt.getTime()) / 86400000);
90131 console.log(`#${issue.number}: would close (${label}, ${age}d old) — ${issue.title}`);
91132 } else {
92- await githubRequest(`${base}/comments`, "POST", { body: closeMessages[label] });
133+ await githubRequest(`${base}/comments`, "POST", { body: CLOSE_MESSAGE(reason) });
93134 await githubRequest(base, "PATCH", { state: "closed", state_reason: "not_planned" });
94135 console.log(`#${issue.number}: closed (${label})`);
95136 }
@@ -98,9 +139,25 @@
98139 }
99140 }
100141
101- console.log(`\nDone: ${closed} ${DRY_RUN ? "would be closed" : "closed"}`);
142+ return closed;
102143 }
103144
145+// --
146+
147+async function main() {
148+ const owner = process.env.GITHUB_REPOSITORY_OWNER;
149+ const repo = process.env.GITHUB_REPOSITORY_NAME;
150+ if (!owner || !repo)
151+ throw new Error("GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required");
152+
153+ if (DRY_RUN) console.log("DRY RUN — no changes will be made\n");
154+
155+ const labeled = await markStale(owner, repo);
156+ const closed = await closeExpired(owner, repo);
157+
158+ console.log(`\nDone: ${labeled} ${DRY_RUN ? "would be labeled" : "labeled"} stale, ${closed} ${DRY_RUN ? "would be closed" : "closed"}`);
159+}
160+
104161 main().catch(console.error);
105162
106163 export {};