Repository navigation
ci(oss): cap open issues per contributor, reserve an issue for its opener, release claims after 48h #635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ci(oss): cap open issues per contributor, reserve an issue for its opener, release claims after 48h #635
Changes from all commits
6cb5e90
6dead8d
3f094a2
82a463f
503068a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = `<!-- claim-ttl:${stage}:${login}:${assignedAt.toISOString()} -->`; | ||
| 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}`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Report the claim’s actual remaining time. If the opener claims 47 hours after acceptance, the warning says “about 24 hours” remain. The claim expires about one hour later. Return the remaining time calculated from 🤖 Prompt for AI Agents |
||
| 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'/); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Start a new claim window after the opener reservation ends.
If an opener claims or re-claims more than
ttlhours after acceptance,fromAcceptancestill selects the old acceptance time. The next sweep expires that new claim immediately unless the opener has already linked a PR. The expiry comment offers re-claiming, but this calculation prevents a later claim from receiving its 48-hour PR window. Use assignment time when the assignment occurs after the acceptance window; keep acceptance time for claims made within that window.🤖 Prompt for AI Agents