Skip to content

Auto-Resolve Bot Threads #54

Auto-Resolve Bot Threads

Auto-Resolve Bot Threads #54

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.');
}