Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/commands/triage-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/claude-issue-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions scripts/flag-empty-generated-issue.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<T>;
}

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(
", "
)}`
);
71 changes: 71 additions & 0 deletions scripts/issue-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
74 changes: 73 additions & 1 deletion scripts/issue-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(/<!--[\s\S]*?-->/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
);
}
6 changes: 3 additions & 3 deletions scripts/lifecycle-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)!;

// --

Expand Down