Claim bot: /claim and /unclaim on issues (#1584)

Comment /claim on an issue to be assigned and get the in progress label; /unclaim releases it. A daily sweep frees claims with no PR after 72 h or with a PR idle for 14 days.
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-15 22:54:06 -04:00
1 parent e21c5f06ac
commit bd17eeef0c
5 files changed
+576

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
// The /claim bot's logic, used by .github/workflows/claim.yml through actions/github-script.
// Everything takes the github-script `github` client and `context`, so the tests can pass fakes.
'use strict';
const LABEL = 'in progress';
const LABEL_COLOR = 'fbca04';
const LABEL_DESCRIPTION = 'Someone is working on this';
const CLAIM_HOURS = 72;
const PR_IDLE_DAYS = 14;
const HOUR = 3600 * 1000;
function parseCommand(body) {
const text = String(body || '').trimStart();
if (/^\/claim(\s|$)/.test(text)) return 'claim';
if (/^\/unclaim(\s|$)/.test(text)) return 'unclaim';
return null;
}
function fmt(date) {
return new Date(date).toISOString().slice(0, 16).replace('T', ' ') + ' UTC';
}
function repoArgs(context) {
return { owner: context.repo.owner, repo: context.repo.repo };
}
async function isMaintainer(github, context, login, cache) {
if (login === context.repo.owner) return true;
if (cache && cache.has(login)) return cache.get(login);
let result = false;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
...repoArgs(context),
username: login,
});
result =
['admin', 'write', 'maintain'].includes(data.permission) ||
['admin', 'maintain', 'write'].includes(data.role_name);
} catch (e) {
result = false;
}
if (cache) cache.set(login, result);
return result;
}
async function ensureLabel(github, context) {
try {
await github.rest.issues.getLabel({ ...repoArgs(context), name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({
...repoArgs(context),
name: LABEL,
color: LABEL_COLOR,
description: LABEL_DESCRIPTION,
});
} catch (e2) {
if (e2.status !== 422) throw e2; // created meanwhile
}
}
}
// When the label was last added, from the timeline; the issue's update time if it can't be found.
async function labeledAt(github, context, issue, timeline) {
const events = timeline || (await getTimeline(github, context, issue.number));
let at = null;
for (const ev of events) {
if (ev.event === 'labeled' && ev.label && ev.label.name === LABEL) at = ev.created_at;
}
return new Date(at || issue.updated_at).getTime();
}
function getTimeline(github, context, number) {
return github.paginate(github.rest.issues.listEventsForTimeline, {
...repoArgs(context),
issue_number: number,
per_page: 100,
});
}
// Open pull requests that mention the issue, from the timeline's cross-references.
function linkedPulls(timeline) {
const seen = new Map();
for (const ev of timeline) {
const src = ev.event === 'cross-referenced' && ev.source && ev.source.issue;
if (src && src.pull_request && src.state === 'open') seen.set(src.number, src);
}
return [...seen.values()];
}
async function lastPush(github, context, pr) {
const { data } = await github.rest.pulls.get({ ...repoArgs(context), pull_number: pr.number });
const { data: commit } = await github.rest.repos.getCommit({
...repoArgs(context),
ref: data.head.sha,
});
return new Date(commit.commit.committer.date).getTime();
}
// Where a claimed issue stands: when it lapses, and whether it is free to release now.
async function claimStatus(github, context, issue, now) {
const timeline = await getTimeline(github, context, issue.number);
const since = await labeledAt(github, context, issue, timeline);
const pulls = linkedPulls(timeline);
if (pulls.length === 0) {
const expires = since + CLAIM_HOURS * HOUR;
return { pulls, since, expires, release: now >= expires, reason: 'no PR' };
}
let newest = 0;
for (const pr of pulls) newest = Math.max(newest, await lastPush(github, context, pr));
const expires = newest + PR_IDLE_DAYS * 24 * HOUR;
return { pulls, since, expires, release: now >= expires, reason: 'idle PR' };
}
async function react(github, context, content) {
await github.rest.reactions.createForIssueComment({
...repoArgs(context),
comment_id: context.payload.comment.id,
content,
});
}
async function reply(github, context, body) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: context.payload.issue.number,
body,
});
}
function hasLabel(issue) {
return (issue.labels || []).some((l) => (typeof l === 'string' ? l : l.name) === LABEL);
}
async function release(github, context, issue, assignees) {
if (assignees.length) {
await github.rest.issues.removeAssignees({
...repoArgs(context),
issue_number: issue.number,
assignees,
});
}
try {
await github.rest.issues.removeLabel({
...repoArgs(context),
issue_number: issue.number,
name: LABEL,
});
} catch (e) {
if (e.status !== 404) throw e;
}
}
async function claim(github, context, now) {
const issue = context.payload.issue;
const user = context.payload.comment.user.login;
const n = issue.number;
const cache = new Map();
const assignees = (issue.assignees || []).map((a) => a.login);
if (assignees.includes(user)) {
await react(github, context, '+1'); // already theirs
return;
}
// Someone else is assigned: theirs unless the claim has lapsed (never for a maintainer).
let held = assignees.length > 0 || hasLabel(issue);
let status = null;
if (held) {
let maintained = false;
for (const a of assignees) maintained = maintained || (await isMaintainer(github, context, a, cache));
if (!maintained) {
status = await claimStatus(github, context, issue, now);
if (status.release) held = false;
}
if (held) {
await react(github, context, 'eyes');
const who = assignees.length ? assignees.map((a) => '@' + a).join(', ') + ' has' : 'Someone has';
const when = maintained || !status ? '' : ` The claim lapses on ${fmt(status.expires)} unless there's progress.`;
await reply(github, context, `${who} claimed this already.${when}`);
return;
}
} else {
// Not claimed, but a draft PR may already be the claim (CONTRIBUTING.md, "Claim = a draft PR").
const timeline = await getTimeline(github, context, n);
const other = linkedPulls(timeline).find((p) => p.user && p.user.login !== user);
if (other) {
await react(github, context, 'eyes');
await reply(github, context, `#${other.number} already covers this one.`);
return;
}
}
// Free (or lapsed): hand it to the commenter. Clear a lapsed claimant first.
if (assignees.length) {
await github.rest.issues.removeAssignees({ ...repoArgs(context), issue_number: n, assignees });
}
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: [user],
});
} catch (e) {
// Assignment can be refused; the label and the reply naming them still stand.
}
await react(github, context, '+1');
await reply(
github,
context,
`Claimed by @${user}. Please open a draft PR referencing #${n} within ${CLAIM_HOURS} h; the claim lapses otherwise. \`/unclaim\` releases it.`,
);
}
async function unclaim(github, context) {
const issue = context.payload.issue;
const user = context.payload.comment.user.login;
const assignees = (issue.assignees || []).map((a) => a.login);
if (!hasLabel(issue) && assignees.length === 0) {
await react(github, context, '+1'); // nothing to release
return;
}
if (assignees.includes(context.repo.owner)) {
await react(github, context, 'eyes'); // never touched by the bot
return;
}
const allowed = assignees.includes(user) || (await isMaintainer(github, context, user));
if (!allowed) {
await react(github, context, 'eyes');
await reply(github, context, `Only the person who claimed this, or a maintainer, can release it, @${user}.`);
return;
}
await release(github, context, issue, assignees);
await react(github, context, '+1');
await reply(github, context, `Released. This one is free again, so anyone can \`/claim\` it.`);
}
// The comment event.
async function handleComment({ github, context, now = Date.now() }) {
const { issue, comment } = context.payload;
if (!issue || issue.pull_request || !comment) return 'skip';
if (issue.state !== 'open' || issue.locked) return 'skip';
if (comment.user.type === 'Bot' || comment.user.login.endsWith('[bot]')) return 'skip';
const cmd = parseCommand(comment.body);
if (!cmd) return 'skip';
if (cmd === 'claim') await claim(github, context, now);
else await unclaim(github, context);
return cmd;
}
// 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, {
...repoArgs(context),
state: 'open',
labels: LABEL,
per_page: 100,
});
const cache = new Map();
const released = [];
for (const issue of issues) {
if (issue.pull_request || issue.locked) continue;
const assignees = (issue.assignees || []).map((a) => a.login);
let skip = false;
for (const a of assignees) skip = skip || (await isMaintainer(github, context, a, cache));
if (skip) continue;
const status = await claimStatus(github, context, issue, now);
if (!status.release) continue;
await release(github, context, issue, assignees);
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: issue.number,
body:
status.reason === 'no PR'
? `No draft PR showed up within ${CLAIM_HOURS} h, so this one is free again. Anyone can \`/claim\` it.`
: `Nothing has been pushed for ${PR_IDLE_DAYS} days, so this one is free again. Anyone can \`/claim\` it.`,
});
released.push(issue.number);
}
return released;
}
module.exports = {
LABEL,
parseCommand,
isMaintainer,
linkedPulls,
claimStatus,
handleComment,
sweep,
};
+230
View File
@@ -0,0 +1,230 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const claim = require('./claim.js');
const NOW = Date.parse('2026-10-16T12:00:00Z');
const H = 3600 * 1000;
const ago = (ms) => new Date(NOW - ms).toISOString();
// A fake github-script client that records writes. `world` describes the repo.
function fake(world = {}) {
const calls = [];
const rec = (name) => async (args) => {
calls.push([name, args]);
if (world.fail && world.fail[name]) throw Object.assign(new Error(name), { status: world.fail[name] });
return { data: {} };
};
const github = {
paginate: async (fn, args) => (await fn(args)).data,
rest: {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => {
const permission = (world.perms || {})[username] || 'none';
return { data: { permission } };
},
getCommit: async () => ({ data: { commit: { committer: { date: world.pushed || ago(H) } } } }),
},
pulls: { get: async () => ({ data: { head: { sha: 'abc' } } }) },
reactions: { createForIssueComment: rec('react') },
issues: {
getLabel: async () => {
if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 });
return { data: {} };
},
createLabel: rec('createLabel'),
addLabels: rec('addLabels'),
removeLabel: rec('removeLabel'),
addAssignees: rec('addAssignees'),
removeAssignees: rec('removeAssignees'),
createComment: rec('comment'),
listForRepo: async () => ({ data: world.issues || [] }),
listEventsForTimeline: async () => ({ data: world.timeline || [] }),
},
},
};
return { github, calls };
}
const names = (calls) => calls.map((c) => c[0]);
const find = (calls, n) => calls.find((c) => c[0] === n);
const labeled = (at) => ({ event: 'labeled', label: { name: 'in progress' }, created_at: at });
const pr = (number, login = 'other', state = 'open') => ({
event: 'cross-referenced',
source: { issue: { number, state, pull_request: {}, user: { login } } },
});
function ctx(body, { assignees = [], labels = [], user = 'alice', extra = {} } = {}) {
return {
repo: { owner: 'bherbruck', repo: 'solidcraft' },
payload: {
issue: {
number: 7,
state: 'open',
locked: false,
updated_at: ago(1000 * H),
assignees: assignees.map((login) => ({ login })),
labels: labels.map((name) => ({ name })),
...extra,
},
comment: { id: 99, body, user: { login: user, type: 'User' } },
},
};
}
test('parseCommand', () => {
assert.equal(claim.parseCommand('/claim'), 'claim');
assert.equal(claim.parseCommand(' /claim please'), 'claim');
assert.equal(claim.parseCommand('/unclaim'), 'unclaim');
assert.equal(claim.parseCommand('/claimed'), null);
assert.equal(claim.parseCommand('I would /claim this'), null);
assert.equal(claim.parseCommand(undefined), null);
});
test('claim on a free issue assigns, labels, reacts and replies', async () => {
const { github, calls } = fake({ labelMissing: true });
assert.equal(await claim.handleComment({ github, context: ctx('/claim'), now: NOW }), 'claim');
assert.deepEqual(find(calls, 'addAssignees')[1].assignees, ['alice']);
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['in progress']);
assert.equal(find(calls, 'createLabel')[1].color, 'fbca04');
assert.equal(find(calls, 'react')[1].content, '+1');
assert.match(find(calls, 'comment')[1].body, /^Claimed by @alice\. .*draft PR referencing #7 within 72 h/);
});
test('claim still labels and replies when assignment is refused', async () => {
const { github, calls } = fake({ fail: { addAssignees: 422 } });
await claim.handleComment({ github, context: ctx('/claim'), now: NOW });
assert.ok(find(calls, 'addLabels'));
assert.match(find(calls, 'comment')[1].body, /@alice/);
});
test('claim by the current claimant is a quiet thumbs up', async () => {
const { github, calls } = fake();
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['alice'], labels: ['in progress'] }), now: NOW });
assert.deepEqual(names(calls), ['react']);
});
test('claim on an issue claimed by someone else names them and when it lapses', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(10 * H))] });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['in progress'] }), now: NOW });
assert.equal(find(calls, 'react')[1].content, 'eyes');
const body = find(calls, 'comment')[1].body;
assert.match(body, /@bob/);
assert.match(body, /2026-10-19 02:00 UTC/); // 10 h ago + 72 h
assert.equal(find(calls, 'addAssignees'), undefined);
});
test('a lapsed claim is taken over', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(100 * H))] });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['in progress'] }), now: NOW });
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['bob']);
assert.deepEqual(find(calls, 'addAssignees')[1].assignees, ['alice']);
});
test('a claim with a recent PR is not taken over', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(100 * H)), pr(50, 'bob')], pushed: ago(2 * H) });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['in progress'] }), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
});
test('a maintainer\'s claim is never taken over', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(500 * H))], perms: { carol: 'write' } });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['carol'], labels: ['in progress'] }), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
assert.equal(find(calls, 'react')[1].content, 'eyes');
});
test('an unclaimed issue with someone else\'s open PR points at the PR', async () => {
const { github, calls } = fake({ timeline: [pr(42, 'bob')] });
await claim.handleComment({ github, context: ctx('/claim'), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
assert.match(find(calls, 'comment')[1].body, /#42/);
});
test('bots, locked issues, closed issues, PRs and other comments are ignored', async () => {
const { github, calls } = fake();
const bot = ctx('/claim');
bot.payload.comment.user = { login: 'dependabot[bot]', type: 'Bot' };
assert.equal(await claim.handleComment({ github, context: bot, now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('/claim', { extra: { locked: true } }), now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('/claim', { extra: { state: 'closed' } }), now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('/claim', { extra: { pull_request: {} } }), now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('hello'), now: NOW }), 'skip');
assert.equal(calls.length, 0);
});
test('unclaim by the claimant releases', async () => {
const { github, calls } = fake();
await claim.handleComment({ github, context: ctx('/unclaim', { assignees: ['alice'], labels: ['in progress'] }), now: NOW });
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['alice']);
assert.equal(find(calls, 'removeLabel')[1].name, 'in progress');
});
test('unclaim by a maintainer releases, by a stranger does not', async () => {
let w = fake({ perms: { carol: 'admin' } });
await claim.handleComment({ github: w.github, context: ctx('/unclaim', { user: 'carol', assignees: ['bob'], labels: ['in progress'] }), now: NOW });
assert.ok(find(w.calls, 'removeLabel'));
w = fake();
await claim.handleComment({ github: w.github, context: ctx('/unclaim', { user: 'mallory', assignees: ['bob'], labels: ['in progress'] }), now: NOW });
assert.equal(find(w.calls, 'removeLabel'), undefined);
assert.equal(find(w.calls, 'react')[1].content, 'eyes');
});
test('unclaim never touches an issue assigned to the owner', async () => {
const { github, calls } = fake();
await claim.handleComment({ github, context: ctx('/unclaim', { user: 'bherbruck', assignees: ['bherbruck'], labels: ['in progress'] }), now: NOW });
assert.equal(find(calls, 'removeLabel'), undefined);
});
const issue = (number, assignees = ['bob']) => ({
number,
locked: false,
updated_at: ago(1000 * H),
assignees: assignees.map((login) => ({ login })),
labels: [{ name: 'in progress' }],
});
const repo = { owner: 'bherbruck', repo: 'solidcraft' };
test('sweep releases a claim older than 72 h with no PR', async () => {
const { github, calls } = fake({ issues: [issue(7)], timeline: [labeled(ago(80 * H))] });
assert.deepEqual(await claim.sweep({ github, context: { repo }, now: NOW }), [7]);
assert.ok(find(calls, 'removeLabel'));
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['bob']);
assert.match(find(calls, 'comment')[1].body, /free again/);
});
test('sweep keeps a young claim and a claim with a fresh PR, using the latest label time', async () => {
let w = fake({ issues: [issue(7)], timeline: [labeled(ago(100 * H)), labeled(ago(5 * H))] });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
w = fake({ issues: [issue(7)], timeline: [labeled(ago(500 * H)), pr(9)], pushed: ago(3 * 24 * H) });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
});
test('sweep releases a claim whose PR has been idle for 14 days', async () => {
const w = fake({ issues: [issue(7)], timeline: [labeled(ago(500 * H)), pr(9)], pushed: ago(15 * 24 * H) });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
assert.match(find(w.calls, 'comment')[1].body, /14 days/);
});
test('sweep ignores a closed PR', async () => {
const w = fake({ issues: [issue(7)], timeline: [labeled(ago(80 * H)), pr(9, 'other', 'closed')] });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
});
test('sweep never touches owner- or maintainer-claimed issues', async () => {
const w = fake({
issues: [issue(1, ['bherbruck']), issue(2, ['carol']), issue(3, ['bob', 'carol'])],
timeline: [labeled(ago(900 * H))],
perms: { carol: 'write' },
});
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
assert.equal(w.calls.length, 0);
});
test('sweep is idempotent: a released issue is no longer listed', async () => {
const world = { issues: [issue(7)], timeline: [labeled(ago(80 * H))] };
const w = fake(world);
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
world.issues = [];
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
});