diff --git a/scripts/auto-close-duplicates.ts b/scripts/auto-close-duplicates.ts new file mode 100644 index 0000000..7b27ae4 --- /dev/null +++ b/scripts/auto-close-duplicates.ts @@ -0,0 +1,277 @@ +#!/usr/bin/env bun + +declare global { + var process: { + env: Record; + }; +} + +interface GitHubIssue { + number: number; + title: string; + user: { id: number }; + created_at: string; +} + +interface GitHubComment { + id: number; + body: string; + created_at: string; + user: { type: string; id: number }; +} + +interface GitHubReaction { + user: { id: number }; + content: string; +} + +async function githubRequest( + endpoint: string, + token: string, + method: string = "GET", + body?: any, +): Promise { + const response = await fetch(`https://api.github.com${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "auto-close-duplicates-script", + ...(body && { "Content-Type": "application/json" }), + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText}`, + ); + } + + return response.json(); +} + +function extractDuplicateIssueNumber(commentBody: string): number | null { + let match = commentBody.match(/#(\d+)/); + if (match) { + return parseInt(match[1], 10); + } + + match = commentBody.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/); + if (match) { + return parseInt(match[1], 10); + } + + return null; +} + +async function closeIssueAsDuplicate( + owner: string, + repo: string, + issueNumber: number, + duplicateOfNumber: number, + token: string, +): Promise { + await githubRequest( + `/repos/${owner}/${repo}/issues/${issueNumber}`, + token, + "PATCH", + { + state: "closed", + state_reason: "duplicate", + labels: ["duplicate"], + }, + ); + + await githubRequest( + `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, + token, + "POST", + { + body: `This issue has been automatically closed as a duplicate of #${duplicateOfNumber}. + +If this is incorrect, please re-open this issue or create a new one.`, + }, + ); +} + +async function autoCloseDuplicates(): Promise { + console.log("[DEBUG] Starting auto-close duplicates script"); + + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("GITHUB_TOKEN environment variable is required"); + } + console.log("[DEBUG] GitHub token found"); + + const owner = process.env.GITHUB_REPOSITORY_OWNER || "deepgram"; + const repo = process.env.GITHUB_REPOSITORY_NAME || "cli"; + console.log(`[DEBUG] Repository: ${owner}/${repo}`); + + const threeDaysAgo = new Date(); + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); + console.log( + `[DEBUG] Checking for duplicate comments older than: ${threeDaysAgo.toISOString()}`, + ); + + console.log("[DEBUG] Fetching open issues created more than 3 days ago..."); + const allIssues: GitHubIssue[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const pageIssues: GitHubIssue[] = await githubRequest( + `/repos/${owner}/${repo}/issues?state=open&per_page=${perPage}&page=${page}`, + token, + ); + + if (pageIssues.length === 0) break; + + const oldEnoughIssues = pageIssues.filter( + (issue) => new Date(issue.created_at) <= threeDaysAgo, + ); + + allIssues.push(...oldEnoughIssues); + page++; + + if (page > 20) break; + } + + const issues = allIssues; + console.log(`[DEBUG] Found ${issues.length} open issues`); + + let processedCount = 0; + let candidateCount = 0; + + for (const issue of issues) { + processedCount++; + console.log( + `[DEBUG] Processing issue #${issue.number} (${processedCount}/${issues.length}): ${issue.title}`, + ); + + console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`); + const comments: GitHubComment[] = await githubRequest( + `/repos/${owner}/${repo}/issues/${issue.number}/comments`, + token, + ); + console.log( + `[DEBUG] Issue #${issue.number} has ${comments.length} comments`, + ); + + const dupeComments = comments.filter( + (comment) => + comment.body.includes("Found") && + comment.body.includes("possible duplicate") && + comment.user.type === "Bot", + ); + console.log( + `[DEBUG] Issue #${issue.number} has ${dupeComments.length} duplicate detection comments`, + ); + + if (dupeComments.length === 0) { + console.log( + `[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping`, + ); + continue; + } + + const lastDupeComment = dupeComments[dupeComments.length - 1]; + const dupeCommentDate = new Date(lastDupeComment.created_at); + console.log( + `[DEBUG] Issue #${issue.number} - most recent duplicate comment from: ${dupeCommentDate.toISOString()}`, + ); + + if (dupeCommentDate > threeDaysAgo) { + console.log( + `[DEBUG] Issue #${issue.number} - duplicate comment is too recent, skipping`, + ); + continue; + } + console.log( + `[DEBUG] Issue #${issue.number} - duplicate comment is old enough (${Math.floor( + (Date.now() - dupeCommentDate.getTime()) / (1000 * 60 * 60 * 24), + )} days)`, + ); + + const commentsAfterDupe = comments.filter( + (comment) => new Date(comment.created_at) > dupeCommentDate, + ); + console.log( + `[DEBUG] Issue #${issue.number} - ${commentsAfterDupe.length} comments after duplicate detection`, + ); + + if (commentsAfterDupe.length > 0) { + console.log( + `[DEBUG] Issue #${issue.number} - has activity after duplicate comment, skipping`, + ); + continue; + } + + console.log( + `[DEBUG] Issue #${issue.number} - checking reactions on duplicate comment...`, + ); + const reactions: GitHubReaction[] = await githubRequest( + `/repos/${owner}/${repo}/issues/comments/${lastDupeComment.id}/reactions`, + token, + ); + console.log( + `[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`, + ); + + const authorThumbsDown = reactions.some( + (reaction) => + reaction.user.id === issue.user.id && reaction.content === "-1", + ); + console.log( + `[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}`, + ); + + if (authorThumbsDown) { + console.log( + `[DEBUG] Issue #${issue.number} - author disagreed with duplicate detection, skipping`, + ); + continue; + } + + const duplicateIssueNumber = extractDuplicateIssueNumber( + lastDupeComment.body, + ); + if (!duplicateIssueNumber) { + console.log( + `[DEBUG] Issue #${issue.number} - could not extract duplicate issue number from comment, skipping`, + ); + continue; + } + + candidateCount++; + const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`; + + try { + console.log( + `[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateIssueNumber}: ${issueUrl}`, + ); + await closeIssueAsDuplicate( + owner, + repo, + issue.number, + duplicateIssueNumber, + token, + ); + console.log( + `[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateIssueNumber}`, + ); + } catch (error) { + console.error( + `[ERROR] Failed to close issue #${issue.number} as duplicate: ${error}`, + ); + } + } + + console.log( + `[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates for auto-close`, + ); +} + +autoCloseDuplicates().catch(console.error); + +export {}; diff --git a/scripts/backfill-duplicate-comments.ts b/scripts/backfill-duplicate-comments.ts new file mode 100644 index 0000000..9f128d3 --- /dev/null +++ b/scripts/backfill-duplicate-comments.ts @@ -0,0 +1,235 @@ +#!/usr/bin/env bun + +declare global { + var process: { + env: Record; + }; +} + +interface GitHubIssue { + number: number; + title: string; + state: string; + state_reason?: string; + user: { id: number }; + created_at: string; + closed_at?: string; +} + +interface GitHubComment { + id: number; + body: string; + created_at: string; + user: { type: string; id: number }; +} + +async function githubRequest( + endpoint: string, + token: string, + method: string = "GET", + body?: any, +): Promise { + const response = await fetch(`https://api.github.com${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "backfill-duplicate-comments-script", + ...(body && { "Content-Type": "application/json" }), + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText}`, + ); + } + + return response.json(); +} + +async function triggerDedupeWorkflow( + owner: string, + repo: string, + issueNumber: number, + token: string, + dryRun: boolean = true, +): Promise { + if (dryRun) { + console.log( + `[DRY RUN] Would trigger dedupe workflow for issue #${issueNumber}`, + ); + return; + } + + await githubRequest( + `/repos/${owner}/${repo}/actions/workflows/claude-dedupe-issues.yml/dispatches`, + token, + "POST", + { + ref: "main", + inputs: { + issue_number: issueNumber.toString(), + }, + }, + ); +} + +async function backfillDuplicateComments(): Promise { + console.log("[DEBUG] Starting backfill duplicate comments script"); + + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error(`GITHUB_TOKEN environment variable is required + +Usage: + GITHUB_TOKEN=your_token bun run scripts/backfill-duplicate-comments.ts + +Environment Variables: + GITHUB_TOKEN - GitHub personal access token with repo and actions permissions (required) + GITHUB_REPOSITORY - owner/repo (auto-set by Actions; falls back to deepgram/cli) + DRY_RUN - Set to "false" to actually trigger workflows (default: true for safety) + MAX_ISSUE_NUMBER - Only process issues with numbers less than this value (default: 99999) + MIN_ISSUE_NUMBER - Only process issues with numbers >= this value (default: 1)`); + } + console.log("[DEBUG] GitHub token found"); + + const repoSlug = process.env.GITHUB_REPOSITORY || "deepgram/cli"; + const [owner, repo] = repoSlug.split("/"); + if (!owner || !repo) { + throw new Error( + `GITHUB_REPOSITORY must be owner/repo format, got "${repoSlug}"`, + ); + } + const dryRun = process.env.DRY_RUN !== "false"; + const maxIssueNumber = parseInt(process.env.MAX_ISSUE_NUMBER || "99999", 10); + const minIssueNumber = parseInt(process.env.MIN_ISSUE_NUMBER || "1", 10); + + console.log(`[DEBUG] Repository: ${owner}/${repo}`); + console.log(`[DEBUG] Dry run mode: ${dryRun}`); + console.log( + `[DEBUG] Looking at issues between #${minIssueNumber} and #${maxIssueNumber}`, + ); + + console.log( + `[DEBUG] Fetching issues between #${minIssueNumber} and #${maxIssueNumber}...`, + ); + const allIssues: GitHubIssue[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const pageIssues: GitHubIssue[] = await githubRequest( + `/repos/${owner}/${repo}/issues?state=all&per_page=${perPage}&page=${page}&sort=created&direction=desc`, + token, + ); + + if (pageIssues.length === 0) break; + + const filteredIssues = pageIssues.filter( + (issue) => + issue.number >= minIssueNumber && issue.number < maxIssueNumber, + ); + allIssues.push(...filteredIssues); + + const oldestIssueInPage = pageIssues[pageIssues.length - 1]; + if (oldestIssueInPage && oldestIssueInPage.number >= maxIssueNumber) { + console.log( + `[DEBUG] Oldest issue in page #${page} is #${oldestIssueInPage.number}, continuing...`, + ); + } else if ( + oldestIssueInPage && + oldestIssueInPage.number < minIssueNumber + ) { + console.log( + `[DEBUG] Oldest issue in page #${page} is #${oldestIssueInPage.number}, below minimum, stopping`, + ); + break; + } else if (filteredIssues.length === 0 && pageIssues.length > 0) { + console.log( + `[DEBUG] No issues in page #${page} are in range #${minIssueNumber}-#${maxIssueNumber}, continuing...`, + ); + } + + page++; + + if (page > 200) { + console.log("[DEBUG] Reached page limit, stopping pagination"); + break; + } + } + + console.log( + `[DEBUG] Found ${allIssues.length} issues between #${minIssueNumber} and #${maxIssueNumber}`, + ); + + let processedCount = 0; + let candidateCount = 0; + let triggeredCount = 0; + + for (const issue of allIssues) { + processedCount++; + console.log( + `[DEBUG] Processing issue #${issue.number} (${processedCount}/${allIssues.length}): ${issue.title}`, + ); + + console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`); + const comments: GitHubComment[] = await githubRequest( + `/repos/${owner}/${repo}/issues/${issue.number}/comments`, + token, + ); + console.log( + `[DEBUG] Issue #${issue.number} has ${comments.length} comments`, + ); + + const dupeDetectionComments = comments.filter( + (comment) => + comment.body.includes("Found") && + comment.body.includes("possible duplicate") && + comment.user.type === "Bot", + ); + + console.log( + `[DEBUG] Issue #${issue.number} has ${dupeDetectionComments.length} duplicate detection comments`, + ); + + if (dupeDetectionComments.length > 0) { + console.log( + `[DEBUG] Issue #${issue.number} already has duplicate detection comment, skipping`, + ); + continue; + } + + candidateCount++; + const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`; + + try { + console.log( + `[INFO] ${dryRun ? "[DRY RUN] " : ""}Triggering dedupe workflow for issue #${issue.number}: ${issueUrl}`, + ); + await triggerDedupeWorkflow(owner, repo, issue.number, token, dryRun); + + if (!dryRun) { + console.log( + `[SUCCESS] Successfully triggered dedupe workflow for issue #${issue.number}`, + ); + } + triggeredCount++; + } catch (error) { + console.error( + `[ERROR] Failed to trigger workflow for issue #${issue.number}: ${error}`, + ); + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + console.log( + `[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates without duplicate comments, ${dryRun ? "would trigger" : "triggered"} ${triggeredCount} workflows`, + ); +} + +backfillDuplicateComments().catch(console.error); + +export {}; diff --git a/scripts/gh.sh b/scripts/gh.sh new file mode 100755 index 0000000..f67b448 --- /dev/null +++ b/scripts/gh.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wrapper around gh CLI that only allows specific subcommands and flags. +# All commands are scoped to the current repository via GH_REPO or GITHUB_REPOSITORY. +# +# Usage: +# ./scripts/gh.sh issue view 123 +# ./scripts/gh.sh issue view 123 --comments +# ./scripts/gh.sh issue list --state open --limit 20 +# ./scripts/gh.sh search issues "search query" --limit 10 +# ./scripts/gh.sh label list --limit 100 + +export GH_HOST=github.com + +REPO="${GH_REPO:-${GITHUB_REPOSITORY:-}}" +if [[ -z "$REPO" || "$REPO" == */*/* || "$REPO" != */* ]]; then + echo "Error: GH_REPO or GITHUB_REPOSITORY must be set to owner/repo format (e.g., GITHUB_REPOSITORY=deepgram/cli)" >&2 + exit 1 +fi +export GH_REPO="$REPO" + +ALLOWED_FLAGS=(--comments --state --limit --label) +FLAGS_WITH_VALUES=(--state --limit --label) + +SUB1="${1:-}" +SUB2="${2:-}" +CMD="$SUB1 $SUB2" +case "$CMD" in + "issue view"|"issue list"|"search issues"|"label list") + ;; + *) + echo "Error: only 'issue view', 'issue list', 'search issues', 'label list' are allowed (e.g., ./scripts/gh.sh issue view 123)" >&2 + exit 1 + ;; +esac + +shift 2 + +# Separate flags from positional arguments +POSITIONAL=() +FLAGS=() +skip_next=false +for arg in "$@"; do + if [[ "$skip_next" == true ]]; then + FLAGS+=("$arg") + skip_next=false + elif [[ "$arg" == -* ]]; then + flag="${arg%%=*}" + matched=false + for allowed in "${ALLOWED_FLAGS[@]}"; do + if [[ "$flag" == "$allowed" ]]; then + matched=true + break + fi + done + if [[ "$matched" == false ]]; then + echo "Error: only --comments, --state, --limit, --label flags are allowed (e.g., ./scripts/gh.sh issue list --state open --limit 20)" >&2 + exit 1 + fi + FLAGS+=("$arg") + # If flag expects a value and isn't using = syntax, skip next arg + if [[ "$arg" != *=* ]]; then + for vflag in "${FLAGS_WITH_VALUES[@]}"; do + if [[ "$flag" == "$vflag" ]]; then + skip_next=true + break + fi + done + fi + else + POSITIONAL+=("$arg") + fi +done + +if [[ "$CMD" == "search issues" ]]; then + QUERY="${POSITIONAL[0]:-}" + QUERY_LOWER=$(echo "$QUERY" | tr '[:upper:]' '[:lower:]') + if [[ "$QUERY_LOWER" == *"repo:"* || "$QUERY_LOWER" == *"org:"* || "$QUERY_LOWER" == *"user:"* ]]; then + echo "Error: search query must not contain repo:, org:, or user: qualifiers (e.g., ./scripts/gh.sh search issues \"bug report\" --limit 10)" >&2 + exit 1 + fi + gh "$SUB1" "$SUB2" "$QUERY" --repo "$REPO" "${FLAGS[@]}" +elif [[ "$CMD" == "issue view" ]]; then + if [[ ${#POSITIONAL[@]} -ne 1 ]] || ! [[ "${POSITIONAL[0]}" =~ ^[0-9]+$ ]]; then + echo "Error: issue view requires exactly one numeric issue number (e.g., ./scripts/gh.sh issue view 123)" >&2 + exit 1 + fi + gh "$SUB1" "$SUB2" "${POSITIONAL[0]}" "${FLAGS[@]}" +else + if [[ ${#POSITIONAL[@]} -ne 0 ]]; then + echo "Error: issue list and label list do not accept positional arguments (e.g., ./scripts/gh.sh issue list --state open, ./scripts/gh.sh label list --limit 100)" >&2 + exit 1 + fi + gh "$SUB1" "$SUB2" "${FLAGS[@]}" +fi diff --git a/scripts/issue-lifecycle.ts b/scripts/issue-lifecycle.ts new file mode 100644 index 0000000..6e2cf6c --- /dev/null +++ b/scripts/issue-lifecycle.ts @@ -0,0 +1,43 @@ +// Single source of truth for issue lifecycle labels, timeouts, and messages. +// Borrowed from anthropics/claude-code/scripts/issue-lifecycle.ts and adapted +// for deepgram/cli. + +export const lifecycle = [ + { + label: "invalid", + days: 3, + reason: "this doesn't appear to be about the Deepgram CLI", + nudge: + "This doesn't appear to be about the [Deepgram CLI](https://github.com/deepgram/cli). For general Deepgram support, visit [developers.deepgram.com](https://developers.deepgram.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 Deepgram CLI version (`dg --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 0000000..9f5d564 --- /dev/null +++ b/scripts/lifecycle-comment.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env bun + +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; +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 new file mode 100644 index 0000000..2c881ca --- /dev/null +++ b/scripts/sweep.ts @@ -0,0 +1,176 @@ +#!/usr/bin/env bun + +import { lifecycle, STALE_UPVOTE_THRESHOLD } from "./issue-lifecycle.ts"; + +const NEW_ISSUE = "https://github.com/deepgram/cli/issues/new/choose"; +const DRY_RUN = process.argv.includes("--dry-run"); + +const CLOSE_MESSAGE = (reason: string) => + `Closing for now — ${reason}. Please [open a new issue](${NEW_ISSUE}) if this is still relevant.`; + +async function githubRequest( + endpoint: string, + method = "GET", + body?: unknown, +): Promise { + const token = process.env.GITHUB_TOKEN; + if (!token) throw new Error("GITHUB_TOKEN required"); + + const response = await fetch(`https://api.github.com${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "sweep", + ...(body && { "Content-Type": "application/json" }), + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + if (response.status === 404) return {} as T; + const text = await response.text(); + throw new Error(`GitHub API ${response.status}: ${text}`); + } + + return response.json(); +} + +async function markStale(owner: string, repo: string) { + const staleDays = lifecycle.find((l) => l.label === "stale")!.days; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - staleDays); + + let labeled = 0; + + console.log(`\n=== marking stale (${staleDays}d inactive) ===`); + + for (let page = 1; page <= 10; page++) { + const issues = await githubRequest( + `/repos/${owner}/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`, + ); + if (issues.length === 0) break; + + for (const issue of issues) { + if (issue.pull_request) continue; + if (issue.locked) continue; + if (issue.assignees?.length > 0) continue; + + const updatedAt = new Date(issue.updated_at); + if (updatedAt > cutoff) return labeled; + + const alreadyStale = issue.labels?.some( + (l: any) => l.name === "stale" || l.name === "autoclose", + ); + if (alreadyStale) continue; + + const thumbsUp = issue.reactions?.["+1"] ?? 0; + if (thumbsUp >= STALE_UPVOTE_THRESHOLD) continue; + + const base = `/repos/${owner}/${repo}/issues/${issue.number}`; + + if (DRY_RUN) { + const age = Math.floor((Date.now() - updatedAt.getTime()) / 86400000); + console.log( + `#${issue.number}: would label stale (${age}d inactive) — ${issue.title}`, + ); + } else { + await githubRequest(`${base}/labels`, "POST", { labels: ["stale"] }); + console.log(`#${issue.number}: labeled stale — ${issue.title}`); + } + labeled++; + } + } + + return labeled; +} + +async function closeExpired(owner: string, repo: string) { + let closed = 0; + + for (const { label, days, reason } of lifecycle) { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + console.log(`\n=== ${label} (${days}d timeout) ===`); + + for (let page = 1; page <= 10; page++) { + const issues = await githubRequest( + `/repos/${owner}/${repo}/issues?state=open&labels=${label}&sort=updated&direction=asc&per_page=100&page=${page}`, + ); + if (issues.length === 0) break; + + for (const issue of issues) { + if (issue.pull_request) continue; + if (issue.locked) continue; + + const thumbsUp = issue.reactions?.["+1"] ?? 0; + if (thumbsUp >= STALE_UPVOTE_THRESHOLD) continue; + + const base = `/repos/${owner}/${repo}/issues/${issue.number}`; + + const events = await githubRequest( + `${base}/events?per_page=100`, + ); + + const labeledAt = events + .filter((e) => e.event === "labeled" && e.label?.name === label) + .map((e) => new Date(e.created_at)) + .pop(); + + if (!labeledAt || labeledAt > cutoff) continue; + + // Skip if a non-bot user commented after the label was applied. + // The triage workflow should remove lifecycle labels on human + // activity, but check here too as a safety net. + const comments = await githubRequest( + `${base}/comments?since=${labeledAt.toISOString()}&per_page=100`, + ); + const hasHumanComment = comments.some( + (c) => c.user && c.user.type !== "Bot", + ); + if (hasHumanComment) { + console.log( + `#${issue.number}: skipping (human activity after ${label} label)`, + ); + continue; + } + + if (DRY_RUN) { + const age = Math.floor((Date.now() - labeledAt.getTime()) / 86400000); + console.log( + `#${issue.number}: would close (${label}, ${age}d old) — ${issue.title}`, + ); + } else { + await githubRequest(`${base}/comments`, "POST", { + body: CLOSE_MESSAGE(reason), + }); + await githubRequest(base, "PATCH", { + state: "closed", + state_reason: "not_planned", + }); + console.log(`#${issue.number}: closed (${label})`); + } + closed++; + } + } + } + + return closed; +} + +const owner = process.env.GITHUB_REPOSITORY_OWNER; +const repo = process.env.GITHUB_REPOSITORY_NAME; +if (!owner || !repo) { + throw new Error( + "GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required", + ); +} + +if (DRY_RUN) console.log("DRY RUN — no changes will be made\n"); + +const labeled = await markStale(owner, repo); +const closed = await closeExpired(owner, repo); + +console.log( + `\nDone: ${labeled} ${DRY_RUN ? "would be labeled" : "labeled"} stale, ${closed} ${DRY_RUN ? "would be closed" : "closed"}`, +);