diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6046eed9..06a6aa06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,8 @@ jobs: run: cargo xtask web-offline-test - name: Affected-package selection self-test run: python3 -I scripts/ci/test_affected.py + - name: Claim bot tests + run: node --test scripts/claim/*.test.js clippy: name: Native Linux Clippy diff --git a/.github/workflows/claim.yml b/.github/workflows/claim.yml new file mode 100644 index 00000000..637253e9 --- /dev/null +++ b/.github/workflows/claim.yml @@ -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'}`); + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee6bf749..edc890be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 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 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, diff --git a/scripts/claim/claim.js b/scripts/claim/claim.js new file mode 100644 index 00000000..f3cf632a --- /dev/null +++ b/scripts/claim/claim.js @@ -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, +}; diff --git a/scripts/claim/claim.test.js b/scripts/claim/claim.test.js new file mode 100644 index 00000000..c61da8a1 --- /dev/null +++ b/scripts/claim/claim.test.js @@ -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 }), []); +});