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
67 changes: 65 additions & 2 deletions .github/scripts/bonus-entry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ async function runPr({
state = 'open',
// The event payload; defaults to the live PR. A queued run can carry an older one.
payload,
// A workflow_dispatch sweep: no PR in the payload, these open PRs listed instead.
sweep,
// PR numbers whose pulls.get throws during a sweep.
failOn = [],
} = {}) {
const calls = [];
const info = [];
Expand All @@ -57,9 +61,14 @@ async function runPr({
const rest = {
pulls: {
get: async ({ pull_number }) => {
if (sweep) {
if (failOn.includes(pull_number)) throw Object.assign(new Error('boom'), { status: 500 });
return { data: { ...structuredClone(livePr), number: pull_number } };
}
assert.equal(pull_number, 7);
return { data: structuredClone(livePr) };
},
list: () => {},
},
issues: {
get: async ({ issue_number }) => {
Expand All @@ -81,6 +90,7 @@ async function runPr({
const github = {
rest,
paginate: async method => {
if (method === rest.pulls.list) return sweep;
assert.equal(method, rest.issues.listComments);
return comments;
},
Expand All @@ -101,10 +111,12 @@ async function runPr({
};
const context = {
repo: { owner: 'TestSprite', repo: 'testsprite-cli' },
payload: { pull_request: payload ?? livePr },
payload: sweep ? {} : { pull_request: payload ?? livePr },
};
const warnings = [];
const core = {
info: message => info.push(message),
warning: message => warnings.push(message),
setFailed: message => failures.push(message),
};
const saved = {};
Expand All @@ -122,7 +134,7 @@ async function runPr({
}
}
const of = name => calls.filter(([method]) => method === name).map(([, args]) => args);
return { calls, of, info, failures };
return { calls, of, info, failures, warnings };
}

const stateOf = body => /<!-- hob:s4-bonus:state:(\w+) -->/.exec(body)?.[1];
Expand Down Expand Up @@ -367,3 +379,54 @@ test('unticking keeps the withdrawal even when the PR still looks like an entry'
const again = await runPr({ body: untickedBody, comments: [botComment(withdrawn)] });
assert.deepEqual(again.calls, []);
});

test('the job token may write to PRs: a PR comment needs pull-requests write', () => {
assert.match(workflow, /^ {2}pull-requests: write$/m);
assert.match(workflow, /^ {2}issues: write /m);
});

test('a manual run sweeps every open PR not opened by a bot', async () => {
const h = await runPr({
sweep: [
{ number: 11, user: { type: 'User' } },
{ number: 12, user: { type: 'Bot' } },
{ number: 13, user: { type: 'User' } },
],
});
assert.deepEqual(
h.of('createComment').map(args => args.issue_number),
[11, 13],
);
assert.deepEqual(
h.of('addLabels').map(args => args.issue_number),
[11, 13],
);
assert.equal(h.failures.length, 0);
});

test('a sweep keeps going past a failing PR and fails the run at the end', async () => {
const h = await runPr({
sweep: [
{ number: 11, user: { type: 'User' } },
{ number: 12, user: { type: 'User' } },
{ number: 13, user: { type: 'User' } },
],
failOn: [12],
});
assert.deepEqual(
h.of('createComment').map(args => args.issue_number),
[11, 13],
);
assert.equal(h.warnings.length, 1);
assert.match(h.warnings[0], /PR #12/);
assert.deepEqual(h.failures, ['Bonus entry sweep failed for: #12']);
});

test('a sweep is gated like a PR event and serialised on its own group', () => {
assert.match(workflow, /^ {2}workflow_dispatch:$/m);
assert.match(workflow, /github\.event_name == 'workflow_dispatch' \|\|/);
assert.match(
workflow,
/group: bonus-entry-\$\{\{ github\.event\.pull_request\.number \|\| 'sweep' \}\}/,
);
});
37 changes: 32 additions & 5 deletions .github/workflows/bonus-entry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
# After BONUS_CLOSES_AT it makes no changes, so the record freezes at the close.
# Remove this workflow and the template section once Season 4 is paid out.
#
# A manual run (workflow_dispatch) sweeps every open PR, to backfill PRs whose
# runs failed before a fix landed: re-running a pull_request_target run reuses
# the workflow file as it was then.
#
# Runs with NO checkout: metadata-only, so it is safe under pull_request_target
# (required for fork PRs to get a write-capable token; see
# check-pr-target-checkout.mjs). Bot identity: same App-token-first /
Expand All @@ -23,13 +27,16 @@ name: Season 4 bonus entry
on:
pull_request_target:
types: [opened, edited, reopened, synchronize, ready_for_review, converted_to_draft]
workflow_dispatch:

permissions:
pull-requests: read # read the live PR and its closing issue references
# write, not read: GitHub refuses a comment on a PR number without it, even
# through the issues API (403 "Resource not accessible by integration").
pull-requests: write
issues: write # sticky comment + label (PR comments and labels ride the issues API)

concurrency:
group: bonus-entry-${{ github.event.pull_request.number }}
group: bonus-entry-${{ github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false

env:
Expand All @@ -42,8 +49,9 @@ jobs:
entry:
if: >-
github.repository == 'TestSprite/testsprite-cli' &&
github.event.pull_request.state == 'open' &&
github.event.pull_request.user.type != 'Bot'
(github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.state == 'open' &&
github.event.pull_request.user.type != 'Bot'))
runs-on: ubuntu-latest
steps:
# Mint an App token so comments post as testsprite-hob[bot]; until the
Expand Down Expand Up @@ -76,10 +84,12 @@ jobs:
return;
}

// One PR per call: the event's PR, or each open PR in a manual sweep.
async function processPr(number) {
// A queued run carries the PR as it was when its event fired. Read it
// fresh so back-to-back edits settle on the latest description and labels.
const { data: pr } = await github.rest.pulls.get(
{ owner, repo, pull_number: context.payload.pull_request.number });
{ owner, repo, pull_number: number });
if (pr.state !== 'open') {
core.info(`PR #${pr.number} is no longer open.`);
return;
Expand Down Expand Up @@ -224,3 +234,20 @@ jobs:
await write(rest => rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: label })).catch(() => {});
}
core.info(`Season 4 bonus entry: state=${state} ticked=${ticked} issueOk=${issueOk} draft=${pr.draft} discord=${hasDiscord}`);
}

if (context.payload.pull_request) {
await processPr(context.payload.pull_request.number);
} else {
// Manual sweep: every open PR not opened by a bot. One PR's failure
// never stops the rest; the run fails at the end if any did.
const open = await github.paginate(github.rest.pulls.list,
{ owner, repo, state: 'open', per_page: 100 });
const failed = [];
for (const p of open.filter(p => p.user?.type !== 'Bot')) {
try { await processPr(p.number); }
catch (e) { failed.push(p.number); core.warning(`PR #${p.number}: ${e.message}`); }
}
core.info(`Swept ${open.length} open PRs; ${failed.length} failed.`);
if (failed.length) core.setFailed(`Bonus entry sweep failed for: ${failed.map(n => `#${n}`).join(', ')}`);
}
Loading