From 6cb5e906458cb63c9f0c0067b209beb0d4577312 Mon Sep 17 00:00:00 2001 From: Fangyuan_Zhang Date: Wed, 21 Oct 2026 14:08:52 -0700 Subject: [PATCH 1/4] ci(oss): cap open issues per contributor and reserve an issue for its opener - issue-open-cap.yml: a community contributor can have at most 3 issues of their own open; a fourth (or a reopen over the cap) is closed with a list of the open ones. Maintainers and bots are exempt. - issue-triage.yml: for 48 hours after an issue is accepted only its opener can claim it, unless a maintainer opened it; and a claimed issue is no longer co-assigned to a second claimant. - CONTRIBUTING.md documents both rules. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/issue-open-cap.test.mjs | 131 ++++++++++++++++++++++++ .github/scripts/issue-triage.test.mjs | 107 ++++++++++++++++++- .github/workflows/issue-open-cap.yml | 76 ++++++++++++++ .github/workflows/issue-triage.yml | 45 ++++++++ CONTRIBUTING.md | 9 +- 5 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/issue-open-cap.test.mjs create mode 100644 .github/workflows/issue-open-cap.yml diff --git a/.github/scripts/issue-open-cap.test.mjs b/.github/scripts/issue-open-cap.test.mjs new file mode 100644 index 00000000..8174a85c --- /dev/null +++ b/.github/scripts/issue-open-cap.test.mjs @@ -0,0 +1,131 @@ +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('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 d439ea7c..3e52ee4d 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/issue-open-cap.yml b/.github/workflows/issue-open-cap.yml new file mode 100644 index 00000000..ac40f561 --- /dev/null +++ b/.github/workflows/issue-open-cap.yml @@ -0,0 +1,76 @@ +# Open-issue cap: a community contributor can have at most OPEN_CAP issues of +# their own open at once. A new (or reopened) issue over the cap is closed with +# a comment listing the open ones; the author can reopen it or open it again +# once one of those is closed (merged PR with `Closes #N`, or closed by us). +# 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 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); + 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, ` + + `so I've closed this one. Once one of yours is closed (its PR merged, or closed by a maintainer), ` + + `reopen this issue or open it again.\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 1b228c73..8872ec5b 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 0b7e16b8..ef0e7d01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,10 @@ 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; the bot closes a fourth with a list of the open ones. An issue + stops counting once it is closed (its PR merged with `Closes #123`, or + closed by a maintainer); then reopen yours or open it again. 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,7 +39,10 @@ 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. 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 From 6dead8dce1ee524aea1d25e4c9ac0d390950e309 Mon Sep 17 00:00:00 2001 From: Fangyuan_Zhang Date: Wed, 21 Oct 2026 14:19:58 -0700 Subject: [PATCH 2/4] ci(oss): release claims after 48 hours, counted from acceptance for the opener Every claim now needs a linked PR (a draft counts) within 48 hours, with a warning at 24. For the issue's opener the clock starts when the issue was accepted, so their reservation and their claim share one window. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/claim-ttl.mjs | 29 ++++++++++--- .github/scripts/claim-ttl.test.mjs | 65 +++++++++++++++++++++++++++++- .github/workflows/claim-ttl.yml | 10 +++-- CONTRIBUTING.md | 10 +++-- 4 files changed, 98 insertions(+), 16 deletions(-) diff --git a/.github/scripts/claim-ttl.mjs b/.github/scripts/claim-ttl.mjs index 4e1e7b51..75f0bad4 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 29db465b..f9952d91 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/workflows/claim-ttl.yml b/.github/workflows/claim-ttl.yml index bb38d5fe..caeab44f 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/CONTRIBUTING.md b/CONTRIBUTING.md index ef0e7d01..cc9148cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,16 +41,18 @@ Discussions**, so the issue tracker stays a clean, actionable list. claimed issues**; the bot refuses further claims until a slot is free. 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. An issue someone has already claimed can't - be claimed by anyone else until it is released. + 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. From 3f094a24535fdf4d0c6fd9a061ddf62a78971cea Mon Sep 17 00:00:00 2001 From: Fangyuan_Zhang Date: Wed, 21 Oct 2026 14:25:35 -0700 Subject: [PATCH 3/4] ci(oss): say an over-cap issue is opened again, not reopened An author can't reopen an issue the bot closed, so the comment and CONTRIBUTING point to opening a new issue instead. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/issue-open-cap.yml | 8 +++++--- CONTRIBUTING.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issue-open-cap.yml b/.github/workflows/issue-open-cap.yml index ac40f561..12dd109f 100644 --- a/.github/workflows/issue-open-cap.yml +++ b/.github/workflows/issue-open-cap.yml @@ -1,7 +1,9 @@ # Open-issue cap: a community contributor can have at most OPEN_CAP issues of # their own open at once. A new (or reopened) issue over the cap is closed with -# a comment listing the open ones; the author can reopen it or open it again -# once one of those is closed (merged PR with `Closes #N`, or closed by us). +# 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 @@ -71,6 +73,6 @@ jobs: 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, ` + `so I've closed this one. Once one of yours is closed (its PR merged, or closed by a maintainer), ` - + `reopen this issue or open it again.\n\nYour open issues (${others.length}/${cap}):\n${list}` }); + + `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/CONTRIBUTING.md b/CONTRIBUTING.md index cc9148cd..68714834 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ Discussions**, so the issue tracker stays a clean, actionable list. Community contributors can have at most **3 open issues of their own** at a time; the bot closes a fourth with a list of the open ones. An issue stops counting once it is closed (its PR merged with `Closes #123`, or - closed by a maintainer); then reopen yours or open it again. + closed by a maintainer); then open it again as a new issue. 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 From 82a463f433390934e5001f0d6c005dac39e31fe0 Mon Sep 17 00:00:00 2001 From: Fangyuan_Zhang Date: Wed, 21 Oct 2026 14:38:28 -0700 Subject: [PATCH 4/4] ci(oss): don't count an issue someone else claimed toward the open cap Once another contributor claims an issue, the work is theirs, so it no longer uses one of the opener's three open-issue slots. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/issue-open-cap.test.mjs | 10 ++++++++++ .github/workflows/issue-open-cap.yml | 14 +++++++++----- CONTRIBUTING.md | 9 ++++++--- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.github/scripts/issue-open-cap.test.mjs b/.github/scripts/issue-open-cap.test.mjs index 8174a85c..5ec16969 100644 --- a/.github/scripts/issue-open-cap.test.mjs +++ b/.github/scripts/issue-open-cap.test.mjs @@ -99,6 +99,16 @@ test('up to three open issues, and PRs, are left alone', async () => { 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 }); diff --git a/.github/workflows/issue-open-cap.yml b/.github/workflows/issue-open-cap.yml index 12dd109f..de51c6ec 100644 --- a/.github/workflows/issue-open-cap.yml +++ b/.github/workflows/issue-open-cap.yml @@ -1,5 +1,6 @@ # Open-issue cap: a community contributor can have at most OPEN_CAP issues of -# their own open at once. A new (or reopened) issue over the cap is closed with +# 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 @@ -64,15 +65,18 @@ jobs: } 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); + })).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, ` - + `so I've closed this one. Once one of yours is closed (its PR merged, or closed by a maintainer), ` - + `open it again as a new issue.\n\nYour open issues (${others.length}/${cap}):\n${list}` }); + 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/CONTRIBUTING.md b/CONTRIBUTING.md index 68714834..4b907e11 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,9 +25,12 @@ Discussions**, so the issue tracker stays a clean, actionable list. 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; the bot closes a fourth with a list of the open ones. An issue - stops counting once it is closed (its PR merged with `Closes #123`, or - closed by a maintainer); then open it again as a new issue. + 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