diff --git a/.github/scripts/bonus-entry.test.mjs b/.github/scripts/bonus-entry.test.mjs index 5aa9102..14d4afc 100644 --- a/.github/scripts/bonus-entry.test.mjs +++ b/.github/scripts/bonus-entry.test.mjs @@ -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 = []; @@ -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 }) => { @@ -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; }, @@ -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 = {}; @@ -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 => //.exec(body)?.[1]; @@ -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' \}\}/, + ); +}); diff --git a/.github/workflows/bonus-entry.yml b/.github/workflows/bonus-entry.yml index 420afe7..6f0c59e 100644 --- a/.github/workflows/bonus-entry.yml +++ b/.github/workflows/bonus-entry.yml @@ -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 / @@ -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: @@ -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 @@ -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; @@ -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(', ')}`); + }