From 1c47883006efa0b30bcf6847a911a6c569667aca Mon Sep 17 00:00:00 2001 From: Zeshi Du Date: Mon, 19 Oct 2026 12:16:50 -0700 Subject: [PATCH] ci(oss): warn idle claims at 48 hours and release them at 72 The Season 4 hackathon rules published on testsprite.com promise a 48-hour warning and a 72-hour release for idle claims. The claim sweep was still on 72/96, so the published rules and the bot disagreed mid-contest. Align the bot with the published rules: the workflow env, the script defaults, the tests and CONTRIBUTING.md all move to 48/72. CONTRIBUTING also says outright that a draft PR holds the claim, which the sweep has always allowed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B55U1fjVffEERGXqo7FJfg --- .github/scripts/claim-ttl.mjs | 4 ++-- .github/scripts/claim-ttl.test.mjs | 36 +++++++++++++++--------------- .github/workflows/claim-ttl.yml | 6 ++--- CONTRIBUTING.md | 7 +++--- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/.github/scripts/claim-ttl.mjs b/.github/scripts/claim-ttl.mjs index dcb6d6de..4e1e7b51 100644 --- a/.github/scripts/claim-ttl.mjs +++ b/.github/scripts/claim-ttl.mjs @@ -46,8 +46,8 @@ export function evaluateClaim({ events, login, issueNumber, now, since, warn, tt } export async function run({ github, context, core, env, now = new Date() }) { - const ttl = readHours(env.CLAIM_TTL_HOURS, 96); - const warn = readHours(env.CLAIM_WARN_HOURS, 72); + const ttl = readHours(env.CLAIM_TTL_HOURS, 72); + const warn = readHours(env.CLAIM_WARN_HOURS, 48); const since = new Date(env.CLAIM_TTL_SINCE); if ( !Number.isFinite(ttl) || diff --git a/.github/scripts/claim-ttl.test.mjs b/.github/scripts/claim-ttl.test.mjs index 7a9c1600..29db465b 100644 --- a/.github/scripts/claim-ttl.test.mjs +++ b/.github/scripts/claim-ttl.test.mjs @@ -7,7 +7,7 @@ import { run } from './claim-ttl.mjs'; const NOW = new Date('2026-09-30T00:00:00Z'); const SINCE = '2026-09-24T00:00:00Z'; const assignedAt = hours => new Date(NOW.getTime() - hours * 60 * 60 * 1000).toISOString(); -const claim = (login = 'contributor', hours = 96) => ({ +const claim = (login = 'contributor', hours = 72) => ({ event: 'assigned', assignee: { login }, created_at: assignedAt(hours), @@ -97,15 +97,15 @@ function harness({ return { execute, writes, of, warnings, failures, info, records, commentStore, calls }; } -test('an assignment younger than 72 hours is left alone', async () => { - const h = harness({ events: { 42: [claim('contributor', 71.99)] } }); +test('an assignment younger than 48 hours is left alone', async () => { + const h = harness({ events: { 42: [claim('contributor', 47.99)] } }); await h.execute(); assert.deepEqual(h.writes(), []); assert.equal(h.failures.length, 0); }); -test('warning starts at 72 hours and is posted only once for the same assignment', async () => { - const h = harness({ events: { 42: [claim('contributor', 72)] } }); +test('warning starts at 48 hours and is posted only once for the same assignment', async () => { + const h = harness({ events: { 42: [claim('contributor', 48)] } }); await h.execute(); await h.execute(); assert.equal(h.of('createComment').length, 1); @@ -116,47 +116,47 @@ test('warning starts at 72 hours and is posted only once for the same assignment assert.match(h.of('createComment')[0].body, /\/assign/); assert.match( h.of('createComment')[0].body, - //, + //, ); assert.equal(h.failures.length, 0); }); test('the latest assignment event defines the current claim', async () => { - const h = harness({ events: { 42: [claim('contributor', 120), claim('contributor', 73)] } }); + const h = harness({ events: { 42: [claim('contributor', 120), claim('contributor', 49)] } }); await h.execute(); assert.equal(h.of('removeAssignees').length, 0); assert.equal(h.of('createComment').length, 1); assert.match( h.of('createComment')[0].body, - /claim-ttl:warn:contributor:2026-09-26T23:00:00.000Z/, + /claim-ttl:warn:contributor:2026-09-27T23:00:00.000Z/, ); }); test('a warning for an older assignment does not suppress this claim warning', async () => { const h = harness({ - events: { 42: [claim('contributor', 150), claim('contributor', 72)] }, + events: { 42: [claim('contributor', 150), claim('contributor', 48)] }, comments: { 42: [{ body: '' }] }, }); await h.execute(); assert.equal(h.of('createComment').length, 1); assert.match( h.of('createComment')[0].body, - /claim-ttl:warn:contributor:2026-09-27T00:00:00.000Z/, + /claim-ttl:warn:contributor:2026-09-28T00:00:00.000Z/, ); }); -test('at 96 hours the assignee is removed and the issue is released', async () => { +test('at 72 hours the assignee is removed and the issue is released', async () => { const h = harness(); await h.execute(); assert.equal(h.of('removeAssignees').length, 1); assert.deepEqual(h.of('removeAssignees')[0].assignees, ['contributor']); assert.equal(h.of('createComment').length, 1); - assert.match(h.of('createComment')[0].body, /expired after 96 hours/); + assert.match(h.of('createComment')[0].body, /expired after 72 hours/); assert.match(h.of('createComment')[0].body, /open for others/); assert.match(h.of('createComment')[0].body, /\/assign/); assert.match( h.of('createComment')[0].body, - //, + //, ); assert.deepEqual( h.of('removeLabel').map(args => args.name), @@ -169,7 +169,7 @@ test('at 96 hours the assignee is removed and the issue is released', async () = test('an existing expiry marker prevents a duplicate expiry comment', async () => { const h = harness({ - comments: { 42: [{ body: '' }] }, + comments: { 42: [{ body: '' }] }, }); await h.execute(); assert.equal(h.of('removeAssignees').length, 1); @@ -308,7 +308,7 @@ test('dry run reads and logs but makes no writes', async () => { assert.ok(h.info.some(message => message.includes('would expire'))); const warning = harness({ - events: { 42: [claim('contributor', 72)] }, + events: { 42: [claim('contributor', 48)] }, env: { DRY_RUN: 'true' }, }); await warning.execute(); @@ -355,7 +355,7 @@ test('invalid TTL settings fail before any writes', async () => { for (const env of [ { CLAIM_TTL_HOURS: '0' }, { CLAIM_WARN_HOURS: '-1' }, - { CLAIM_WARN_HOURS: '96' }, + { CLAIM_WARN_HOURS: '72' }, ]) { const h = harness({ env }); await h.execute(); @@ -368,8 +368,8 @@ 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: '96'$/m); - assert.match(workflow, /^ {2}CLAIM_WARN_HOURS: '72'$/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_SINCE: '2026-09-24T00:00:00Z'$/m); assert.match( workflow, diff --git a/.github/workflows/claim-ttl.yml b/.github/workflows/claim-ttl.yml index ac154d67..bb38d5fe 100644 --- a/.github/workflows/claim-ttl.yml +++ b/.github/workflows/claim-ttl.yml @@ -1,4 +1,4 @@ -# Sweep open issue claims hourly: warn at 72 hours and release at 96 hours +# 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 # from stale.yml because stale tracks inactivity in days and exempts the # labels used by the contributor claim flow. @@ -23,8 +23,8 @@ concurrency: cancel-in-progress: false env: - CLAIM_TTL_HOURS: '96' - CLAIM_WARN_HOURS: '72' + CLAIM_TTL_HOURS: '72' + CLAIM_WARN_HOURS: '48' 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 5cb31912..0b7e16b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,9 +42,10 @@ Discussions**, so the issue tracker stays a clean, actionable list. `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 72 hours and releases a claim after - 96 hours unless you have an open PR whose description references the issue - as in step 4 (a bare `#123` mention does not count). You can `/assign` again +- **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 + 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. - **PR gate:** a bot checks every community PR whose title does not start with `docs` for a referenced issue **assigned to the PR author**. PRs that don't