Triage bot and PR-based claims (#2397)

* Claim bot: a PR referencing an issue claims it

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Triage bot: label new issues and PRs, /triage, backfill

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Docs: a draft PR is the claim; labels and triage

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Triage bot: start a runner only for events it acts on

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-18 05:17:21 -04:00
1 parent b685b7301f
commit 2e4266cb0a
9 files changed
+736 -10

No files matched your search

+75
View File
@@ -251,6 +251,79 @@ async function handleComment({ github, context, now = Date.now() }) {
return cmd;
}
// Issue numbers a PR claims: `#N` in its title (`<summary> (#N)`) and closing keywords in its body.
const MAX_REFS = 5;
function referencedIssues(title, body) {
const found = new Set();
for (const m of String(title || '').matchAll(/(?:^|[\s(])#(\d{1,7})\b/g)) found.add(Number(m[1]));
const closing = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d{1,7})\b/gi;
for (const m of String(body || '').matchAll(closing)) found.add(Number(m[1]));
return [...found].filter((n) => n > 0).slice(0, MAX_REFS);
}
// A pull request was opened (or its title or body edited): it claims each open issue it
// references that nobody holds yet, so a draft PR alone is enough (CONTRIBUTING.md).
async function handlePull({ github, context }) {
const pull = context.payload.pull_request;
if (!pull || pull.state !== 'open') return [];
const author = pull.user && pull.user.login;
if (!author || pull.user.type === 'Bot' || author.endsWith('[bot]')) return [];
const claimed = [];
for (const n of referencedIssues(pull.title, pull.body)) {
if (n === pull.number) continue;
let issue;
try {
({ data: issue } = await github.rest.issues.get({ ...repoArgs(context), issue_number: n }));
} catch (e) {
if (e.status === 404 || e.status === 410) continue;
throw e;
}
if (issue.pull_request || issue.state !== 'open' || issue.locked) continue;
const assignees = (issue.assignees || []).map((a) => a.login);
if (assignees.includes(author)) {
if (!hasLabel(issue)) {
await ensureLabel(github, context);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
}
continue;
}
if (assignees.length || hasLabel(issue)) {
const who = assignees.length ? assignees.map((a) => '@' + a).join(', ') : 'someone else';
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${n} is already claimed by ${who}. If this PR duplicates their work, please close it or offer to help there.`,
});
continue;
}
// An older open PR for the issue wins (lowest number), even if it never recorded its claim.
const timeline = await getTimeline(github, context, n);
const older = linkedPulls(timeline).find((p) => p.number < pull.number && p.user && p.user.login !== author);
if (older) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${older.number} claimed #${n} first (the lowest-numbered open PR wins). Please close this one or offer to help there.`,
});
continue;
}
await ensureLabel(github, context);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
try {
await github.rest.issues.addAssignees({ ...repoArgs(context), issue_number: n, assignees: [author] });
} catch (e) {
// Assignment can be refused; the label and the comment naming them still stand.
}
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: n,
body: `Claimed by @${author} through #${pull.number}. The claim lapses after ${PR_IDLE_DAYS} days without pushes.`,
});
claimed.push(n);
}
return claimed;
}
// The daily sweep over open issues labelled `in progress`.
async function sweep({ github, context, now = Date.now() }) {
const issues = await github.paginate(github.rest.issues.listForRepo, {
@@ -286,9 +359,11 @@ async function sweep({ github, context, now = Date.now() }) {
module.exports = {
LABEL,
parseCommand,
referencedIssues,
isMaintainer,
linkedPulls,
claimStatus,
handleComment,
handlePull,
sweep,
};
+64
View File
@@ -39,6 +39,11 @@ function fake(world = {}) {
removeAssignees: rec('removeAssignees'),
createComment: rec('comment'),
listForRepo: async () => ({ data: world.issues || [] }),
get: async ({ issue_number }) => {
const found = (world.byNumber || {})[issue_number];
if (!found) throw Object.assign(new Error('nf'), { status: 404 });
return { data: found };
},
listEventsForTimeline: async () => ({ data: world.timeline || [] }),
},
},
@@ -231,3 +236,62 @@ test('sweep is idempotent: a released issue is no longer listed', async () => {
world.issues = [];
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
});
test('referencedIssues reads the title and closing keywords, capped', () => {
assert.deepEqual(claim.referencedIssues('Fillet: chain (#123)', 'Fixes #9, closes: #10, see #11'), [123, 9, 10]);
assert.deepEqual(claim.referencedIssues('no refs', 'mentions #4 only'), []);
assert.deepEqual(claim.referencedIssues('#1 #2 #3 #4 #5 #6 #7', ''), [1, 2, 3, 4, 5]);
assert.deepEqual(claim.referencedIssues(undefined, null), []);
});
const openIssue = (number, assignees = [], labels = []) => ({
number,
state: 'open',
locked: false,
assignees: assignees.map((login) => ({ login })),
labels: labels.map((name) => ({ name })),
});
const pullCtx = (number, title, body = '', login = 'alice') => ({
repo,
payload: { pull_request: { number, state: 'open', title, body, user: { login, type: 'User' } } },
});
test('a PR referencing a free issue claims it for the author', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7) } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), [7]);
assert.deepEqual(find(calls, 'addAssignees')[1], { ...repo, issue_number: 7, assignees: ['alice'] });
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['in progress']);
assert.match(find(calls, 'comment')[1].body, /Claimed by @alice through #50/);
});
test('a PR on an issue someone else holds comments on the PR and changes nothing', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7, ['bob'], ['in progress']) } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.equal(find(calls, 'addAssignees'), undefined);
const c = find(calls, 'comment')[1];
assert.equal(c.issue_number, 50);
assert.match(c.body, /@bob/);
});
test('the lowest-numbered open PR wins an unrecorded claim', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7) }, timeline: [pr(42, 'bob')] });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.match(find(calls, 'comment')[1].body, /#42 claimed #7 first/);
});
test('the claimant\'s own PR just makes sure the label is there', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7, ['alice']) } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.deepEqual(names(calls), ['addLabels']);
});
test('PRs from bots, references to PRs, closed and missing issues are ignored', async () => {
const w = fake({
byNumber: { 7: { ...openIssue(7), pull_request: {} }, 8: { ...openIssue(8), state: 'closed' } },
});
assert.deepEqual(await claim.handlePull({ github: w.github, context: pullCtx(50, 'x (#7) (#8) (#9)') }), []);
const bot = pullCtx(51, 'x (#1)');
bot.payload.pull_request.user = { login: 'dependabot[bot]', type: 'Bot' };
assert.deepEqual(await claim.handlePull({ github: w.github, context: bot }), []);
assert.equal(w.calls.length, 0);
});