forked from 1b-agent/fieldcraft-upstream
github: claim bot, /claim, /unclaim, PR references and a 14-day sweep
Adapted from SolidCraft's claim bot to FieldCraft's rules: a claim assigns and labels claimed, only help wanted / good first issue issues are open to contributors, in progress is the core team's and can't be claimed, and no visible progress for 14 days frees the issue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
15f9b7aa6e
commit
9fce2f7bab
3 files changed
+798
No files matched your search
@@ -0,0 +1,53 @@
|
|||||||
|
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Before you start: claim an issue"), and a
|
||||||
|
# daily sweep that frees claims with no visible progress for 14 days. A pull request that
|
||||||
|
# references an issue (`Closes #N`, `<summary> (#N)`) claims it too. The logic is in
|
||||||
|
# scripts/claim/claim.js (tested in ci.yml).
|
||||||
|
name: Claim
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
|
||||||
|
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened]
|
||||||
|
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 || github.event.pull_request.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 if (context.eventName === 'pull_request_target') {
|
||||||
|
const claimed = await claim.handlePull({ github, context });
|
||||||
|
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
|
||||||
|
} else {
|
||||||
|
const released = await claim.sweep({ github, context });
|
||||||
|
core.info(`Released: ${released.join(', ') || 'none'}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
// 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';
|
||||||
|
|
||||||
|
// CONTRIBUTING.md, "Before you start: claim an issue": a claimed issue is assigned and labelled
|
||||||
|
// `claimed`; `in progress` marks the core team's own work and can't be claimed; contributors pick
|
||||||
|
// issues labelled `help wanted` or `good first issue`; no visible progress for 14 days frees it.
|
||||||
|
const LABEL = 'claimed';
|
||||||
|
const LABEL_COLOR = '0052cc';
|
||||||
|
const LABEL_DESCRIPTION = 'Assigned to a contributor; see CONTRIBUTING.md';
|
||||||
|
const CORE = 'in progress';
|
||||||
|
const OPEN = ['help wanted', 'good first issue'];
|
||||||
|
const CLAIM_DAYS = 14;
|
||||||
|
const DAY = 24 * 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_DAYS * DAY;
|
||||||
|
return { pulls, since, expires, release: now >= expires, reason: 'no PR' };
|
||||||
|
}
|
||||||
|
let newest = since;
|
||||||
|
for (const pr of pulls) newest = Math.max(newest, await lastPush(github, context, pr));
|
||||||
|
const expires = newest + CLAIM_DAYS * DAY;
|
||||||
|
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 labelNames(issue) {
|
||||||
|
return (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLabel(issue, name = LABEL) {
|
||||||
|
return labelNames(issue).includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open to contributors: labelled `help wanted` or `good first issue`, and not the core team's.
|
||||||
|
function claimable(issue) {
|
||||||
|
const names = labelNames(issue);
|
||||||
|
return !names.includes(CORE) && OPEN.some((n) => names.includes(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLabel(issue, CORE)) {
|
||||||
|
await react(github, context, 'eyes');
|
||||||
|
await reply(github, context, `The core team is working on this one (\`${CORE}\`), so it can't be claimed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!claimable(issue) && !(await isMaintainer(github, context, user, cache))) {
|
||||||
|
await react(github, context, 'eyes');
|
||||||
|
await reply(
|
||||||
|
github,
|
||||||
|
context,
|
||||||
|
`Only issues labelled \`help wanted\` or \`good first issue\` can be claimed, @${user}. ` +
|
||||||
|
'Say here what you would like to do and a maintainer will open it up or assign it to you.',
|
||||||
|
);
|
||||||
|
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 someone's open PR may already cover it.
|
||||||
|
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 early with \`Closes #${n}\`; the claim lapses after ${CLAIM_DAYS} days without visible progress. \`/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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: it claims each open-for-contributors issue it references that nobody
|
||||||
|
// holds yet. The core team's PRs claim nothing (their issues carry `in progress` instead).
|
||||||
|
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 [];
|
||||||
|
if (await isMaintainer(github, context, author)) 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 (hasLabel(issue, CORE)) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
...repoArgs(context),
|
||||||
|
issue_number: pull.number,
|
||||||
|
body: `#${n} is being worked on by the core team (\`${CORE}\`). Please ask on the issue before going further.`,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (!claimable(issue)) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
...repoArgs(context),
|
||||||
|
issue_number: pull.number,
|
||||||
|
body: `#${n} isn't open for contributors yet (no \`help wanted\` or \`good first issue\` label), so this PR doesn't claim it. A maintainer will reply on the issue.`,
|
||||||
|
});
|
||||||
|
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 ${CLAIM_DAYS} days without pushes.`,
|
||||||
|
});
|
||||||
|
claimed.push(n);
|
||||||
|
}
|
||||||
|
return claimed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The daily sweep over open issues labelled `claimed`.
|
||||||
|
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 || hasLabel(issue, CORE)) 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 pull request showed up within ${CLAIM_DAYS} days, so this one is free again. Anyone can \`/claim\` it.`
|
||||||
|
: `Nothing has been pushed for ${CLAIM_DAYS} days, so this one is free again. Anyone can \`/claim\` it.`,
|
||||||
|
});
|
||||||
|
released.push(issue.number);
|
||||||
|
}
|
||||||
|
return released;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
LABEL,
|
||||||
|
CORE,
|
||||||
|
claimable,
|
||||||
|
parseCommand,
|
||||||
|
referencedIssues,
|
||||||
|
isMaintainer,
|
||||||
|
linkedPulls,
|
||||||
|
claimStatus,
|
||||||
|
handleComment,
|
||||||
|
handlePull,
|
||||||
|
sweep,
|
||||||
|
};
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
'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 D = 24 * H;
|
||||||
|
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 || [] }),
|
||||||
|
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 || [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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: 'claimed' }, 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 = ['help wanted'], user = 'alice', extra = {} } = {}) {
|
||||||
|
return {
|
||||||
|
repo: { owner: 'tangency-labs', repo: 'fieldcraft' },
|
||||||
|
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, ['claimed']);
|
||||||
|
assert.equal(find(calls, 'createLabel')[1].color, '0052cc');
|
||||||
|
assert.equal(find(calls, 'react')[1].content, '+1');
|
||||||
|
assert.match(find(calls, 'comment')[1].body, /^Claimed by @alice\. .*`Closes #7`.*14 days/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: ['help wanted', 'claimed'] }), 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: ['help wanted', 'claimed'] }), 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-30 02:00 UTC/); // 10 h ago + 14 days
|
||||||
|
assert.equal(find(calls, 'addAssignees'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lapsed claim is taken over', async () => {
|
||||||
|
const { github, calls } = fake({ timeline: [labeled(ago(15 * D))] });
|
||||||
|
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['help wanted', 'claimed'] }), 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(30 * D)), pr(50, 'bob')], pushed: ago(2 * H) });
|
||||||
|
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['help wanted', 'claimed'] }), 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: ['help wanted', 'claimed'] }), 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: ['help wanted', 'claimed'] }), now: NOW });
|
||||||
|
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['alice']);
|
||||||
|
assert.equal(find(calls, 'removeLabel')[1].name, 'claimed');
|
||||||
|
});
|
||||||
|
|
||||||
|
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: ['help wanted', 'claimed'] }), now: NOW });
|
||||||
|
assert.ok(find(w.calls, 'removeLabel'));
|
||||||
|
w = fake();
|
||||||
|
await claim.handleComment({ github: w.github, context: ctx('/unclaim', { user: 'mallory', assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
|
||||||
|
assert.equal(find(w.calls, 'removeLabel'), undefined);
|
||||||
|
assert.equal(find(w.calls, 'react')[1].content, 'eyes');
|
||||||
|
});
|
||||||
|
|
||||||
|
const issue = (number, assignees = ['bob'], extra = []) => ({
|
||||||
|
number,
|
||||||
|
locked: false,
|
||||||
|
updated_at: ago(1000 * H),
|
||||||
|
assignees: assignees.map((login) => ({ login })),
|
||||||
|
labels: [{ name: 'claimed' }, ...extra.map((name) => ({ name }))],
|
||||||
|
});
|
||||||
|
const repo = { owner: 'tangency-labs', repo: 'fieldcraft' };
|
||||||
|
|
||||||
|
test('sweep releases a claim older than 14 days with no PR', async () => {
|
||||||
|
const { github, calls } = fake({ issues: [issue(7)], timeline: [labeled(ago(15 * D))] });
|
||||||
|
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(30 * D)), labeled(ago(5 * D))] });
|
||||||
|
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
|
||||||
|
w = fake({ issues: [issue(7)], timeline: [labeled(ago(30 * D)), pr(9)], pushed: ago(3 * D) });
|
||||||
|
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(30 * D)), pr(9)], pushed: ago(15 * D) });
|
||||||
|
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(15 * D)), pr(9, 'other', 'closed')] });
|
||||||
|
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sweep never touches core-team or maintainer-claimed issues', async () => {
|
||||||
|
const w = fake({
|
||||||
|
issues: [issue(1, ['bob'], ['in progress']), 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(15 * D))] };
|
||||||
|
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 }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = ['help wanted']) => ({
|
||||||
|
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, ['claimed']);
|
||||||
|
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'], ['help wanted', 'claimed']) } });
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an issue the core team holds (`in progress`) cannot be claimed', async () => {
|
||||||
|
const { github, calls } = fake();
|
||||||
|
await claim.handleComment({ github, context: ctx('/claim', { labels: ['help wanted', 'in progress'] }), now: NOW });
|
||||||
|
assert.equal(find(calls, 'addAssignees'), undefined);
|
||||||
|
assert.match(find(calls, 'comment')[1].body, /core team/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only help wanted / good first issue issues can be claimed, except by a maintainer', async () => {
|
||||||
|
let w = fake();
|
||||||
|
await claim.handleComment({ github: w.github, context: ctx('/claim', { labels: ['bug'] }), now: NOW });
|
||||||
|
assert.equal(find(w.calls, 'addAssignees'), undefined);
|
||||||
|
assert.match(find(w.calls, 'comment')[1].body, /help wanted/);
|
||||||
|
w = fake();
|
||||||
|
await claim.handleComment({ github: w.github, context: ctx('/claim', { labels: ['good first issue'] }), now: NOW });
|
||||||
|
assert.ok(find(w.calls, 'addAssignees'));
|
||||||
|
w = fake({ perms: { carol: 'write' } });
|
||||||
|
await claim.handleComment({ github: w.github, context: ctx('/claim', { user: 'carol', labels: [] }), now: NOW });
|
||||||
|
assert.deepEqual(find(w.calls, 'addAssignees')[1].assignees, ['carol']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a PR on a core-team or not-yet-open issue comments on the PR and claims nothing', async () => {
|
||||||
|
const { github, calls } = fake({
|
||||||
|
byNumber: { 7: openIssue(7, [], ['help wanted', 'in progress']), 8: openIssue(8, [], ['bug']) },
|
||||||
|
});
|
||||||
|
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7) (#8)') }), []);
|
||||||
|
assert.equal(find(calls, 'addLabels'), undefined);
|
||||||
|
const bodies = calls.filter((c) => c[0] === 'comment').map((c) => c[1].body);
|
||||||
|
assert.match(bodies[0], /core team/);
|
||||||
|
assert.match(bodies[1], /isn't open for contributors/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a maintainer\'s PR claims nothing', async () => {
|
||||||
|
const { github, calls } = fake({ byNumber: { 7: openIssue(7) }, perms: { alice: 'admin' } });
|
||||||
|
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a claim whose PR is stale but which was claimed recently stands', async () => {
|
||||||
|
const w = fake({ issues: [issue(7)], timeline: [labeled(ago(2 * D)), pr(9)], pushed: ago(30 * D) });
|
||||||
|
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user