diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 00000000..b519eb74 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,46 @@ +name: PR title labels + +on: + pull_request_target: + types: [opened, edited, reopened] + +permissions: + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Run only this trusted script; never check out or execute PR head code. + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + if (context.payload.action === 'edited' && !context.payload.changes?.title) return; + + // Fetch current metadata so delayed events do not use an outdated title or labels. + const { data: pr } = await github.rest.pulls.get({ + ...context.repo, + pull_number: context.issue.number, + }); + const match = /^(fix|bug|docs|feat|feature)(?:\([^()\r\n]+\))?[::]/i.exec(pr.title.trim()); + if (!match) return; + + const label = { + fix: 'bug', bug: 'bug', docs: 'documentation', feat: 'enhancement', feature: 'enhancement', + }[match[1].toLowerCase()]; + if (pr.labels.some(({ name }) => name.toLowerCase() === label)) return; + + // Only use labels already defined in this repository. + try { + await github.rest.issues.getLabel({ ...context.repo, name: label }); + } catch (error) { + if (error.status !== 404) throw error; + core.info(`Skipping missing repository label: ${label}`); + return; + } + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.issue.number, + labels: [label], + }); diff --git a/test/pr-labeler.test.js b/test/pr-labeler.test.js new file mode 100644 index 00000000..c375a099 --- /dev/null +++ b/test/pr-labeler.test.js @@ -0,0 +1,139 @@ +// Execute the workflow's actual script with API doubles; this is not a GitHub Actions integration test. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const workflow = readFileSync(new URL('../.github/workflows/pr-labeler.yml', import.meta.url), 'utf8'); +const block = workflow.match(/^ {10}script: \|\n((?: {12}.*\n|\n)+)$/m); +assert.ok(block, 'the workflow contains one inline script'); +const script = block[1].replace(/^ {12}/gm, ''); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const run = new AsyncFunction('github', 'context', 'core', script); +const repo = { owner: 'sganggs', repo: 'Stronghold-Protocol' }; + +function harness(title, { labels = [], action = 'opened', changes, eventTitle = title, labelError, addError } = {}) { + const calls = []; + const messages = []; + const pr = { title, labels: labels.map((name) => ({ name })) }; + const context = { + repo, + issue: { ...repo, number: 123 }, + payload: { + action, changes, + pull_request: { + number: 123, title: eventTitle, + head: { ref: 'fix/example', repo: { full_name: 'contributor/Stronghold-Protocol', fork: true } }, + }, + }, + }; + const github = { rest: { + pulls: { get: async (params) => { + assert.deepEqual(params, { ...repo, pull_number: 123 }); + calls.push(['get', params]); + return { data: pr }; + } }, + issues: { + getLabel: async (params) => { + assert.deepEqual(params, { ...repo, name: params.name }); + calls.push(['getLabel', params]); + if (labelError) throw labelError; + return { data: { name: params.name } }; + }, + addLabels: async (params) => { + assert.deepEqual(params, { ...repo, issue_number: 123, labels: params.labels }); + calls.push(['addLabels', params]); + if (addError) throw addError; + for (const name of params.labels) pr.labels.push({ name }); + }, + }, + } }; + return { calls, messages, pr, context, run: () => run(github, context, { info: (message) => messages.push(message) }) }; +} + +test('only PR metadata events, minimal permission, pinned action and no executable PR input', () => { + assert.match(workflow, /^on:\n {2}pull_request_target:\n {4}types: \[opened, edited, reopened\]\n\npermissions:/m); + assert.match(workflow, /^permissions:\n {2}pull-requests: write\n\njobs:/m); + assert.equal((workflow.match(/^ {6}- uses:/gm) || []).length, 1); + assert.match(workflow, /uses: actions\/github-script@[a-f0-9]{40} # v8/); + assert.doesNotMatch(workflow, /actions\/checkout|^\s*run:|\$\{\{|secrets\./m); +}); + +for (const [title, expected] of [ + ['fix: repair', 'bug'], ['bug: repair', 'bug'], ['docs: update', 'documentation'], + ['feat: add', 'enhancement'], ['feature: add', 'enhancement'], + ['FIX(ui): repair', 'bug'], ['BuG(server):修复', 'bug'], ['Docs(readme):说明', 'documentation'], + ['FEAT(ui): add', 'enhancement'], ['Feature(界面):新增', 'enhancement'], [' fix:修复 ', 'bug'], + ['fix: " ; process.exit(1); // ${{ secrets.TOKEN }} $(echo unsafe)', 'bug'], +]) { + test(`maps ${JSON.stringify(title)} to ${expected}`, async () => { + const h = harness(title, { labels: ['help wanted'] }); + await h.run(); + assert.deepEqual(h.calls.map(([name]) => name), ['get', 'getLabel', 'addLabels']); + assert.deepEqual(h.calls[2][1].labels, [expected]); + assert.deepEqual(h.pr.labels.map(({ name }) => name), ['help wanted', expected]); + }); +} + +for (const title of [ + 'chore: maintain', 'refactor: cleanup', 'test: coverage', 'ci: improve', 'perf: speed', 'style: format', + 'bugfix: repair', 'fixes: repair', 'prefix fix: repair', 'fix repair', 'fix[]: repair', + 'fix(): repair', 'fix(ui)(server): repair', 'fix(ui: repair', 'fix(ui\nserver): repair', '', +]) { + test(`ignores ${JSON.stringify(title)}`, async () => { + const h = harness(title, { labels: ['help wanted'] }); + await h.run(); + assert.deepEqual(h.calls.map(([name]) => name), ['get']); + assert.deepEqual(h.pr.labels, [{ name: 'help wanted' }]); + }); +} + +test('repeated runs skip an existing label and preserve every previous label', async () => { + const h = harness('fix: repair', { labels: ['help wanted', 'enhancement'] }); + await h.run(); + await h.run(); + assert.equal(h.calls.filter(([name]) => name === 'addLabels').length, 1); + assert.deepEqual(h.pr.labels.map(({ name }) => name), ['help wanted', 'enhancement', 'bug']); + const existing = harness('BUG:修复', { labels: ['BUG', 'documentation'] }); + await existing.run(); + assert.deepEqual(existing.calls.map(([name]) => name), ['get']); +}); + +test('title edits use current PR metadata and add the new type without removing the old type', async () => { + const h = harness('docs(ui):说明', { + action: 'edited', changes: { title: { from: 'fix: repair' } }, eventTitle: 'fix: repair', labels: ['bug'], + }); + await h.run(); + assert.deepEqual(h.pr.labels.map(({ name }) => name), ['bug', 'documentation']); +}); + +test('body or base edits do not make any API calls', async () => { + for (const changes of [undefined, {}, { body: { from: 'old body' } }, { base: { ref: { from: 'old' } } }]) { + const h = harness('fix: repair', { action: 'edited', changes }); + await h.run(); + assert.deepEqual(h.calls, []); + } +}); + +test('opened and reopened fork events add labels in the base repository', async () => { + for (const action of ['opened', 'reopened']) { + const h = harness('feat: add', { action }); + await h.run(); + assert.deepEqual(h.calls.at(-1), ['addLabels', { ...repo, issue_number: 123, labels: ['enhancement'] }]); + } +}); + +test('a missing repository label is skipped without creating or adding it', async () => { + const h = harness('fix: repair', { labelError: Object.assign(new Error('Not Found'), { status: 404 }) }); + await h.run(); + assert.deepEqual(h.calls.map(([name]) => name), ['get', 'getLabel']); + assert.deepEqual(h.pr.labels, []); + assert.equal(h.messages.length, 1); +}); + +test('permission and API failures remain visible', async () => { + for (const status of [403, 500]) { + const error = Object.assign(new Error('API failure'), { status }); + await assert.rejects(harness('fix: repair', { labelError: error }).run(), error); + await assert.rejects(harness('fix: repair', { addError: error }).run(), error); + } +});