diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c7c0744..7fbf381 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,6 +16,17 @@ can agree on the approach before you invest time — see CONTRIBUTING.md. community PRs unless the title starts with `docs`: claim an eligible issue with `/assign` first. See CONTRIBUTING.md → Contribution model. --> +## Season 4 CLI Improvement Bonus + + + +- [ ] I'm entering this PR for the Season 4 CLI Improvement Bonus + +Discord username: + ## Type of change - [ ] Bug fix (non-breaking change that fixes an issue) diff --git a/.github/scripts/bonus-entry.test.mjs b/.github/scripts/bonus-entry.test.mjs new file mode 100644 index 0000000..5aa9102 --- /dev/null +++ b/.github/scripts/bonus-entry.test.mjs @@ -0,0 +1,369 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const workflowPath = fileURLToPath(new URL('../workflows/bonus-entry.yml', import.meta.url)); +const workflow = readFileSync(workflowPath, 'utf8'); +const lines = workflow.split('\n'); +const scriptStart = lines.indexOf(' script: |'); +assert.notEqual(scriptStart, -1); +const script = lines + .slice(scriptStart + 1) + .map(line => { + if (line.length === 0) return ''; + assert.ok(line.startsWith(' ')); + return line.slice(12); + }) + .join('\n'); +const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; +const runScript = new AsyncFunction('github', 'context', 'core', script); + +const templatePath = fileURLToPath(new URL('../PULL_REQUEST_TEMPLATE.md', import.meta.url)); +const template = readFileSync(templatePath, 'utf8'); +const TICKED = "- [x] I'm entering this PR for the Season 4 CLI Improvement Bonus"; +const UNTICKED = "- [ ] I'm entering this PR for the Season 4 CLI Improvement Bonus"; +const MARKER = ''; +const LABEL = 's4-cli-bonus'; + +async function runPr({ + body = `Closes #42\n\n${TICKED}\n\nDiscord username: contributor#1234`, + title = 'fix(cli): a real fix', + draft = false, + labels = [], + comments = [], + // Issues the GraphQL closing-reference query returns. + closing = [{ number: 42, title: 'Some bug', labels: [], assignees: ['contributor'] }], + // Issues reachable through issues.get for body-only references. + issues = {}, + labelExists = true, + closesAt = '2999-01-01T00:00:00Z', + state = 'open', + // The event payload; defaults to the live PR. A queued run can carry an older one. + payload, +} = {}) { + const calls = []; + const info = []; + const failures = []; + const livePr = { + number: 7, + state, + title, + body, + draft, + labels: labels.map(name => ({ name })), + user: { login: 'contributor' }, + }; + const rest = { + pulls: { + get: async ({ pull_number }) => { + assert.equal(pull_number, 7); + return { data: structuredClone(livePr) }; + }, + }, + issues: { + get: async ({ issue_number }) => { + if (!issues[issue_number]) throw Object.assign(new Error('Not Found'), { status: 404 }); + return { data: issues[issue_number] }; + }, + listComments: () => {}, + createComment: async args => calls.push(['createComment', args]), + updateComment: async args => calls.push(['updateComment', args]), + addLabels: async args => calls.push(['addLabels', args]), + removeLabel: async args => calls.push(['removeLabel', args]), + createLabel: async args => calls.push(['createLabel', args]), + getLabel: async () => { + if (!labelExists) throw Object.assign(new Error('Not Found'), { status: 404 }); + return { data: { name: LABEL } }; + }, + }, + }; + const github = { + rest, + paginate: async method => { + assert.equal(method, rest.issues.listComments); + return comments; + }, + graphql: async () => ({ + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: closing.map(i => ({ + number: i.number, + title: i.title, + labels: { nodes: i.labels.map(name => ({ name })) }, + assignees: { nodes: i.assignees.map(login => ({ login })) }, + })), + }, + }, + }, + }), + }; + const context = { + repo: { owner: 'TestSprite', repo: 'testsprite-cli' }, + payload: { pull_request: payload ?? livePr }, + }; + const core = { + info: message => info.push(message), + setFailed: message => failures.push(message), + }; + const saved = {}; + const env = { LABEL, MARKER, BONUS_CLOSES_AT: closesAt, APP_TOKEN: '' }; + for (const [key, value] of Object.entries(env)) { + saved[key] = process.env[key]; + process.env[key] = value; + } + try { + await runScript(github, context, core); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + const of = name => calls.filter(([method]) => method === name).map(([, args]) => args); + return { calls, of, info, failures }; +} + +const stateOf = body => //.exec(body)?.[1]; +const botComment = body => ({ id: 99, user: { login: 'github-actions[bot]' }, body }); + +test('a ticked box records the entry, labels the PR and confirms in one comment', async () => { + const h = await runPr(); + const [comment] = h.of('createComment'); + assert.equal(h.of('createComment').length, 1); + assert.equal(comment.issue_number, 7); + assert.ok(comment.body.startsWith(MARKER)); + assert.equal(stateOf(comment.body), 'entered'); + assert.match(comment.body, /entry recorded/); + assert.match(comment.body, /@contributor/); + assert.match(comment.body, /✅ A linked issue assigned to you\./); + assert.match(comment.body, /✅ Out of draft\./); + assert.match(comment.body, /✅ A Discord username/); + assert.match(comment.body, /Oct 29, 11:59 PM PT/); + assert.deepEqual(h.of('addLabels'), [ + { owner: 'TestSprite', repo: 'testsprite-cli', issue_number: 7, labels: [LABEL] }, + ]); + assert.equal(h.of('createLabel').length, 0); + assert.equal(h.failures.length, 0); +}); + +test('the template checkbox, once ticked, is what the bot recognises', async () => { + assert.ok(template.includes(UNTICKED), 'template must carry the entry checkbox'); + assert.match(template, /^Discord username:$/m); + const h = await runPr({ body: template.replace(UNTICKED, TICKED) }); + assert.equal(stateOf(h.of('createComment')[0].body), 'entered'); + assert.equal(h.of('addLabels').length, 1); +}); + +test('the untouched template is not an entry and not a near-miss', async () => { + const h = await runPr({ body: template }); + assert.deepEqual(h.calls, []); +}); + +test('an entry lists what is still missing: draft, unclaimed issue, Discord', async () => { + const h = await runPr({ + body: TICKED, + draft: true, + closing: [{ number: 42, title: 'Some bug', labels: [], assignees: ['someone-else'] }], + }); + const body = h.of('createComment')[0].body; + assert.equal(stateOf(body), 'entered'); + assert.match(body, /⬜ A linked issue assigned to you: the issue this PR links isn't assigned/); + assert.match(body, /`\/assign`/); + assert.match(body, /⬜ Out of draft: this PR is still a draft/); + assert.match(body, /No Discord username/); + assert.match(body, /reach you here on the PR/); +}); + +test('an entry without any linked issue says how to link one', async () => { + const h = await runPr({ body: TICKED, closing: [] }); + assert.match(h.of('createComment')[0].body, /add `Closes #123`/); +}); + +test('a body-only "Part of #N" reference counts as the linked issue', async () => { + const h = await runPr({ + body: `Part of #50\n\n${TICKED}`, + closing: [], + issues: { 50: { title: 'Umbrella', labels: [], assignees: [{ login: 'contributor' }] } }, + }); + assert.match(h.of('createComment')[0].body, /✅ A linked issue assigned to you\./); +}); + +test('box variants: capital X, other list markers, a typographic apostrophe', async () => { + for (const line of [ + "- [X] I'm entering this PR for the Season 4 CLI Improvement Bonus", + "* [x] I'm entering this PR for the Season 4 CLI Improvement Bonus", + ' + [x] I’m entering this PR for the Season 4 CLI Improvement Bonus', + ]) { + const h = await runPr({ body: line }); + assert.equal(stateOf(h.of('createComment')[0]?.body ?? ''), 'entered', line); + } +}); + +test('a ticked box inside an HTML comment is not an entry', async () => { + const h = await runPr({ body: `` }); + assert.deepEqual(h.calls, []); +}); + +test('an unticked [Hackathon] PR is asked, not labelled', async () => { + const h = await runPr({ body: UNTICKED, title: '[Hackathon] fix: something' }); + const body = h.of('createComment')[0].body; + assert.equal(stateOf(body), 'maybe'); + assert.match(body, /entry box in the description isn't ticked/); + assert.ok(body.includes(TICKED), 'shows the ticked line to paste'); + assert.equal(h.of('addLabels').length, 0); +}); + +test('an unticked PR linking a hackathon issue is asked', async () => { + for (const issue of [ + { number: 42, title: 'Some bug', labels: ['hackathon'], assignees: ['contributor'] }, + { number: 42, title: '[Hackathon] Some bug', labels: [], assignees: ['contributor'] }, + ]) { + const h = await runPr({ body: 'Closes #42', closing: [issue] }); + assert.equal(stateOf(h.of('createComment')[0].body), 'maybe'); + } +}); + +test('an unticked PR with a Discord username filled in is asked', async () => { + const h = await runPr({ body: `${UNTICKED}\n\nDiscord username: someone` }); + assert.equal(stateOf(h.of('createComment')[0].body), 'maybe'); +}); + +test('placeholder Discord values do not count as a username', async () => { + for (const value of ['', '', 'N/A', '-', 'none']) { + const h = await runPr({ body: `${UNTICKED}\n\nDiscord username: ${value}` }); + assert.deepEqual(h.calls, [], JSON.stringify(value)); + } +}); + +test('unticking a recorded entry records the withdrawal and removes the label', async () => { + const first = await runPr(); + const sticky = botComment(first.of('createComment')[0].body); + const h = await runPr({ body: UNTICKED, labels: [LABEL], comments: [sticky] }); + assert.equal(h.of('createComment').length, 0); + const [update] = h.of('updateComment'); + assert.equal(update.comment_id, 99); + assert.equal(stateOf(update.body), 'withdrawn'); + assert.match(update.body, /no longer entered/); + assert.deepEqual( + h.of('removeLabel').map(args => args.name), + [LABEL], + ); +}); + +test('re-running on an unchanged PR makes no writes', async () => { + const first = await runPr(); + const sticky = botComment(first.of('createComment')[0].body); + const h = await runPr({ labels: [LABEL], comments: [sticky] }); + assert.deepEqual(h.calls, []); +}); + +test('fixing a missing item updates the same comment', async () => { + const first = await runPr({ draft: true }); + const sticky = botComment(first.of('createComment')[0].body); + const h = await runPr({ labels: [LABEL], comments: [sticky] }); + assert.equal(h.of('createComment').length, 0); + assert.equal(h.of('updateComment').length, 1); + assert.match(h.of('updateComment')[0].body, /✅ Out of draft\./); +}); + +test('the bonus label is created when it does not exist yet', async () => { + const h = await runPr({ labelExists: false }); + assert.equal(h.of('createLabel').length, 1); + assert.equal(h.of('createLabel')[0].name, LABEL); + const order = h.calls + .map(([method]) => method) + .filter(m => m.endsWith('Label') || m === 'addLabels'); + assert.deepEqual(order, ['createLabel', 'addLabels']); +}); + +test('after the close the bot changes nothing', async () => { + const h = await runPr({ closesAt: '2000-01-01T00:00:00Z' }); + assert.deepEqual(h.calls, []); + assert.ok(h.info.some(message => message.includes('has closed'))); +}); + +test('an invalid close time fails without writes', async () => { + const h = await runPr({ closesAt: 'not-a-date' }); + assert.deepEqual(h.calls, []); + assert.equal(h.failures.length, 1); +}); + +test('workflow pins the close, stays metadata-only and runs only in the public repo', () => { + assert.match(workflow, /^ {2}BONUS_CLOSES_AT: '2026-10-30T06:59:59Z'$/m); + assert.match(workflow, /^ {2}LABEL: 's4-cli-bonus'$/m); + assert.match(workflow, /github\.repository == 'TestSprite\/testsprite-cli'/); + assert.match( + workflow, + /^ {4}types: \[opened, edited, reopened, synchronize, ready_for_review, converted_to_draft\]$/m, + ); + assert.doesNotMatch(workflow, /actions\/checkout/); +}); + +test('a marker comment pasted by a user is not taken for the bot comment', async () => { + const first = await runPr(); + const spoof = { id: 5, user: { login: 'someone' }, body: first.of('createComment')[0].body }; + const entered = await runPr({ comments: [spoof] }); + assert.equal(entered.of('updateComment').length, 0); + assert.equal(entered.of('createComment').length, 1); + assert.equal(entered.of('addLabels').length, 1); + + // Nor does a pasted "entered" state make an untouched PR look withdrawn. + const untouched = await runPr({ body: UNTICKED, comments: [spoof] }); + assert.deepEqual(untouched.calls, []); +}); + +test('a queued run acts on the live PR, not its stale event payload', async () => { + const first = await runPr(); + const sticky = botComment(first.of('createComment')[0].body); + // Ticked, then unticked straight away: the second run's payload predates the + // label the first run added, but the live PR has it. + const stale = { + number: 7, + title: 'x', + body: UNTICKED, + draft: false, + labels: [], + user: { login: 'contributor' }, + }; + const withdrawn = await runPr({ + body: UNTICKED, + labels: [LABEL], + comments: [sticky], + payload: stale, + }); + assert.equal(stateOf(withdrawn.of('updateComment')[0].body), 'withdrawn'); + assert.deepEqual( + withdrawn.of('removeLabel').map(args => args.name), + [LABEL], + ); + + // And the reverse: a payload that is still unticked while the live PR is ticked. + const reentered = await runPr({ payload: { ...stale, body: UNTICKED } }); + assert.equal(stateOf(reentered.of('createComment')[0].body), 'entered'); + assert.equal(reentered.of('addLabels').length, 1); +}); + +test('a PR closed before its queued run gets no writes', async () => { + const h = await runPr({ state: 'closed' }); + assert.deepEqual(h.calls, []); +}); + +test('unticking keeps the withdrawal even when the PR still looks like an entry', async () => { + const first = await runPr(); + const entered = botComment(first.of('createComment')[0].body); + // The default body keeps its Discord username, which alone reads as a near miss. + const untickedBody = `Closes #42\n\n${UNTICKED}\n\nDiscord username: contributor#1234`; + const h = await runPr({ body: untickedBody, labels: [LABEL], comments: [entered] }); + const withdrawn = h.of('updateComment')[0].body; + assert.equal(stateOf(withdrawn), 'withdrawn'); + assert.deepEqual( + h.of('removeLabel').map(args => args.name), + [LABEL], + ); + + // A later edit that is still unticked leaves the withdrawal as it is. + const again = await runPr({ body: untickedBody, comments: [botComment(withdrawn)] }); + assert.deepEqual(again.calls, []); +}); diff --git a/.github/workflows/bonus-entry.yml b/.github/workflows/bonus-entry.yml new file mode 100644 index 0000000..420afe7 --- /dev/null +++ b/.github/workflows/bonus-entry.yml @@ -0,0 +1,226 @@ +# Season 4 CLI Improvement Bonus entry bot. A PR enters the bonus by ticking +# the entry checkbox in its description (the "Season 4 CLI Improvement Bonus" +# section of PULL_REQUEST_TEMPLATE.md). The bot keeps one sticky comment per PR: +# - box ticked: confirms the entry, adds the bonus label, and lists what the +# PR still needs to be counted (a linked issue assigned to the author, out +# of draft by the close, a Discord username or a note that we'll reach the +# author on the PR); +# - box unticked but the PR looks like a hackathon entry (a `[Hackathon]` +# title, a linked issue labelled `hackathon` or titled `[Hackathon]`, or a +# Discord username filled in): asks instead of silently leaving it out; +# - box unticked after an entry was confirmed: records the withdrawal and +# removes the label. +# The bot only records entries; maintainers decide eligibility after the close. +# 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. +# +# 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 / +# GITHUB_TOKEN-fallback pattern as pr-triage.yml. +name: Season 4 bonus entry + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize, ready_for_review, converted_to_draft] + +permissions: + pull-requests: read # read the live PR and its closing issue references + issues: write # sticky comment + label (PR comments and labels ride the issues API) + +concurrency: + group: bonus-entry-${{ github.event.pull_request.number }} + cancel-in-progress: false + +env: + LABEL: 's4-cli-bonus' + MARKER: '' + # Oct 29, 11:59 PM PT (PDT, UTC-7) — the published close of the CLI window. + BONUS_CLOSES_AT: '2026-10-30T06:59:59Z' + +jobs: + entry: + if: >- + github.repository == 'TestSprite/testsprite-cli' && + 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 + # App + secrets exist this no-ops and writes fall back to GITHUB_TOKEN. + - id: app-token + continue-on-error: true + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }} + private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }} + # Every write below is an `issues.*` call (comments and labels, even + # on a PR number), which GitHub gates on the Issues permission. + permission-issues: write + + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + with: + script: | + const { owner, repo } = context.repo; + const label = process.env.LABEL; + const marker = process.env.MARKER; + const closesAt = new Date(process.env.BONUS_CLOSES_AT); + if (Number.isNaN(closesAt.getTime())) { + core.setFailed('Invalid BONUS_CLOSES_AT'); + return; + } + if (Date.now() > closesAt.getTime()) { + core.info('The Season 4 CLI bonus window has closed; leaving entries as recorded.'); + 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; } + } + 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 } } } + } + } + } + }`, { 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'; + } + + 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(() => {}); + } + core.info(`Season 4 bonus entry: state=${state} ticked=${ticked} issueOk=${issueOk} draft=${pr.draft} discord=${hasDiscord}`);