diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index bd48a3d6..12ed1be5 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -2,7 +2,8 @@ name: Auto-close duplicate issues (DRY RUN) description: Dry run - logs issues that would be auto-closed as duplicates after 3 days if no response on: schedule: - - cron: '0 9 * * *' + - cron: "0 9 * * *" + workflow_dispatch: jobs: auto-close-duplicates: @@ -10,69 +11,20 @@ jobs: timeout-minutes: 10 permissions: contents: read - issues: write + issues: read steps: - - name: Auto-close duplicate issues - uses: actions/github-script@v7 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 with: - script: | - const threeDaysAgo = new Date(); - threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); - - // Get all open issues - const { data: issues } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100 - }); - - for (const issue of issues) { - // Get comments for this issue - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number - }); - - // Find duplicate comments (look for "Found 1 possible duplicate" or "Found X possible duplicate") - const dupeComments = comments.filter(comment => - comment.body.includes('Found') && - comment.body.includes('possible duplicate') && - comment.user.type === 'Bot' - ); - - if (dupeComments.length === 0) continue; - - // Get the most recent duplicate comment - const lastDupeComment = dupeComments[dupeComments.length - 1]; - const dupeCommentDate = new Date(lastDupeComment.created_at); - - // Check if the duplicate comment is 3+ days old - if (dupeCommentDate > threeDaysAgo) continue; - - // Check if there are any comments after the duplicate comment - const commentsAfterDupe = comments.filter(comment => - new Date(comment.created_at) > dupeCommentDate - ); - - if (commentsAfterDupe.length > 0) continue; - - // Check if issue author reacted with thumbs down to the duplicate comment - const { data: reactions } = await github.rest.reactions.listForIssueComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: lastDupeComment.id - }); - - const authorThumbsDown = reactions.some(reaction => - reaction.user.id === issue.user.id && reaction.content === '-1' - ); - - if (authorThumbsDown) continue; - - // DRY RUN: Log the issue that would be auto-closed - const issueUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${issue.number}`; - console.log(`[DRY RUN] Would auto-close issue #${issue.number} as duplicate: ${issueUrl}`); - } \ No newline at end of file + bun-version: latest + + - name: Auto-close duplicate issues + run: bun run scripts/auto-close-duplicates.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} diff --git a/scripts/auto-close-duplicates.ts b/scripts/auto-close-duplicates.ts new file mode 100644 index 00000000..9985ba8c --- /dev/null +++ b/scripts/auto-close-duplicates.ts @@ -0,0 +1,183 @@ +#!/usr/bin/env bun + +declare global { + var process: { + env: Record; + }; +} + +interface GitHubIssue { + number: number; + title: string; + user: { id: number }; +} + +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): Promise { + const response = await fetch(`https://api.github.com${endpoint}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "auto-close-duplicates-script", + }, + }); + + if (!response.ok) { + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText}` + ); + } + + return response.json(); +} + +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 || "anthropics"; + const repo = process.env.GITHUB_REPOSITORY_NAME || "claude-code"; + 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..."); + const issues: GitHubIssue[] = await githubRequest( + `/repos/${owner}/${repo}/issues?state=open&per_page=100`, + token + ); + 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; + } + + candidateCount++; + const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`; + console.log( + `[DRY RUN] Would auto-close issue #${issue.number} as duplicate: ${issueUrl}` + ); + } + + console.log( + `[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates for auto-close` + ); +} + +autoCloseDuplicates().catch(console.error); + +// Make it a module +export {};