diff --git a/.claude/commands/triage-issue.md b/.claude/commands/triage-issue.md index 4d63784bb2..65ef77f77d 100644 --- a/.claude/commands/triage-issue.md +++ b/.claude/commands/triage-issue.md @@ -41,6 +41,7 @@ TASK: - Check for duplicates with `./scripts/gh.sh search issues`. Only mark as duplicate of OPEN issues. 6. Evaluate lifecycle labels: + - Empty generated reports: If the title or body is a placeholder asking the user to provide a bug report or feature request description, or the body only contains generated metadata such as version, OS, terminal, and feedback ID without an actual problem statement, classify it as `bug` and apply `needs-info`. Environment metadata alone is not enough to investigate. Do not mark it `invalid` just because the generated report is incomplete. - `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. 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. 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. diff --git a/.github/workflows/claude-issue-triage.yml b/.github/workflows/claude-issue-triage.yml index 6c667e2d8a..988b8d8534 100644 --- a/.github/workflows/claude-issue-triage.yml +++ b/.github/workflows/claude-issue-triage.yml @@ -25,6 +25,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup Bun + if: github.event_name == 'issues' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 (sha-pinned) + with: + bun-version: latest + + - name: Flag empty generated issue reports + if: github.event_name == 'issues' + run: bun run scripts/flag-empty-generated-issue.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run Claude Code for Issue Triage timeout-minutes: 5 uses: anthropics/claude-code-action@v1 diff --git a/scripts/flag-empty-generated-issue.ts b/scripts/flag-empty-generated-issue.ts new file mode 100644 index 0000000000..8a443d265b --- /dev/null +++ b/scripts/flag-empty-generated-issue.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env bun + +// Labels generated issue reports that contain only metadata and no actionable +// bug description, so the lifecycle comment asks the reporter for details. + +import { + isEmptyGeneratedIssueReport, +} from "./issue-lifecycle.ts"; + +const DRY_RUN = process.argv.includes("--dry-run"); +const token = process.env.GITHUB_TOKEN; +const repo = process.env.GITHUB_REPOSITORY; +const eventPath = process.env.GITHUB_EVENT_PATH; + +if (!DRY_RUN && !token) throw new Error("GITHUB_TOKEN required"); +if (!repo) throw new Error("GITHUB_REPOSITORY required"); +if (!eventPath) throw new Error("GITHUB_EVENT_PATH required"); + +const event = await Bun.file(eventPath).json(); +const issue = event.issue; + +if (!issue?.number) { + console.log("No issue in event payload, skipping"); + process.exit(0); +} + +const labels = new Set( + (issue.labels ?? []).map((label: { name: string } | string) => + typeof label === "string" ? label : label.name + ) +); + +async function githubRequest(endpoint: string, method = "GET", body?: unknown) { + if (!token) throw new Error("GITHUB_TOKEN required"); + + const response = await fetch(`https://api.github.com/repos/${repo}${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + "User-Agent": "flag-empty-generated-issue", + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub API ${response.status}: ${text}`); + } + + return response.json() as Promise; +} + +if ( + !isEmptyGeneratedIssueReport({ + title: issue.title ?? "", + body: issue.body ?? "", + }) +) { + console.log(`#${issue.number}: not an empty generated issue report`); + process.exit(0); +} + +const labelsToApply = ["bug", "needs-info"].filter((label) => !labels.has(label)); + +if (labelsToApply.length === 0) { + console.log(`#${issue.number}: empty generated issue report already labeled`); + process.exit(0); +} + +if (DRY_RUN) { + console.log( + `#${issue.number}: would add labels for empty generated issue report: ${labelsToApply.join( + ", " + )}` + ); + process.exit(0); +} + +await githubRequest(`/issues/${issue.number}/labels`, "POST", { + labels: labelsToApply, +}); + +console.log( + `#${issue.number}: added labels for empty generated issue report: ${labelsToApply.join( + ", " + )}` +); diff --git a/scripts/issue-lifecycle.test.ts b/scripts/issue-lifecycle.test.ts new file mode 100644 index 0000000000..f260bb6650 --- /dev/null +++ b/scripts/issue-lifecycle.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatLifecycleComment, + getLifecycleEntry, + isEmptyGeneratedIssueReport, +} from "./issue-lifecycle.ts"; + +describe("issue lifecycle comments", () => { + test("formats lifecycle comments with the configured timeout", () => { + expect(formatLifecycleComment("needs-repro")).toContain( + "within 7 days" + ); + expect(formatLifecycleComment("not-a-lifecycle-label")).toBeNull(); + }); + + test("needs-info asks for the missing bug description, not just metadata", () => { + const entry = getLifecycleEntry("needs-info"); + const comment = formatLifecycleComment("needs-info"); + + expect(entry?.reason).toContain("more information"); + expect(comment).toContain("actual issue details"); + expect(comment).toContain("what you expected to happen"); + expect(comment).toContain("what happened instead"); + expect(comment).toContain("steps or context to reproduce it"); + expect(comment).toContain( + "version, OS, terminal, and feedback metadata alone are not enough" + ); + }); + + test("detects generated reports with placeholder title and no description", () => { + expect( + isEmptyGeneratedIssueReport({ + title: + "I need a bug report or feature request description to generate a GitHub issue title. Please provide the details of the issue you'd like to report.", + body: `**Bug Description** + + +**Environment Info** +- Platform: linux +- Terminal: gnome-terminal +- Version: 2.1.177 +- Feedback ID: cc995832-bc6a-4d6c-808c-76684557b2c0 + +**Errors** +\`\`\`json +[] +\`\`\` +`, + }) + ).toBe(true); + }); + + test("does not flag generated reports that include an actionable description", () => { + expect( + isEmptyGeneratedIssueReport({ + title: + "I need a bug report or feature request description to generate a GitHub issue title. Please provide the details of the issue you'd like to report.", + body: `**Bug Description** +The terminal freezes after I accept a file edit. It happens every time after running /bug. + +**Environment Info** +- Platform: linux +- Terminal: gnome-terminal +- Version: 2.1.177 +- Feedback ID: cc995832-bc6a-4d6c-808c-76684557b2c0 +`, + }) + ).toBe(false); + }); +}); diff --git a/scripts/issue-lifecycle.ts b/scripts/issue-lifecycle.ts index 304b520c0d..d28be6174e 100644 --- a/scripts/issue-lifecycle.ts +++ b/scripts/issue-lifecycle.ts @@ -17,7 +17,8 @@ export const lifecycle = [ label: "needs-info", days: 7, reason: "we still need a bit more information to move forward", - nudge: "We need more information to continue investigating. Can you make sure to include your Claude Code version (`claude --version`), OS, and any error messages or logs?", + nudge: + "We need the actual issue details before we can investigate. Please reply with a short summary, what you expected to happen, what happened instead, and steps or context to reproduce it. If you used `/bug` or `/feedback`, make sure the description is not blank or a placeholder; version, OS, terminal, and feedback metadata alone are not enough.", }, { label: "stale", @@ -36,3 +37,74 @@ export const lifecycle = [ export type LifecycleLabel = (typeof lifecycle)[number]["label"]; export const STALE_UPVOTE_THRESHOLD = 10; + +const EMPTY_GENERATED_TITLE_PATTERNS = [ + "i need a bug report or feature request description", + "please provide the details of the issue you'd like to report", +]; + +export function getLifecycleEntry(label: string) { + return lifecycle.find((entry) => entry.label === label); +} + +export function formatLifecycleComment(label: string) { + const entry = getLifecycleEntry(label); + if (!entry) return null; + + return `${entry.nudge} This issue will be closed automatically if there's no activity within ${entry.days} days.`; +} + +function extractBoldSection(body: string, heading: string) { + const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = body.match( + new RegExp( + String.raw`\*\*${escapedHeading}\*\*[ \t]*(?:\r?\n)?([\s\S]*?)(?=\r?\n\*\*[^*]+\*\*|$)`, + "i" + ) + ); + + return match?.[1] ?? null; +} + +function hasMeaningfulText(value: string | null) { + if (!value) return false; + + const normalized = value + .replace(/```[\s\S]*?```/g, "") + .replace(//g, "") + .trim() + .toLowerCase(); + + return !["", "n/a", "na", "none", "null", "[]"].includes(normalized); +} + +export function isEmptyGeneratedIssueReport({ + title, + body, +}: { + title: string; + body?: string | null; +}) { + const normalizedTitle = title.trim().toLowerCase(); + const issueBody = body ?? ""; + const hasPlaceholderTitle = EMPTY_GENERATED_TITLE_PATTERNS.some((pattern) => + normalizedTitle.includes(pattern) + ); + const hasGeneratedMetadata = + /\*\*Environment Info\*\*/i.test(issueBody) && /Feedback ID:/i.test(issueBody); + + if (!hasGeneratedMetadata) return false; + + const descriptionSections = [ + extractBoldSection(issueBody, "Bug Description"), + extractBoldSection(issueBody, "Feature Description"), + extractBoldSection(issueBody, "Description"), + ]; + const hasDescriptionSection = descriptionSections.some((section) => section !== null); + const hasMeaningfulDescription = descriptionSections.some(hasMeaningfulText); + + return ( + (hasPlaceholderTitle || hasDescriptionSection) && + !hasMeaningfulDescription + ); +} diff --git a/scripts/lifecycle-comment.ts b/scripts/lifecycle-comment.ts index 3edbae7c5e..d753869ff5 100644 --- a/scripts/lifecycle-comment.ts +++ b/scripts/lifecycle-comment.ts @@ -3,7 +3,7 @@ // Posts a comment when a lifecycle label is applied to an issue, // giving the author a heads-up and a chance to respond before auto-close. -import { lifecycle } from "./issue-lifecycle.ts"; +import { formatLifecycleComment, getLifecycleEntry } from "./issue-lifecycle.ts"; const DRY_RUN = process.argv.includes("--dry-run"); const token = process.env.GITHUB_TOKEN; @@ -16,13 +16,13 @@ if (!repo) throw new Error("GITHUB_REPOSITORY required"); if (!label) throw new Error("LABEL required"); if (!issueNumber) throw new Error("ISSUE_NUMBER required"); -const entry = lifecycle.find((l) => l.label === label); +const entry = getLifecycleEntry(label); if (!entry) { console.log(`No lifecycle entry for label "${label}", skipping`); process.exit(0); } -const body = `${entry.nudge} This issue will be closed automatically if there's no activity within ${entry.days} days.`; +const body = formatLifecycleComment(label)!; // --