From 4baba1a63dc5b4c9cea3e5761b479f08ca61a6c7 Mon Sep 17 00:00:00 2001 From: Fangyuan_Zhang Date: Wed, 21 Oct 2026 13:15:42 -0700 Subject: [PATCH 1/3] ci(oss): let the Season 4 bonus bot comment on PRs, and backfill failed runs The bonus entry check failed on almost every entry: commenting on a PR number needs pull-requests: write, even through the issues API, and the workflow requested read. The App token lacks the permission too, so the GITHUB_TOKEN fallback 403'd as well. Grant pull-requests: write, and add a workflow_dispatch sweep over open PRs. Re-running a failed pull_request_target run reuses the old workflow file, so the sweep is the way to backfill. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/bonus-entry.test.mjs | 55 ++++- .github/workflows/bonus-entry.yml | 308 +++++++++++++++------------ 2 files changed, 220 insertions(+), 143 deletions(-) diff --git a/.github/scripts/bonus-entry.test.mjs b/.github/scripts/bonus-entry.test.mjs index 5aa9102..b7fa4a9 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,42 @@ 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, /^ pull-requests: write$/m); + assert.match(workflow, /^ 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, /^ 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..1a60933 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,151 +84,169 @@ jobs: return; } - // 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 }); - if (pr.state !== 'open') { - core.info(`PR #${pr.number} is no longer open.`); - return; - } - const author = pr.user.login; - - const appClient = process.env.APP_TOKEN - ? new (github.constructor)({ auth: process.env.APP_TOKEN }) - : null; - // Prefer the App identity; fall back to github-actions[bot] when the - // App is absent or lacks the permission (403/404). - async function write(fn) { - if (appClient) { - try { return await fn(appClient.rest); } - catch (e) { if (e.status !== 403 && e.status !== 404) throw e; } + 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: number }); + if (pr.state !== 'open') { + core.info(`PR #${pr.number} is no longer open.`); + return; } - return await fn(github.rest); - } + const author = pr.user.login; + + const appClient = process.env.APP_TOKEN + ? new (github.constructor)({ auth: process.env.APP_TOKEN }) + : null; + // Prefer the App identity; fall back to github-actions[bot] when the + // App is absent or lacks the permission (403/404). + async function write(fn) { + if (appClient) { + try { return await fn(appClient.rest); } + catch (e) { if (e.status !== 403 && e.status !== 404) throw e; } + } + return await fn(github.rest); + } + + // HTML comments are template guidance, never an entry. + const body = (pr.body || '').replace(//g, ''); + const box = /^[ \t]*[-*+][ \t]+\[([ xX])\][ \t]+I['’]m entering this PR for the Season 4 CLI Improvement Bonus/m.exec(body); + const ticked = Boolean(box) && box[1] !== ' '; + const discordLine = /^[ \t]*(?:[-*+][ \t]+)?\**Discord(?:[ \t]+(?:username|handle|user[ \t]*name))?\**[ \t]*:\**(.*)$/im.exec(body); + const discord = (discordLine ? discordLine[1] : '').replace(/[*_`]/g, '').trim(); + const hasDiscord = discord !== '' && !/^(?:<.*>|-+|n\/?a|none|tbd)$/i.test(discord); - // HTML comments are template guidance, never an entry. - const body = (pr.body || '').replace(//g, ''); - const box = /^[ \t]*[-*+][ \t]+\[([ xX])\][ \t]+I['’]m entering this PR for the Season 4 CLI Improvement Bonus/m.exec(body); - const ticked = Boolean(box) && box[1] !== ' '; - const discordLine = /^[ \t]*(?:[-*+][ \t]+)?\**Discord(?:[ \t]+(?:username|handle|user[ \t]*name))?\**[ \t]*:\**(.*)$/im.exec(body); - const discord = (discordLine ? discordLine[1] : '').replace(/[*_`]/g, '').trim(); - const hasDiscord = discord !== '' && !/^(?:<.*>|-+|n\/?a|none|tbd)$/i.test(discord); - - // Linked issues, the same way the issue-link gate (pr-triage.yml) - // finds them: GitHub-computed closing references, plus body keywords - // (including non-closing ones such as "Part of #N"). - const gql = await github.graphql( - `query($owner:String!,$repo:String!,$num:Int!){ - repository(owner:$owner,name:$repo){ - pullRequest(number:$num){ - closingIssuesReferences(first:10){ - nodes{ number title labels(first:20){ nodes{ name } } assignees(first:10){ nodes{ login } } } + // Linked issues, the same way the issue-link gate (pr-triage.yml) + // finds them: GitHub-computed closing references, plus body keywords + // (including non-closing ones such as "Part of #N"). + const gql = await github.graphql( + `query($owner:String!,$repo:String!,$num:Int!){ + repository(owner:$owner,name:$repo){ + pullRequest(number:$num){ + closingIssuesReferences(first:10){ + nodes{ number title labels(first:20){ nodes{ name } } assignees(first:10){ nodes{ login } } } + } } } - } - }`, { owner, repo, num: pr.number }); - const linkedIssues = gql.repository.pullRequest.closingIssuesReferences.nodes.map(n => ({ - number: n.number, - title: n.title || '', - labels: n.labels.nodes.map(l => l.name), - assignees: n.assignees.nodes.map(a => a.login), - })); - const bodyRefs = [...body - .matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|refs?|references?|related\s+to)\s*:?\s+#(\d+)/gi)] - .map(m => Number(m[1])) - .filter(n => !linkedIssues.some(i => i.number === n)) - .slice(0, 5); - for (const num of bodyRefs) { - try { - const { data } = await github.rest.issues.get({ owner, repo, issue_number: num }); - if (data.pull_request) continue; // "#N" pointed at a PR, not an issue - linkedIssues.push({ - number: num, - title: data.title || '', - labels: (data.labels || []).map(l => (typeof l === 'string' ? l : l.name)), - assignees: (data.assignees || []).map(a => a.login), - }); - } catch (e) { /* unknown number — ignore */ } - } + }`, { owner, repo, num: pr.number }); + const linkedIssues = gql.repository.pullRequest.closingIssuesReferences.nodes.map(n => ({ + number: n.number, + title: n.title || '', + labels: n.labels.nodes.map(l => l.name), + assignees: n.assignees.nodes.map(a => a.login), + })); + const bodyRefs = [...body + .matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|refs?|references?|related\s+to)\s*:?\s+#(\d+)/gi)] + .map(m => Number(m[1])) + .filter(n => !linkedIssues.some(i => i.number === n)) + .slice(0, 5); + for (const num of bodyRefs) { + try { + const { data } = await github.rest.issues.get({ owner, repo, issue_number: num }); + if (data.pull_request) continue; // "#N" pointed at a PR, not an issue + linkedIssues.push({ + number: num, + title: data.title || '', + labels: (data.labels || []).map(l => (typeof l === 'string' ? l : l.name)), + assignees: (data.assignees || []).map(a => a.login), + }); + } catch (e) { /* unknown number — ignore */ } + } - const hackathonTitle = /^\s*\[hackathon\]/i; - const looksLikeEntry = - hackathonTitle.test(pr.title || '') || - hasDiscord || - linkedIssues.some(i => i.labels.includes('hackathon') || hackathonTitle.test(i.title)); - const issueOk = linkedIssues.some(i => i.assignees.includes(author)); - - const comments = await github.paginate(github.rest.issues.listComments, - { owner, repo, issue_number: pr.number, per_page: 100 }); - // Anyone can paste the marker, so only a comment by this bot is the sticky one. - const botLogins = ['github-actions[bot]', 'testsprite-hob[bot]']; - const sticky = comments.find(c => botLogins.includes(c.user?.login) && (c.body || '').includes(marker)); - const previous = //.exec(sticky?.body || '')?.[1]; - - const rulesUrl = 'https://www.testsprite.com/hackathon-s4'; - const close = 'Oct 29, 11:59 PM PT'; - let state; - let text; - if (ticked) { - state = 'entered'; - const issueLine = issueOk - ? '✅ A linked issue assigned to you.' - : linkedIssues.length > 0 - ? '⬜ A linked issue assigned to you: the issue this PR links isn\'t assigned to you yet. Claim it by commenting `/assign` on it.' - : '⬜ A linked issue assigned to you: add `Closes #123` (or `Part of #123` for an umbrella issue) to the description.'; - const draftLine = pr.draft - ? `⬜ Out of draft: this PR is still a draft. Mark it ready for review before ${close}.` - : '✅ Out of draft.'; - const discordStatus = hasDiscord - ? '✅ A Discord username in the description.' - : 'ℹ️ No Discord username in the description. Add one so we can reach you, or we\'ll reach you here on the PR.'; - text = `🏆 **Season 4 CLI Improvement Bonus: entry recorded.** Thanks, @${author}!\n\n` - + `To be counted, this PR needs the following by **${close}**:\n\n` - + `- ${issueLine}\n- ${draftLine}\n- ${discordStatus}\n\n` - + 'Rewards go to merged PRs judged a genuine improvement, and up to three bonus-eligible PRs count per person. ' - + `Untick the box to withdraw the entry. This comment refreshes when you edit the PR. [Full rules](${rulesUrl}).`; - } else if (previous === 'entered' || previous === 'withdrawn') { - // Before the near-miss check: an entrant who unticks usually still - // has a Discord username filled in, which would read as a near miss. - state = 'withdrawn'; - text = `This PR is no longer entered for the Season 4 CLI Improvement Bonus (the entry box is unticked). ` - + `Tick it again before **${close}** to re-enter.`; - } else if (looksLikeEntry) { - state = 'maybe'; - text = `Hi @${author}, this looks like it might be a Season 4 CLI Improvement Bonus entry, but the entry box in the description isn't ticked. ` - + `To enter, edit the description so it includes this section, with the box ticked, before **${close}**:\n\n` - + '```markdown\n## Season 4 CLI Improvement Bonus\n\n' - + '- [x] I\'m entering this PR for the Season 4 CLI Improvement Bonus\n\n' - + 'Discord username: your-username\n```\n\n' - + `If this PR isn't an entry, you can ignore this comment. [Full rules](${rulesUrl}).`; - } else { - state = 'none'; - } + const hackathonTitle = /^\s*\[hackathon\]/i; + const looksLikeEntry = + hackathonTitle.test(pr.title || '') || + hasDiscord || + linkedIssues.some(i => i.labels.includes('hackathon') || hackathonTitle.test(i.title)); + const issueOk = linkedIssues.some(i => i.assignees.includes(author)); + + const comments = await github.paginate(github.rest.issues.listComments, + { owner, repo, issue_number: pr.number, per_page: 100 }); + // Anyone can paste the marker, so only a comment by this bot is the sticky one. + const botLogins = ['github-actions[bot]', 'testsprite-hob[bot]']; + const sticky = comments.find(c => botLogins.includes(c.user?.login) && (c.body || '').includes(marker)); + const previous = //.exec(sticky?.body || '')?.[1]; - if (state !== 'none') { - const full = `${marker}\n\n${text}`; - if (!sticky) { - await write(rest => rest.issues.createComment({ owner, repo, issue_number: pr.number, body: full })); - } else if (sticky.body !== full) { - await write(rest => rest.issues.updateComment({ owner, repo, comment_id: sticky.id, body: full })); + const rulesUrl = 'https://www.testsprite.com/hackathon-s4'; + const close = 'Oct 29, 11:59 PM PT'; + let state; + let text; + if (ticked) { + state = 'entered'; + const issueLine = issueOk + ? '✅ A linked issue assigned to you.' + : linkedIssues.length > 0 + ? '⬜ A linked issue assigned to you: the issue this PR links isn\'t assigned to you yet. Claim it by commenting `/assign` on it.' + : '⬜ A linked issue assigned to you: add `Closes #123` (or `Part of #123` for an umbrella issue) to the description.'; + const draftLine = pr.draft + ? `⬜ Out of draft: this PR is still a draft. Mark it ready for review before ${close}.` + : '✅ Out of draft.'; + const discordStatus = hasDiscord + ? '✅ A Discord username in the description.' + : 'ℹ️ No Discord username in the description. Add one so we can reach you, or we\'ll reach you here on the PR.'; + text = `🏆 **Season 4 CLI Improvement Bonus: entry recorded.** Thanks, @${author}!\n\n` + + `To be counted, this PR needs the following by **${close}**:\n\n` + + `- ${issueLine}\n- ${draftLine}\n- ${discordStatus}\n\n` + + 'Rewards go to merged PRs judged a genuine improvement, and up to three bonus-eligible PRs count per person. ' + + `Untick the box to withdraw the entry. This comment refreshes when you edit the PR. [Full rules](${rulesUrl}).`; + } else if (previous === 'entered' || previous === 'withdrawn') { + // Before the near-miss check: an entrant who unticks usually still + // has a Discord username filled in, which would read as a near miss. + state = 'withdrawn'; + text = `This PR is no longer entered for the Season 4 CLI Improvement Bonus (the entry box is unticked). ` + + `Tick it again before **${close}** to re-enter.`; + } else if (looksLikeEntry) { + state = 'maybe'; + text = `Hi @${author}, this looks like it might be a Season 4 CLI Improvement Bonus entry, but the entry box in the description isn't ticked. ` + + `To enter, edit the description so it includes this section, with the box ticked, before **${close}**:\n\n` + + '```markdown\n## Season 4 CLI Improvement Bonus\n\n' + + '- [x] I\'m entering this PR for the Season 4 CLI Improvement Bonus\n\n' + + 'Discord username: your-username\n```\n\n' + + `If this PR isn't an entry, you can ignore this comment. [Full rules](${rulesUrl}).`; + } else { + state = 'none'; } - } - const hasLabel = (pr.labels || []).some(l => l.name === label); - if (state === 'entered' && !hasLabel) { - try { - await github.rest.issues.getLabel({ owner, repo, name: label }); - } catch (e) { - if (e.status !== 404) throw e; - await write(rest => rest.issues.createLabel({ - owner, repo, name: label, color: 'fbca04', - description: 'Entered for the Season 4 CLI Improvement Bonus', - })); + if (state !== 'none') { + const full = `${marker}\n\n${text}`; + if (!sticky) { + await write(rest => rest.issues.createComment({ owner, repo, issue_number: pr.number, body: full })); + } else if (sticky.body !== full) { + await write(rest => rest.issues.updateComment({ owner, repo, comment_id: sticky.id, body: full })); + } } - await write(rest => rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [label] })); + + const hasLabel = (pr.labels || []).some(l => l.name === label); + if (state === 'entered' && !hasLabel) { + try { + await github.rest.issues.getLabel({ owner, repo, name: label }); + } catch (e) { + if (e.status !== 404) throw e; + await write(rest => rest.issues.createLabel({ + owner, repo, name: label, color: 'fbca04', + description: 'Entered for the Season 4 CLI Improvement Bonus', + })); + } + await write(rest => rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [label] })); + } + if (state !== 'entered' && hasLabel) { + 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 (state !== 'entered' && hasLabel) { - await write(rest => rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: label })).catch(() => {}); + + 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(', ')}`); } - core.info(`Season 4 bonus entry: state=${state} ticked=${ticked} issueOk=${issueOk} draft=${pr.draft} discord=${hasDiscord}`); From 8b8b0ab64089a83a47cdff0065e79124239eb98b Mon Sep 17 00:00:00 2001 From: Fangyuan_Zhang Date: Wed, 21 Oct 2026 13:50:57 -0700 Subject: [PATCH 2/3] test(oss): satisfy no-regex-spaces in the bonus entry checks Co-Authored-By: Claude Opus 5.5 --- .github/scripts/bonus-entry.test.mjs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/scripts/bonus-entry.test.mjs b/.github/scripts/bonus-entry.test.mjs index b7fa4a9..14d4afc 100644 --- a/.github/scripts/bonus-entry.test.mjs +++ b/.github/scripts/bonus-entry.test.mjs @@ -381,8 +381,8 @@ test('unticking keeps the withdrawal even when the PR still looks like an entry' }); test('the job token may write to PRs: a PR comment needs pull-requests write', () => { - assert.match(workflow, /^ pull-requests: write$/m); - assert.match(workflow, /^ issues: write /m); + 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 () => { @@ -393,8 +393,14 @@ test('a manual run sweeps every open PR not opened by a bot', async () => { { 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.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); }); @@ -407,14 +413,20 @@ test('a sweep keeps going past a failing PR and fails the run at the end', async ], failOn: [12], }); - assert.deepEqual(h.of('createComment').map(args => args.issue_number), [11, 13]); + 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, /^ workflow_dispatch:$/m); + 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' \}\}/); + assert.match( + workflow, + /group: bonus-entry-\$\{\{ github\.event\.pull_request\.number \|\| 'sweep' \}\}/, + ); }); From 42275156bd16dcb1192bfa25343afaff8176a293 Mon Sep 17 00:00:00 2001 From: Fangyuan_Zhang Date: Wed, 21 Oct 2026 13:57:51 -0700 Subject: [PATCH 3/3] ci(oss): keep the bonus entry script's original indentation Wrap the per-PR logic in processPr without re-indenting it, so the diff shows only the lines that changed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bonus-entry.yml | 279 +++++++++++++++--------------- 1 file changed, 140 insertions(+), 139 deletions(-) diff --git a/.github/workflows/bonus-entry.yml b/.github/workflows/bonus-entry.yml index 1a60933..6f0c59e 100644 --- a/.github/workflows/bonus-entry.yml +++ b/.github/workflows/bonus-entry.yml @@ -84,155 +84,156 @@ 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: number }); - if (pr.state !== 'open') { - core.info(`PR #${pr.number} is no longer open.`); - return; - } - const author = pr.user.login; - - const appClient = process.env.APP_TOKEN - ? new (github.constructor)({ auth: process.env.APP_TOKEN }) - : null; - // Prefer the App identity; fall back to github-actions[bot] when the - // App is absent or lacks the permission (403/404). - async function write(fn) { - if (appClient) { - try { return await fn(appClient.rest); } - catch (e) { if (e.status !== 403 && e.status !== 404) throw e; } - } - return await fn(github.rest); + // 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: number }); + if (pr.state !== 'open') { + core.info(`PR #${pr.number} is no longer open.`); + return; + } + const author = pr.user.login; + + const appClient = process.env.APP_TOKEN + ? new (github.constructor)({ auth: process.env.APP_TOKEN }) + : null; + // Prefer the App identity; fall back to github-actions[bot] when the + // App is absent or lacks the permission (403/404). + async function write(fn) { + if (appClient) { + try { return await fn(appClient.rest); } + catch (e) { if (e.status !== 403 && e.status !== 404) throw e; } } + return await fn(github.rest); + } - // HTML comments are template guidance, never an entry. - const body = (pr.body || '').replace(//g, ''); - const box = /^[ \t]*[-*+][ \t]+\[([ xX])\][ \t]+I['’]m entering this PR for the Season 4 CLI Improvement Bonus/m.exec(body); - const ticked = Boolean(box) && box[1] !== ' '; - const discordLine = /^[ \t]*(?:[-*+][ \t]+)?\**Discord(?:[ \t]+(?:username|handle|user[ \t]*name))?\**[ \t]*:\**(.*)$/im.exec(body); - const discord = (discordLine ? discordLine[1] : '').replace(/[*_`]/g, '').trim(); - const hasDiscord = discord !== '' && !/^(?:<.*>|-+|n\/?a|none|tbd)$/i.test(discord); - - // Linked issues, the same way the issue-link gate (pr-triage.yml) - // finds them: GitHub-computed closing references, plus body keywords - // (including non-closing ones such as "Part of #N"). - const gql = await github.graphql( - `query($owner:String!,$repo:String!,$num:Int!){ - repository(owner:$owner,name:$repo){ - pullRequest(number:$num){ - closingIssuesReferences(first:10){ - nodes{ number title labels(first:20){ nodes{ name } } assignees(first:10){ nodes{ login } } } - } + // HTML comments are template guidance, never an entry. + const body = (pr.body || '').replace(//g, ''); + const box = /^[ \t]*[-*+][ \t]+\[([ xX])\][ \t]+I['’]m entering this PR for the Season 4 CLI Improvement Bonus/m.exec(body); + const ticked = Boolean(box) && box[1] !== ' '; + const discordLine = /^[ \t]*(?:[-*+][ \t]+)?\**Discord(?:[ \t]+(?:username|handle|user[ \t]*name))?\**[ \t]*:\**(.*)$/im.exec(body); + const discord = (discordLine ? discordLine[1] : '').replace(/[*_`]/g, '').trim(); + const hasDiscord = discord !== '' && !/^(?:<.*>|-+|n\/?a|none|tbd)$/i.test(discord); + + // Linked issues, the same way the issue-link gate (pr-triage.yml) + // finds them: GitHub-computed closing references, plus body keywords + // (including non-closing ones such as "Part of #N"). + const gql = await github.graphql( + `query($owner:String!,$repo:String!,$num:Int!){ + repository(owner:$owner,name:$repo){ + pullRequest(number:$num){ + closingIssuesReferences(first:10){ + nodes{ number title labels(first:20){ nodes{ name } } assignees(first:10){ nodes{ login } } } } } - }`, { owner, repo, num: pr.number }); - const linkedIssues = gql.repository.pullRequest.closingIssuesReferences.nodes.map(n => ({ - number: n.number, - title: n.title || '', - labels: n.labels.nodes.map(l => l.name), - assignees: n.assignees.nodes.map(a => a.login), - })); - const bodyRefs = [...body - .matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|refs?|references?|related\s+to)\s*:?\s+#(\d+)/gi)] - .map(m => Number(m[1])) - .filter(n => !linkedIssues.some(i => i.number === n)) - .slice(0, 5); - for (const num of bodyRefs) { - try { - const { data } = await github.rest.issues.get({ owner, repo, issue_number: num }); - if (data.pull_request) continue; // "#N" pointed at a PR, not an issue - linkedIssues.push({ - number: num, - title: data.title || '', - labels: (data.labels || []).map(l => (typeof l === 'string' ? l : l.name)), - assignees: (data.assignees || []).map(a => a.login), - }); - } catch (e) { /* unknown number — ignore */ } - } + } + }`, { owner, repo, num: pr.number }); + const linkedIssues = gql.repository.pullRequest.closingIssuesReferences.nodes.map(n => ({ + number: n.number, + title: n.title || '', + labels: n.labels.nodes.map(l => l.name), + assignees: n.assignees.nodes.map(a => a.login), + })); + const bodyRefs = [...body + .matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|refs?|references?|related\s+to)\s*:?\s+#(\d+)/gi)] + .map(m => Number(m[1])) + .filter(n => !linkedIssues.some(i => i.number === n)) + .slice(0, 5); + for (const num of bodyRefs) { + try { + const { data } = await github.rest.issues.get({ owner, repo, issue_number: num }); + if (data.pull_request) continue; // "#N" pointed at a PR, not an issue + linkedIssues.push({ + number: num, + title: data.title || '', + labels: (data.labels || []).map(l => (typeof l === 'string' ? l : l.name)), + assignees: (data.assignees || []).map(a => a.login), + }); + } catch (e) { /* unknown number — ignore */ } + } - const hackathonTitle = /^\s*\[hackathon\]/i; - const looksLikeEntry = - hackathonTitle.test(pr.title || '') || - hasDiscord || - linkedIssues.some(i => i.labels.includes('hackathon') || hackathonTitle.test(i.title)); - const issueOk = linkedIssues.some(i => i.assignees.includes(author)); - - const comments = await github.paginate(github.rest.issues.listComments, - { owner, repo, issue_number: pr.number, per_page: 100 }); - // Anyone can paste the marker, so only a comment by this bot is the sticky one. - const botLogins = ['github-actions[bot]', 'testsprite-hob[bot]']; - const sticky = comments.find(c => botLogins.includes(c.user?.login) && (c.body || '').includes(marker)); - const previous = //.exec(sticky?.body || '')?.[1]; - - const rulesUrl = 'https://www.testsprite.com/hackathon-s4'; - const close = 'Oct 29, 11:59 PM PT'; - let state; - let text; - if (ticked) { - state = 'entered'; - const issueLine = issueOk - ? '✅ A linked issue assigned to you.' - : linkedIssues.length > 0 - ? '⬜ A linked issue assigned to you: the issue this PR links isn\'t assigned to you yet. Claim it by commenting `/assign` on it.' - : '⬜ A linked issue assigned to you: add `Closes #123` (or `Part of #123` for an umbrella issue) to the description.'; - const draftLine = pr.draft - ? `⬜ Out of draft: this PR is still a draft. Mark it ready for review before ${close}.` - : '✅ Out of draft.'; - const discordStatus = hasDiscord - ? '✅ A Discord username in the description.' - : 'ℹ️ No Discord username in the description. Add one so we can reach you, or we\'ll reach you here on the PR.'; - text = `🏆 **Season 4 CLI Improvement Bonus: entry recorded.** Thanks, @${author}!\n\n` - + `To be counted, this PR needs the following by **${close}**:\n\n` - + `- ${issueLine}\n- ${draftLine}\n- ${discordStatus}\n\n` - + 'Rewards go to merged PRs judged a genuine improvement, and up to three bonus-eligible PRs count per person. ' - + `Untick the box to withdraw the entry. This comment refreshes when you edit the PR. [Full rules](${rulesUrl}).`; - } else if (previous === 'entered' || previous === 'withdrawn') { - // Before the near-miss check: an entrant who unticks usually still - // has a Discord username filled in, which would read as a near miss. - state = 'withdrawn'; - text = `This PR is no longer entered for the Season 4 CLI Improvement Bonus (the entry box is unticked). ` - + `Tick it again before **${close}** to re-enter.`; - } else if (looksLikeEntry) { - state = 'maybe'; - text = `Hi @${author}, this looks like it might be a Season 4 CLI Improvement Bonus entry, but the entry box in the description isn't ticked. ` - + `To enter, edit the description so it includes this section, with the box ticked, before **${close}**:\n\n` - + '```markdown\n## Season 4 CLI Improvement Bonus\n\n' - + '- [x] I\'m entering this PR for the Season 4 CLI Improvement Bonus\n\n' - + 'Discord username: your-username\n```\n\n' - + `If this PR isn't an entry, you can ignore this comment. [Full rules](${rulesUrl}).`; - } else { - state = 'none'; - } + const hackathonTitle = /^\s*\[hackathon\]/i; + const looksLikeEntry = + hackathonTitle.test(pr.title || '') || + hasDiscord || + linkedIssues.some(i => i.labels.includes('hackathon') || hackathonTitle.test(i.title)); + const issueOk = linkedIssues.some(i => i.assignees.includes(author)); + + const comments = await github.paginate(github.rest.issues.listComments, + { owner, repo, issue_number: pr.number, per_page: 100 }); + // Anyone can paste the marker, so only a comment by this bot is the sticky one. + const botLogins = ['github-actions[bot]', 'testsprite-hob[bot]']; + const sticky = comments.find(c => botLogins.includes(c.user?.login) && (c.body || '').includes(marker)); + const previous = //.exec(sticky?.body || '')?.[1]; + + const rulesUrl = 'https://www.testsprite.com/hackathon-s4'; + const close = 'Oct 29, 11:59 PM PT'; + let state; + let text; + if (ticked) { + state = 'entered'; + const issueLine = issueOk + ? '✅ A linked issue assigned to you.' + : linkedIssues.length > 0 + ? '⬜ A linked issue assigned to you: the issue this PR links isn\'t assigned to you yet. Claim it by commenting `/assign` on it.' + : '⬜ A linked issue assigned to you: add `Closes #123` (or `Part of #123` for an umbrella issue) to the description.'; + const draftLine = pr.draft + ? `⬜ Out of draft: this PR is still a draft. Mark it ready for review before ${close}.` + : '✅ Out of draft.'; + const discordStatus = hasDiscord + ? '✅ A Discord username in the description.' + : 'ℹ️ No Discord username in the description. Add one so we can reach you, or we\'ll reach you here on the PR.'; + text = `🏆 **Season 4 CLI Improvement Bonus: entry recorded.** Thanks, @${author}!\n\n` + + `To be counted, this PR needs the following by **${close}**:\n\n` + + `- ${issueLine}\n- ${draftLine}\n- ${discordStatus}\n\n` + + 'Rewards go to merged PRs judged a genuine improvement, and up to three bonus-eligible PRs count per person. ' + + `Untick the box to withdraw the entry. This comment refreshes when you edit the PR. [Full rules](${rulesUrl}).`; + } else if (previous === 'entered' || previous === 'withdrawn') { + // Before the near-miss check: an entrant who unticks usually still + // has a Discord username filled in, which would read as a near miss. + state = 'withdrawn'; + text = `This PR is no longer entered for the Season 4 CLI Improvement Bonus (the entry box is unticked). ` + + `Tick it again before **${close}** to re-enter.`; + } else if (looksLikeEntry) { + state = 'maybe'; + text = `Hi @${author}, this looks like it might be a Season 4 CLI Improvement Bonus entry, but the entry box in the description isn't ticked. ` + + `To enter, edit the description so it includes this section, with the box ticked, before **${close}**:\n\n` + + '```markdown\n## Season 4 CLI Improvement Bonus\n\n' + + '- [x] I\'m entering this PR for the Season 4 CLI Improvement Bonus\n\n' + + 'Discord username: your-username\n```\n\n' + + `If this PR isn't an entry, you can ignore this comment. [Full rules](${rulesUrl}).`; + } else { + state = 'none'; + } - if (state !== 'none') { - const full = `${marker}\n\n${text}`; - if (!sticky) { - await write(rest => rest.issues.createComment({ owner, repo, issue_number: pr.number, body: full })); - } else if (sticky.body !== full) { - await write(rest => rest.issues.updateComment({ owner, repo, comment_id: sticky.id, body: full })); - } + if (state !== 'none') { + const full = `${marker}\n\n${text}`; + if (!sticky) { + await write(rest => rest.issues.createComment({ owner, repo, issue_number: pr.number, body: full })); + } else if (sticky.body !== full) { + await write(rest => rest.issues.updateComment({ owner, repo, comment_id: sticky.id, body: full })); } + } - const hasLabel = (pr.labels || []).some(l => l.name === label); - if (state === 'entered' && !hasLabel) { - try { - await github.rest.issues.getLabel({ owner, repo, name: label }); - } catch (e) { - if (e.status !== 404) throw e; - await write(rest => rest.issues.createLabel({ - owner, repo, name: label, color: 'fbca04', - description: 'Entered for the Season 4 CLI Improvement Bonus', - })); - } - await write(rest => rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [label] })); - } - if (state !== 'entered' && hasLabel) { - await write(rest => rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: label })).catch(() => {}); + const hasLabel = (pr.labels || []).some(l => l.name === label); + if (state === 'entered' && !hasLabel) { + try { + await github.rest.issues.getLabel({ owner, repo, name: label }); + } catch (e) { + if (e.status !== 404) throw e; + await write(rest => rest.issues.createLabel({ + owner, repo, name: label, color: 'fbca04', + description: 'Entered for the Season 4 CLI Improvement Bonus', + })); } - core.info(`Season 4 bonus entry: state=${state} ticked=${ticked} issueOk=${issueOk} draft=${pr.draft} discord=${hasDiscord}`); + await write(rest => rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [label] })); + } + if (state !== 'entered' && hasLabel) { + 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) {