Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/scripts/claim-ttl.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down
36 changes: 18 additions & 18 deletions .github/scripts/claim-ttl.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand All @@ -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,
/<!-- claim-ttl:warn:contributor:2026-09-27T00:00:00.000Z -->/,
/<!-- claim-ttl:warn:contributor:2026-09-28T00:00:00.000Z -->/,
);
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: '<!-- claim-ttl:warn:contributor:2026-09-23T18:00:00.000Z -->' }] },
});
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,
/<!-- claim-ttl:expired:contributor:2026-09-26T00:00:00.000Z -->/,
/<!-- claim-ttl:expired:contributor:2026-09-27T00:00:00.000Z -->/,
);
assert.deepEqual(
h.of('removeLabel').map(args => args.name),
Expand All @@ -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: '<!-- claim-ttl:expired:contributor:2026-09-26T00:00:00.000Z -->' }] },
comments: { 42: [{ body: '<!-- claim-ttl:expired:contributor:2026-09-27T00:00:00.000Z -->' }] },
});
await h.execute();
assert.equal(h.of('removeAssignees').length, 1);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/claim-ttl.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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' }}

Expand Down
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading