diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..662f890 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ +## Ticket + +Closes # + + + +## What and why + + + +## How this was verified + + + +## Notes for the reviewer + + diff --git a/.github/workflows/pr-ticket.yml b/.github/workflows/pr-ticket.yml new file mode 100644 index 0000000..77f5c41 --- /dev/null +++ b/.github/workflows/pr-ticket.yml @@ -0,0 +1,124 @@ +name: PR ticket link + +# Every PR must close a GitHub issue. This only reports status - it blocks +# nothing until "linked ticket" is marked a required check in branch protection +# (Settings > Branches). Apply the "no-ticket" label to bypass it. + +on: + pull_request: + types: + - opened + - edited # re-run when the body is fixed + - reopened + - synchronize + - ready_for_review + - labeled # re-run when the bypass label is applied + - unlabeled + +permissions: + contents: read + issues: read + pull-requests: write + +jobs: + ticket: + name: linked ticket + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false + steps: + - uses: actions/github-script@v7 + with: + script: | + const BYPASS_LABEL = 'no-ticket'; + const MARKER = ''; + + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const labels = pr.labels.map(l => l.name); + + // The PR template documents "Closes #12" inside an HTML comment. + // Strip comments first or every PR passes on the example text. + const body = (pr.body || '').replace(//g, ''); + + const CLOSING = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi; + const REFS = /\b(?:refs?|references?|relate[sd]\s+to|part\s+of)\s+#(\d+)\b/gi; + + const nums = re => [...new Set([...body.matchAll(re)].map(m => m[1]))]; + const closing = nums(CLOSING); + const refsOnly = nums(REFS).filter(n => !closing.includes(n)); + + async function describe(n) { + try { + const { data } = await github.rest.issues.get({ + owner, repo, issue_number: Number(n), + }); + if (data.pull_request) return { n, ok: false, why: 'that is a pull request, not an issue' }; + return { n, ok: true, title: data.title, state: data.state, + assignee: data.assignee?.login, + labels: data.labels.map(l => l.name || l) }; + } catch (e) { + if (e.status === 404) return { n, ok: false, why: 'no such issue' }; + throw e; + } + } + + const found = await Promise.all(closing.map(describe)); + const valid = found.filter(f => f.ok); + + let pass, lines; + if (labels.includes(BYPASS_LABEL)) { + pass = true; + lines = [`Bypassed with the \`${BYPASS_LABEL}\` label.`]; + } else if (valid.length) { + pass = true; + lines = ['**Linked ticket**', '']; + for (const f of valid) { + const meta = [ + f.state, + f.assignee ? `@${f.assignee}` : 'unassigned', + ...(f.labels.length ? [f.labels.join(', ')] : []), + ].join(' · '); + lines.push(`- #${f.n} — ${f.title} \n ${meta}`); + if (f.state === 'closed') lines.push(` > note: #${f.n} is already closed.`); + } + if (refsOnly.length) { + lines.push('', `Also references (will stay open): ${refsOnly.map(n => '#' + n).join(', ')}`); + } + } else { + pass = false; + lines = [ + '**No linked ticket.**', + '', + 'Add a closing reference to the PR description, on its own line:', + '', + '```', + 'Closes #12', + '```', + '', + 'Accepted keywords: `closes`, `fixes`, `resolves` (and their tenses).', + ]; + if (refsOnly.length) { + lines.push('', `Found ${refsOnly.map(n => '#' + n).join(', ')}, but a plain reference does not close the issue. Use \`Closes\`.`); + } + for (const f of found.filter(x => !x.ok)) { + lines.push('', `\`#${f.n}\` cannot be used: ${f.why}.`); + } + lines.push('', `No ticket to link? Apply the \`${BYPASS_LABEL}\` label.`); + } + + const comment = `${MARKER}\n${lines.join('\n')}`; + const existing = (await github.rest.issues.listComments({ + owner, repo, issue_number: pr.number, per_page: 100, + })).data.find(c => c.body?.includes(MARKER)); + + try { + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: comment }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: comment }); + } + } catch (e) { + core.warning(`Could not post the summary comment: ${e.message}`); + } + + if (!pass) core.setFailed('This PR does not close a ticket.'); diff --git a/docs/pr-workflow.md b/docs/pr-workflow.md new file mode 100644 index 0000000..da55cc3 --- /dev/null +++ b/docs/pr-workflow.md @@ -0,0 +1,50 @@ +# PR workflow + +Every pull request closes a GitHub issue. A CI check enforces the link. + +## Opening a PR + +The description template puts the reference at the top: + +``` +Closes #12 +``` + +Accepted keywords are `closes`, `fixes` and `resolves`, in any tense. On merge +the issue closes on its own and the PR appears in its Development sidebar. + +`Refs #12` by itself does not satisfy the check — a plain mention neither closes +the issue nor creates that link. Doing partial work? Open a smaller issue this PR +does close, and reference the parent with `Refs` on a separate line. + +## The check + +`linked ticket` runs on every PR and: + +- parses the description for a closing keyword +- confirms the number is a real issue, not a pull request +- comments with the ticket's title, state, assignee and labels + +It re-runs when the description is edited, so fixing a missing reference does not +need a new commit. HTML comments are stripped before parsing, so the example +inside the template does not count as a link. + +## Bypassing it + +Apply the `no-ticket` label, for hotfixes or work with genuinely no ticket. The +check re-runs when labels change. + +## Making it a merge blocker + +It is report-only today: a failing check is visible but blocks nothing. To +enforce, with no code change: + +> Settings → Branches → add a rule for `main` → Require status checks to pass → +> select `linked ticket` + +## Files + +| Path | | +|---|---| +| `.github/PULL_REQUEST_TEMPLATE.md` | the description template | +| `.github/workflows/pr-ticket.yml` | the check |