Auto-Resolve Bot Threads #54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auto-Resolve Bot Threads | |
| on: | |
| check_run: | |
| types: [completed] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| auto-resolve: | |
| name: Resolve Stale Bot Threads | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| if: >- | |
| github.event.check_run.name == 'review-agent' && | |
| github.event.check_run.conclusion == 'success' | |
| steps: | |
| - name: Checkout at reviewed SHA | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ github.event.check_run.head_sha }} | |
| - name: Resolve bot-only threads | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const headSha = context.payload.check_run.head_sha; | |
| // Find the PR associated with this check run | |
| const prs = context.payload.check_run.pull_requests || []; | |
| if (prs.length === 0) { | |
| core.info('No PRs associated with this check run — nothing to resolve.'); | |
| return; | |
| } | |
| const prNumber = prs[0].number; | |
| core.info(`Processing PR #${prNumber} at SHA ${headSha.slice(0, 12)}`); | |
| const isBot = (login) => login.endsWith('[bot]') || login === 'github-actions'; | |
| // Safety: verify this check run is for the current HEAD SHA | |
| const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); | |
| if (pr.head.sha !== headSha) { | |
| core.info(`Check run SHA (${headSha.slice(0, 12)}) ≠ PR HEAD (${pr.head.sha.slice(0, 12)}) — stale result, skipping.`); | |
| return; | |
| } | |
| // Fetch all review comments (inline thread comments) | |
| const reviewComments = await github.paginate( | |
| github.rest.pulls.listReviewComments, | |
| { owner, repo, pull_number: prNumber, per_page: 100 }, | |
| ); | |
| // Group comments by thread (in_reply_to_id chains back to root) | |
| const threads = new Map(); | |
| for (const comment of reviewComments) { | |
| const threadId = comment.in_reply_to_id || comment.id; | |
| if (!threads.has(threadId)) threads.set(threadId, []); | |
| threads.get(threadId).push(comment); | |
| } | |
| let resolvedCount = 0; | |
| for (const [threadId, comments] of threads) { | |
| // Safety rule: never auto-resolve if any human has commented | |
| const allBot = comments.every((c) => isBot(c.user?.login || '')); | |
| if (!allBot) { | |
| core.info(`Thread ${threadId}: has human comments — preserving.`); | |
| continue; | |
| } | |
| const rootComment = comments.find((c) => !c.in_reply_to_id) || comments[0]; | |
| // Safety rule: only resolve threads from prior SHAs, not the current one | |
| const commentSha = rootComment.original_commit_id || rootComment.commit_id; | |
| if (commentSha === headSha) { | |
| core.info(`Thread ${threadId}: from current SHA — skipping (too new).`); | |
| continue; | |
| } | |
| // Minimize via GraphQL (REST API does not expose resolve) | |
| try { | |
| await github.graphql(` | |
| mutation($id: ID!) { | |
| minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) { | |
| minimizedComment { isMinimized } | |
| } | |
| } | |
| `, { id: rootComment.node_id }); | |
| resolvedCount++; | |
| core.info(`Thread ${threadId}: auto-resolved (bot-only, addressed in ${headSha.slice(0, 12)}).`); | |
| } catch (error) { | |
| core.warning(`Failed to resolve thread ${threadId}: ${error.message}`); | |
| } | |
| } | |
| if (resolvedCount > 0) { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| body: [ | |
| `<!-- harness-auto-resolve: ${headSha} -->`, | |
| `🧹 Auto-resolved **${resolvedCount}** bot-only review thread${resolvedCount === 1 ? '' : 's'} addressed in commit \`${headSha.slice(0, 12)}\`.`, | |
| '', | |
| '_Threads with human comments are preserved. Only bot-originated threads from prior commits were resolved._', | |
| ].join('\n'), | |
| }); | |
| core.info(`Auto-resolved ${resolvedCount} bot threads.`); | |
| } else { | |
| core.info('No bot-only threads to resolve.'); | |
| } |