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

+2
View File
@@ -125,6 +125,8 @@ jobs:
run: cargo xtask web-offline-test run: cargo xtask web-offline-test
- name: Affected-package selection self-test - name: Affected-package selection self-test
run: python3 -I scripts/ci/test_affected.py run: python3 -I scripts/ci/test_affected.py
- name: Claim bot tests
run: node --test scripts/claim/*.test.js
clippy: clippy:
name: Native Linux Clippy name: Native Linux Clippy
+44
View File
@@ -0,0 +1,44 @@
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Sending a change"), and a daily sweep that
# frees claims nobody is working on. The logic is in scripts/claim/claim.js (tested in ci.yml).
name: Claim
on:
issue_comment:
types: [created]
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
permissions:
contents: read # to check out scripts/claim
issues: write
pull-requests: read
# One run at a time per issue, so two simultaneous /claim comments can't both win.
concurrency:
group: claim-${{ github.event.issue.number || 'sweep' }}
cancel-in-progress: false
jobs:
claim:
name: Claim bot
runs-on: ubuntu-24.04
timeout-minutes: 10
if: >-
github.event_name != 'issue_comment' ||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/claim
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const claim = require('./scripts/claim/claim.js');
if (context.eventName === 'issue_comment') {
await claim.handleComment({ github, context });
} else {
const released = await claim.sweep({ github, context });
core.info(`Released: ${released.join(', ') || 'none'}`);
}
+6
View File
@@ -262,6 +262,12 @@ and a fresh CI run:
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and 4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones). the expected ones).
Comment `/claim` on an issue to take it: you are assigned, it gets the `in progress` label, and
the bot replies with the deadline. Open a draft PR referencing the issue within 72 hours, or the
claim lapses and the issue is freed again; a claim with a PR is freed after 14 days without
pushes. `/unclaim` releases it early (only you or a maintainer can). Issues a maintainer has
taken are never freed automatically.
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
include actual descriptive screenshots or a short recording in the issue or PR. This evidence include actual descriptive screenshots or a short recording in the issue or PR. This evidence
is required for changes or bugs that can be demonstrated in the app, including kernel, is required for changes or bugs that can be demonstrated in the app, including kernel,
+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 }), []);
});