diff --git a/.github/scripts/claim-ttl.mjs b/.github/scripts/claim-ttl.mjs index 4e1e7b5..75f0bad 100644 --- a/.github/scripts/claim-ttl.mjs +++ b/.github/scripts/claim-ttl.mjs @@ -1,4 +1,6 @@ const MAINTAINER_PERMISSIONS = new Set(['admin', 'maintain', 'write']); +// Labels that open an issue for claiming (issue-triage.yml uses the same list). +const ACCEPT_LABELS = new Set(['accepted', 'good first issue', 'help wanted']); const HOUR_MS = 60 * 60 * 1000; function latestAssignment(events, login) { @@ -9,6 +11,14 @@ function latestAssignment(events, login) { .sort((a, b) => b.getTime() - a.getTime())[0]; } +function latestAcceptance(events) { + return events + .filter(event => event.event === 'labeled' && ACCEPT_LABELS.has(event.label?.name)) + .map(event => new Date(event.created_at)) + .filter(date => !Number.isNaN(date.getTime())) + .sort((a, b) => b.getTime() - a.getTime())[0]; +} + // Same keywords the PR gate (pr-triage.yml) accepts as an issue reference. const ISSUE_REFERENCE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|refs?|references?|related\s+to)\s*:?\s+#(\d+)/gi; @@ -33,16 +43,21 @@ function readHours(value, fallback) { return value === undefined ? fallback : Number(value); } -export function evaluateClaim({ events, login, issueNumber, now, since, warn, ttl }) { +export function evaluateClaim({ events, login, issueNumber, now, since, warn, ttl, opener }) { const assignedAt = latestAssignment(events, login); if (!assignedAt) return { action: 'skip', reason: 'no-assignment-event' }; if (assignedAt < since) return { action: 'skip', reason: 'grandfathered' }; if (hasOpenLinkedPr(events, login, issueNumber)) return { action: 'skip', reason: 'linked-open-pr' }; - const age = (now.getTime() - assignedAt.getTime()) / HOUR_MS; + // The opener's reservation and claim share one window: their clock starts + // when the issue was accepted, not when they claimed it. + const acceptedAt = login === opener ? latestAcceptance(events) : undefined; + const fromAcceptance = acceptedAt !== undefined && acceptedAt < assignedAt; + const start = fromAcceptance ? acceptedAt : assignedAt; + const age = (now.getTime() - start.getTime()) / HOUR_MS; if (age < warn) return { action: 'skip', reason: 'under-warning-age' }; - return { action: age >= ttl ? 'expired' : 'warn', assignedAt }; + return { action: age >= ttl ? 'expired' : 'warn', assignedAt, fromAcceptance }; } export async function run({ github, context, core, env, now = new Date() }) { @@ -134,6 +149,7 @@ export async function run({ github, context, core, env, now = new Date() }) { since, warn, ttl, + opener: issue.user?.login, }); if (evaluation.action === 'skip') { skip(evaluation.reason); @@ -147,7 +163,7 @@ export async function run({ github, context, core, env, now = new Date() }) { per_page: 100, }); const stage = evaluation.action; - const { assignedAt } = evaluation; + const { assignedAt, fromAcceptance } = evaluation; const marker = ``; const alreadyCommented = comments.some(comment => (comment.body ?? '').includes(marker)); if (stage === 'warn') { @@ -158,7 +174,7 @@ export async function run({ github, context, core, env, now = new Date() }) { if (dryRun) { core.info(`DRY_RUN: would warn @${login} on #${issue.number}`); } else { - const body = `@${login}, your claim on this issue expires in about 24 hours unless you open a PR that links it (for example, \`Closes #${issue.number}\` or \`Part of #${issue.number}\`). You can re-claim later with \`/assign\` if the issue is still free.\n\n${marker}`; + const body = `@${login}, your claim on this issue expires in about ${ttl - warn} hours unless you open a PR that links it (for example, \`Closes #${issue.number}\` or \`Part of #${issue.number}\`). You can re-claim later with \`/assign\` if the issue is still free.\n\n${marker}`; await github.rest.issues.createComment({ owner, repo, @@ -193,7 +209,8 @@ export async function run({ github, context, core, env, now = new Date() }) { } } if (!alreadyCommented) { - const body = `@${login}, your claim expired after ${ttl} hours without a linked PR. This issue is open for others now, and you're welcome to \`/assign\` again if it's still free.\n\n${marker}`; + const counted = fromAcceptance ? ' (counted from when this issue was accepted)' : ''; + const body = `@${login}, your claim expired after ${ttl} hours${counted} without a linked PR. This issue is open for others now, and you're welcome to \`/assign\` again if it's still free.\n\n${marker}`; await github.rest.issues.createComment({ owner, repo, diff --git a/.github/scripts/claim-ttl.test.mjs b/.github/scripts/claim-ttl.test.mjs index 29db465..f9952d9 100644 --- a/.github/scripts/claim-ttl.test.mjs +++ b/.github/scripts/claim-ttl.test.mjs @@ -368,11 +368,72 @@ test('scheduled workflow pins the policy and runs only in the public repo', () = const path = fileURLToPath(new URL('../workflows/claim-ttl.yml', import.meta.url)); const workflow = readFileSync(path, 'utf8'); assert.match(workflow, /^ {4}if: github\.repository == 'TestSprite\/testsprite-cli'$/m); - assert.match(workflow, /^ {2}CLAIM_TTL_HOURS: '72'$/m); - assert.match(workflow, /^ {2}CLAIM_WARN_HOURS: '48'$/m); + assert.match(workflow, /^ {2}CLAIM_TTL_HOURS: '48'$/m); + assert.match(workflow, /^ {2}CLAIM_WARN_HOURS: '24'$/m); assert.match(workflow, /^ {2}CLAIM_TTL_SINCE: '2026-09-24T00:00:00Z'$/m); assert.match( workflow, /^ {2}DRY_RUN: \$\{\{ inputs\.dry_run == true && 'true' \|\| 'false' \}\}$/m, ); }); + +const accepted = hours => ({ + event: 'labeled', + label: { name: 'accepted' }, + created_at: assignedAt(hours), +}); +const openerIssue = { + number: 42, + user: { login: 'contributor' }, + assignees: [{ login: 'contributor' }], + labels: [], +}; + +test('the opener’s window starts at acceptance, not at the claim', async () => { + // Accepted 72h ago, claimed by the opener 30h ago: the opener's 72h are up. + const h = harness({ + issues: [openerIssue], + events: { 42: [accepted(72), claim('contributor', 30)] }, + }); + await h.execute(); + assert.equal(h.of('removeAssignees').length, 1); + assert.match(h.of('createComment')[0].body, /counted from when this issue was accepted/); + // The marker still names the assignment, so a later re-claim gets its own notices. + assert.match( + h.of('createComment')[0].body, + /claim-ttl:expired:contributor:2026-09-28T18:00:00.000Z/, + ); +}); + +test('a non-opener’s window starts at their own claim', async () => { + const h = harness({ + issues: [{ ...openerIssue, user: { login: 'opener' } }], + events: { 42: [accepted(72), claim('contributor', 30)] }, + }); + await h.execute(); + assert.deepEqual(h.writes(), []); +}); + +test('a linked draft PR keeps the opener’s claim past the window', async () => { + const h = harness({ + issues: [openerIssue], + events: { 42: [accepted(72), claim('contributor', 30), linkedPr('contributor', 'open', true)] }, + }); + await h.execute(); + assert.deepEqual(h.writes(), []); +}); + +test('the warning states the hours left under the configured policy', async () => { + const h = harness({ + events: { 42: [claim('contributor', 24)] }, + env: { CLAIM_TTL_HOURS: '48', CLAIM_WARN_HOURS: '24' }, + }); + await h.execute(); + assert.match(h.of('createComment')[0].body, /expires in about 24 hours/); + const expired = harness({ + events: { 42: [claim('contributor', 48)] }, + env: { CLAIM_TTL_HOURS: '48', CLAIM_WARN_HOURS: '24' }, + }); + await expired.execute(); + assert.match(expired.of('createComment')[0].body, /expired after 48 hours without a linked PR/); +}); diff --git a/.github/scripts/issue-open-cap.test.mjs b/.github/scripts/issue-open-cap.test.mjs new file mode 100644 index 0000000..5ec1696 --- /dev/null +++ b/.github/scripts/issue-open-cap.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const workflowPath = fileURLToPath(new URL('../workflows/issue-open-cap.yml', import.meta.url)); +const workflow = readFileSync(workflowPath, 'utf8'); +const lines = workflow.split('\n'); +const scriptStart = lines.indexOf(' script: |'); +assert.notEqual(scriptStart, -1); +const script = lines + .slice(scriptStart + 1) + .map(line => { + if (line.length === 0) return ''; + assert.ok(line.startsWith(' ')); + return line.slice(12); + }) + .join('\n'); +const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; +const runScript = new AsyncFunction('github', 'context', script); + +async function runIssue({ + // Open items the author created, as listForRepo returns them (PRs included). + open = [], + action = 'opened', + sender = 'contributor', + permission = 'read', + association = 'NONE', +} = {}) { + const calls = []; + const issues = { + listForRepo: () => {}, + createComment: async args => calls.push(['createComment', args]), + update: async args => calls.push(['update', args]), + }; + const repos = { + getCollaboratorPermissionLevel: async () => { + if (permission === 'error') throw new Error('permission lookup failed'); + return { data: { permission } }; + }, + }; + const github = { + paginate: async (method, params) => { + assert.equal(method, issues.listForRepo); + assert.equal(params.creator, 'contributor'); + assert.equal(params.state, 'open'); + return open; + }, + rest: { issues, repos }, + }; + const context = { + repo: { owner: 'TestSprite', repo: 'testsprite-cli' }, + payload: { + action, + sender: { login: sender }, + issue: { number: 50, user: { login: 'contributor' }, author_association: association }, + }, + }; + const previous = process.env.OPEN_CAP; + process.env.OPEN_CAP = '3'; + try { + await runScript(github, context); + } finally { + if (previous === undefined) delete process.env.OPEN_CAP; + else process.env.OPEN_CAP = previous; + } + return name => calls.filter(([method]) => method === name).map(([, args]) => args); +} + +const issueItems = numbers => numbers.map(number => ({ number, title: `Issue ${number}` })); + +test('the cap is three open issues', () => { + assert.match(workflow, /^ {2}OPEN_CAP: '3'$/m); +}); + +test('a fourth open issue is closed with a comment listing the other three', async () => { + const of = await runIssue({ open: issueItems([11, 12, 13, 50]) }); + const [comment] = of('createComment'); + assert.equal(comment.issue_number, 50); + assert.match(comment.body, /at most 3 open at a time/); + assert.match(comment.body, /#11 — Issue 11/); + assert.doesNotMatch(comment.body, /#50/); + assert.deepEqual(of('update'), [ + { + owner: 'TestSprite', + repo: 'testsprite-cli', + issue_number: 50, + state: 'closed', + state_reason: 'not_planned', + }, + ]); +}); + +test('up to three open issues, and PRs, are left alone', async () => { + const of = await runIssue({ + open: [...issueItems([11, 12, 50]), { number: 14, pull_request: {} }], + }); + assert.equal(of('createComment').length, 0); + assert.equal(of('update').length, 0); +}); + +test('an issue claimed by someone else does not count, one claimed by the author does', async () => { + const claimedByOther = { number: 13, title: 'Issue 13', assignees: [{ login: 'someone-else' }] }; + const claimedBySelf = { number: 14, title: 'Issue 14', assignees: [{ login: 'contributor' }] }; + const under = await runIssue({ open: [...issueItems([11, 12]), claimedByOther] }); + assert.equal(under('update').length, 0); + const over = await runIssue({ open: [...issueItems([11, 12]), claimedBySelf] }); + assert.equal(over('update').length, 1); + assert.match(over('createComment')[0].body, /#14 — Issue 14/); +}); + +test('maintainers are exempt, by permission or by association when the lookup fails', async () => { + for (const permission of ['write', 'maintain', 'admin']) { + const of = await runIssue({ open: issueItems([11, 12, 13]), permission }); + assert.equal(of('update').length, 0); + } + const fallback = await runIssue({ + open: issueItems([11, 12, 13]), + permission: 'error', + association: 'MEMBER', + }); + assert.equal(fallback('update').length, 0); + const community = await runIssue({ open: issueItems([11, 12, 13]), permission: 'error' }); + assert.equal(community('update').length, 1); +}); + +test('reopening over the cap closes again, unless a maintainer reopened it', async () => { + const self = await runIssue({ open: issueItems([11, 12, 13]), action: 'reopened' }); + assert.equal(self('update').length, 1); + const byMaintainer = await runIssue({ + open: issueItems([11, 12, 13]), + action: 'reopened', + sender: 'maintainer', + }); + assert.equal(byMaintainer('update').length, 0); +}); + +test('runs are serialised per author and skip bots', () => { + assert.match(workflow, /group: issue-open-cap-\$\{\{ github\.event\.issue\.user\.login \}\}/); + assert.match(workflow, /github\.event\.issue\.user\.type != 'Bot'/); +}); diff --git a/.github/scripts/issue-triage.test.mjs b/.github/scripts/issue-triage.test.mjs index d439ea7..3e52ee4 100644 --- a/.github/scripts/issue-triage.test.mjs +++ b/.github/scripts/issue-triage.test.mjs @@ -26,6 +26,11 @@ async function runComment({ enforce = false, // Repository permission returned for the commenter; 'error' makes the lookup throw. permission = 'read', + // The issue's opener and current assignees (payload), and its label events. + opener, + openerPermission = 'read', + assignees = [], + events = [], } = {}) { const calls = []; const issues = { @@ -38,25 +43,37 @@ async function runComment({ }, removeLabel: async args => calls.push(['removeLabel', args]), listForRepo: () => {}, + listEvents: () => {}, }; const repos = { - getCollaboratorPermissionLevel: async () => { - if (permission === 'error') throw new Error('permission lookup failed'); - return { data: { permission } }; + getCollaboratorPermissionLevel: async ({ username }) => { + const level = opener && username === opener ? openerPermission : permission; + if (level === 'error') throw new Error('permission lookup failed'); + return { data: { permission: level } }; }, }; - const github = { paginate: async () => assigned, rest: { issues, repos } }; + const github = { + paginate: async method => (method === issues.listEvents ? events : assigned), + rest: { issues, repos }, + }; const context = { repo: { owner: 'TestSprite', repo: 'testsprite-cli' }, payload: { - issue: { number: 42, labels: labels.map(name => ({ name })) }, + issue: { + number: 42, + labels: labels.map(name => ({ name })), + assignees: assignees.map(login => ({ login })), + ...(opener ? { user: { login: opener, type: 'User' }, author_association: 'NONE' } : {}), + }, comment: { body, author_association: association, user: { login: 'contributor' } }, }, }; const previousCap = process.env.SLOT_CAP; const previousEnforce = process.env.ENFORCE_CAP; + const previousReserve = process.env.OPENER_RESERVE_HOURS; process.env.SLOT_CAP = '3'; process.env.ENFORCE_CAP = String(enforce); + process.env.OPENER_RESERVE_HOURS = '48'; try { await runScript(github, context); } finally { @@ -64,6 +81,8 @@ async function runComment({ else process.env.SLOT_CAP = previousCap; if (previousEnforce === undefined) delete process.env.ENFORCE_CAP; else process.env.ENFORCE_CAP = previousEnforce; + if (previousReserve === undefined) delete process.env.OPENER_RESERVE_HOURS; + else process.env.OPENER_RESERVE_HOURS = previousReserve; } return calls; } @@ -148,3 +167,81 @@ test('the enforced cap does not apply to maintainers', async () => { assert.equal(callsOf(calls, 'addAssignees').length, 1); assert.doesNotMatch(callsOf(calls, 'createComment')[0].body, /at the 3-issue limit/); }); + +const hoursAgo = hours => new Date(Date.now() - hours * 3600 * 1000).toISOString(); +const acceptedEvent = hours => ({ + event: 'labeled', + label: { name: 'accepted' }, + created_at: hoursAgo(hours), +}); + +test('an issue claimed by someone else is not co-assigned', async () => { + const calls = await runComment({ labels: ['accepted'], assignees: ['first-claimer'] }); + assert.equal(callsOf(calls, 'addAssignees').length, 0); + assert.match(callsOf(calls, 'createComment')[0].body, /already claimed by @first-claimer/); + + // A maintainer can still add themselves. + const maintainer = await runComment({ + labels: ['accepted'], + assignees: ['first-claimer'], + permission: 'write', + }); + assert.equal(callsOf(maintainer, 'addAssignees').length, 1); +}); + +test('the opener has 48 hours after acceptance to claim before anyone else', async () => { + const reserved = await runComment({ + labels: ['accepted'], + opener: 'opener', + events: [acceptedEvent(2)], + }); + assert.equal(callsOf(reserved, 'addAssignees').length, 0); + assert.match( + callsOf(reserved, 'createComment')[0].body, + /reserved for them until \d{4}-\d{2}-\d{2}T/, + ); + assert.match(callsOf(reserved, 'createComment')[0].body, /@opener opened this issue/); + + // The opener themselves claims inside the window. + const own = await runComment({ + labels: ['accepted'], + opener: 'contributor', + events: [acceptedEvent(2)], + }); + assert.equal(callsOf(own, 'addAssignees').length, 1); + + // After the window anyone can claim. + const later = await runComment({ + labels: ['accepted'], + opener: 'opener', + events: [acceptedEvent(49)], + }); + assert.equal(callsOf(later, 'addAssignees').length, 1); + + // The window counts from the latest acceptance (a label removed and re-added). + const readded = await runComment({ + labels: ['accepted'], + opener: 'opener', + events: [acceptedEvent(100), acceptedEvent(1)], + }); + assert.equal(callsOf(readded, 'addAssignees').length, 0); +}); + +test('issues opened by maintainers are not reserved, and maintainers bypass a reservation', async () => { + const maintainerOpened = await runComment({ + labels: ['accepted'], + opener: 'opener', + openerPermission: 'write', + events: [acceptedEvent(2)], + }); + assert.equal(callsOf(maintainerOpened, 'addAssignees').length, 1); + + const maintainerClaims = await runComment({ + labels: ['accepted'], + opener: 'opener', + permission: 'write', + events: [acceptedEvent(2)], + }); + assert.equal(callsOf(maintainerClaims, 'addAssignees').length, 1); + assert.match(readFileSync(workflowPath, 'utf8'), /^ {2}OPENER_RESERVE_HOURS: '48'$/m); +}); diff --git a/.github/workflows/claim-ttl.yml b/.github/workflows/claim-ttl.yml index bb38d5f..caeab44 100644 --- a/.github/workflows/claim-ttl.yml +++ b/.github/workflows/claim-ttl.yml @@ -1,5 +1,7 @@ -# Sweep open issue claims hourly: warn at 48 hours and release at 72 hours -# unless the assignee has an open PR linked to the issue. This is separate +# Sweep open issue claims hourly: warn at 24 hours and release at 48 hours +# unless the assignee has an open PR (a draft counts) linked to the issue. For +# the issue's opener the clock starts when the issue was accepted, so their +# 48-hour reservation and their claim share one window. This is separate # from stale.yml because stale tracks inactivity in days and exempts the # labels used by the contributor claim flow. name: Claim TTL @@ -23,8 +25,8 @@ concurrency: cancel-in-progress: false env: - CLAIM_TTL_HOURS: '72' - CLAIM_WARN_HOURS: '48' + CLAIM_TTL_HOURS: '48' + CLAIM_WARN_HOURS: '24' CLAIM_TTL_SINCE: '2026-09-24T00:00:00Z' DRY_RUN: ${{ inputs.dry_run == true && 'true' || 'false' }} diff --git a/.github/workflows/issue-open-cap.yml b/.github/workflows/issue-open-cap.yml new file mode 100644 index 0000000..de51c6e --- /dev/null +++ b/.github/workflows/issue-open-cap.yml @@ -0,0 +1,82 @@ +# Open-issue cap: a community contributor can have at most OPEN_CAP issues of +# their own open at once. An issue someone else has claimed doesn't count: the +# work has moved to them, so it no longer ties up the opener. A new (or reopened) issue over the cap is closed with +# a comment listing the open ones; the author can open it again once one of +# those is closed (merged PR with `Closes #N`, or closed by us). An issue the +# bot closed can't be reopened by its author, so reopen only covers an issue +# its author closed themselves. +# Maintainers and bots are exempt. Separate from the claim cap in +# issue-triage.yml, which limits issues ASSIGNED to someone. +name: Issue open cap + +on: + issues: + types: [opened, reopened] + +permissions: + issues: write # comment + close; nothing else + +env: + OPEN_CAP: '3' + +# One run per author at a time, so two issues opened together are counted in +# order instead of both seeing the other as the third. +concurrency: + group: issue-open-cap-${{ github.event.issue.user.login }} + cancel-in-progress: false + +jobs: + cap: + # Public repo only, like the sibling bots. + if: >- + github.repository == 'TestSprite/testsprite-cli' && + github.event.issue.user.type != 'Bot' + runs-on: ubuntu-latest + steps: + # Same App-token-first / GITHUB_TOKEN-fallback identity as issue-triage.yml. + - id: app-token + continue-on-error: true + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }} + private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }} + permission-issues: write + + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.app-token.outputs.token || github.token }} + script: | + const cap = parseInt(process.env.OPEN_CAP, 10); + const { owner, repo } = context.repo; + const issue = context.payload.issue; + const author = issue.user.login; + + // A maintainer reopening someone's issue is not the author opening a new one. + if (context.payload.action === 'reopened' && context.payload.sender?.login !== author) return; + + // Maintainer = write access or above; author_association is only the + // fallback (private org members arrive as CONTRIBUTOR). + let isMaintainer; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: author }); + isMaintainer = ['admin', 'maintain', 'write'].includes(data.permission); + } catch { + isMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(issue.author_association); + } + if (isMaintainer) return; + + const claimedByOthers = i => (i.assignees || []).length > 0 + && !i.assignees.some(a => a.login === author); + const others = (await github.paginate(github.rest.issues.listForRepo, { + owner, repo, state: 'open', creator: author, per_page: 100, + })).filter(i => !i.pull_request && i.number !== issue.number && !claimedByOthers(i)); + if (others.length < cap) return; + + const list = others.map(i => `- ${owner}/${repo}#${i.number} — ${i.title}`).join('\n'); + await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, + body: `Thanks @${author}! You already have ${others.length} open issues, and each contributor can have at most ${cap} open at a time ` + + `(an issue someone else has claimed doesn't count), so I've closed this one. Once one of yours is closed ` + + `(its PR merged, or closed by a maintainer) or claimed by someone else, open it again as a new issue.` + + `\n\nYour open issues (${others.length}/${cap}):\n${list}` }); + await github.rest.issues.update({ owner, repo, issue_number: issue.number, + state: 'closed', state_reason: 'not_planned' }); diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 1b228c7..8872ec5 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -27,6 +27,8 @@ env: SLOT_CAP: '3' # D2: the three-issue cap is enforced for new claims. ENFORCE_CAP: 'true' + # How long after an issue is accepted only its opener can claim it. + OPENER_RESERVE_HOURS: '48' jobs: triage: @@ -233,6 +235,49 @@ jobs: + `Feel free to check back, or ping us on Discord if it's been quiet.` }); return; } + + // One claimant at a time: a second claim on a claimed issue would + // otherwise just add a co-assignee. + const holders = (context.payload.issue.assignees || []) + .map(a => a.login).filter(login => login !== user); + if (holders.length > 0 && !isMaintainer) { + await github.rest.issues.createComment({ owner, repo, issue_number: issue, + body: `Thanks for the interest, @${user}! This issue is already claimed by @${holders.join(', @')}. ` + + `If the claim is released, you can comment \`/assign\` again.` }); + return; + } + + // The opener's head start: for OPENER_RESERVE_HOURS after the issue + // is accepted, only the person who opened it can claim it. Issues + // opened by maintainers or bots are not reserved. + const opener = context.payload.issue.user; + if (opener && opener.login !== user && opener.type !== 'Bot' && !isMaintainer) { + let openerIsMaintainer; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: opener.login }); + openerIsMaintainer = ['admin', 'maintain', 'write'].includes(data.permission); + } catch { + openerIsMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(context.payload.issue.author_association); + } + if (!openerIsMaintainer) { + const events = await github.paginate(github.rest.issues.listEvents, + { owner, repo, issue_number: issue, per_page: 100 }); + const acceptedAt = events + .filter(e => e.event === 'labeled' && ACCEPT_LABELS.includes(e.label?.name)) + .map(e => Date.parse(e.created_at)) + .filter(t => !Number.isNaN(t)) + .reduce((latest, t) => Math.max(latest, t), 0); + const reservedUntil = acceptedAt + parseInt(process.env.OPENER_RESERVE_HOURS, 10) * 3600 * 1000; + if (acceptedAt && Date.now() < reservedUntil) { + const until = new Date(reservedUntil).toISOString().replace(/\.\d{3}Z$/, 'Z'); + await github.rest.issues.createComment({ owner, repo, issue_number: issue, + body: `Thanks for the interest, @${user}! @${opener.login} opened this issue, so it's reserved for them ` + + `until ${until} (${process.env.OPENER_RESERVE_HOURS} hours after it was accepted). ` + + `If they haven't claimed it by then, comment \`/assign\` again.` }); + return; + } + } + } } // The cap limits community claims; maintainers triage and hold many issues. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b7e16b..4b907e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,13 @@ Discussions**, so the issue tracker stays a clean, actionable list. - **Code changes**, including small fixes, new commands or flags, changed output, new dependencies, and refactors, follow **issue-first**: 1. Find an existing issue, or open a new one describing the change. + Community contributors can have at most **3 open issues of their own** + at a time. This counts issues you opened that are still open, unless + someone else has claimed them; your PRs and other people's issues don't + count. An issue stops counting once it is closed (its PR merged with + `Closes #123`, closed by a maintainer, or closed by you) or claimed by + someone else. A fourth is closed by the bot with a list of the three; you + can't reopen it, so open it again as a new issue once you're under the cap. 2. If the issue is new, wait for triage — we check proposals against [VISION.md](./VISION.md) and the [standing policies](#standing-scope-policies) below before any code is written, so you don't invest in something @@ -35,15 +42,20 @@ Discussions**, so the issue tracker stays a clean, actionable list. "I'll take this". It ignores quoted text and code. You can release a claim with `/unassign`. Community contributors can hold at most **3 open claimed issues**; the bot refuses further claims until a slot is free. - It replies with your open assignment count. + It replies with your open assignment count. If you opened the issue, + it is reserved for you for **48 hours** after it is accepted: only you + can claim it in that window, and to keep it you need a linked PR (a + draft is fine) within those same 48 hours. After that anyone can claim + it. An issue someone has already claimed can't be claimed by anyone else + until it is released. 4. Open your PR with a same-repository issue reference in its description: `Closes #123`, `Fixes #123`, or `Resolves #123`. For an umbrella issue that should stay open, use `Part of #123`, `Refs #123`, or `Related to #123`. The bot also accepts `Close`/`Closed`, `Fix`/`Fixed`, `Resolve`/`Resolved`, and `Ref`/`Reference`/`References` (case-insensitive, with an optional colon before `#123`). -- **Claim lifetime:** the bot warns after 48 hours and releases a claim after - 72 hours unless you have an open PR whose description references the issue +- **Claim lifetime:** the bot warns after 24 hours and releases a claim after + 48 hours unless you have an open PR whose description references the issue as in step 4 (a bare `#123` mention does not count). Draft PRs count, so opening a draft early holds the claim while you work. You can `/assign` again if the issue is still free.