diff --git a/.github/workflows/issue-lifecycle-comment.yml b/.github/workflows/issue-lifecycle-comment.yml new file mode 100644 index 00000000..75b1a035 --- /dev/null +++ b/.github/workflows/issue-lifecycle-comment.yml @@ -0,0 +1,27 @@ +name: "Issue Lifecycle Comment" + +on: + issues: + types: [labeled] + +permissions: + issues: write + +jobs: + comment: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Post lifecycle comment + run: bun run scripts/lifecycle-comment.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LABEL: ${{ github.event.label.name }} + ISSUE_NUMBER: ${{ github.event.issue.number }} diff --git a/scripts/issue-lifecycle.ts b/scripts/issue-lifecycle.ts new file mode 100644 index 00000000..304b520c --- /dev/null +++ b/scripts/issue-lifecycle.ts @@ -0,0 +1,38 @@ +// Single source of truth for issue lifecycle labels, timeouts, and messages. + +export const lifecycle = [ + { + label: "invalid", + days: 3, + reason: "this doesn't appear to be about Claude Code", + nudge: "This doesn't appear to be about [Claude Code](https://github.com/anthropics/claude-code). For general Anthropic support, visit [support.anthropic.com](https://support.anthropic.com).", + }, + { + label: "needs-repro", + days: 7, + reason: "we still need reproduction steps to investigate", + nudge: "We weren't able to reproduce this. Could you provide steps to trigger the issue — what you ran, what happened, and what you expected?", + }, + { + 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?", + }, + { + label: "stale", + days: 14, + reason: "inactive for too long", + nudge: "This issue has been automatically marked as stale due to inactivity.", + }, + { + label: "autoclose", + days: 14, + reason: "inactive for too long", + nudge: "This issue has been marked for automatic closure.", + }, +] as const; + +export type LifecycleLabel = (typeof lifecycle)[number]["label"]; + +export const STALE_UPVOTE_THRESHOLD = 10; diff --git a/scripts/lifecycle-comment.ts b/scripts/lifecycle-comment.ts new file mode 100644 index 00000000..3edbae7c --- /dev/null +++ b/scripts/lifecycle-comment.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env bun + +// 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"; + +const DRY_RUN = process.argv.includes("--dry-run"); +const token = process.env.GITHUB_TOKEN; +const repo = process.env.GITHUB_REPOSITORY; // owner/repo +const label = process.env.LABEL; +const issueNumber = process.env.ISSUE_NUMBER; + +if (!DRY_RUN && !token) throw new Error("GITHUB_TOKEN required"); +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); +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.`; + +// -- + +if (DRY_RUN) { + console.log(`Would comment on #${issueNumber} for label "${label}":\n\n${body}`); + process.exit(0); +} + +const response = await fetch( + `https://api.github.com/repos/${repo}/issues/${issueNumber}/comments`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + "User-Agent": "lifecycle-comment", + }, + body: JSON.stringify({ body }), + } +); + +if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub API ${response.status}: ${text}`); +} + +console.log(`Commented on #${issueNumber} for label "${label}"`); diff --git a/scripts/sweep.ts b/scripts/sweep.ts index 2ebd5318..41d09ac3 100644 --- a/scripts/sweep.ts +++ b/scripts/sweep.ts @@ -1,23 +1,15 @@ #!/usr/bin/env bun +import { lifecycle, STALE_UPVOTE_THRESHOLD } from "./issue-lifecycle.ts"; + // -- const NEW_ISSUE = "https://github.com/anthropics/claude-code/issues/new/choose"; const DRY_RUN = process.argv.includes("--dry-run"); -const STALE_DAYS = 14; -const STALE_UPVOTE_THRESHOLD = 10; const CLOSE_MESSAGE = (reason: string) => `Closing for now — ${reason}. Please [open a new issue](${NEW_ISSUE}) if this is still relevant.`; -const lifecycle = [ - { label: "invalid", days: 3, reason: "this doesn't appear to be about Claude Code" }, - { label: "needs-repro", days: 7, reason: "we still need reproduction steps to investigate" }, - { label: "needs-info", days: 7, reason: "we still need a bit more information to move forward" }, - { label: "stale", days: 14, reason: "inactive for too long" }, - { label: "autoclose", days: 14, reason: "inactive for too long" }, -]; - // -- async function githubRequest( @@ -51,12 +43,13 @@ async function githubRequest( // -- async function markStale(owner: string, repo: string) { + const staleDays = lifecycle.find((l) => l.label === "stale")!.days; const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - STALE_DAYS); + cutoff.setDate(cutoff.getDate() - staleDays); let labeled = 0; - console.log(`\n=== marking stale (${STALE_DAYS}d inactive) ===`); + console.log(`\n=== marking stale (${staleDays}d inactive) ===`); for (let page = 1; page <= 10; page++) { const issues = await githubRequest(