diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml deleted file mode 100644 index 53a07c505..000000000 --- a/.github/workflows/danger.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Danger - -on: - pull_request: - types: [ synchronize, opened, reopened, edited ] - -jobs: - build: - if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} # Only run on non-forked PRs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - name: Use Node.js 22.x - uses: actions/setup-node@master - with: - node-version: 22.x - - name: install danger - run: yarn global add danger - - name: Validate PR title validation rules - working-directory: ./ci/validate-pr-title - run: node validate.test.js - - name: Danger - run: danger ci - working-directory: ./ci/validate-pr-title - env: - DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_TOKEN }} diff --git a/.github/workflows/pr_title.yml b/.github/workflows/pr_title.yml new file mode 100644 index 000000000..b3ab87204 --- /dev/null +++ b/.github/workflows/pr_title.yml @@ -0,0 +1,34 @@ +name: PR title + +on: + pull_request: + # synchronize is here even though pushing cannot change a title: the status is + # attached to the head commit, so every new head needs its own verdict or the + # check reads as missing against the commit the pull request is showing. + types: [ synchronize, opened, reopened, edited ] + merge_group: + +permissions: {} + +# One job, so a pull request shows one "PR title / validate" row beside the +# "PR title" status this posts. The rules' own tests live in pr_title_rules.yml, +# which runs only when ci/validate-pr-title changes: keeping them out of this +# workflow is what stops a failing test from taking the verdict down with it, and +# keeps two near-identical rows off every unrelated pull request. +jobs: + validate: + # A pull request from a fork gets a read-only token, so this job could post + # neither the status nor the comment. Same condition the Danger job carried. + if: ${{ github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + permissions: + contents: read + statuses: write # the "PR title" commit status + pull-requests: write # write, update and delete the explanation comment + steps: + - uses: actions/checkout@v5 + - name: Validate the pull request title + working-directory: ./ci/validate-pr-title + env: + GITHUB_TOKEN: ${{ github.token }} + run: node check.js diff --git a/.github/workflows/pr_title_rules.yml b/.github/workflows/pr_title_rules.yml new file mode 100644 index 000000000..fe11fc936 --- /dev/null +++ b/.github/workflows/pr_title_rules.yml @@ -0,0 +1,32 @@ +name: PR title rules + +# The tests for the title rules and for the reporting, kept out of pr_title.yml +# for two reasons. They must not gate the verdict: run as a step of the job that +# posts the status, a failing or flaky test stops check.js from running at all, +# no status is posted, and the pull request sits behind a check that is merely +# missing. And they are only interesting when the checker itself changes, so a +# path filter keeps a second near-identical row off every pull request that has +# nothing to do with them. +on: + pull_request: + paths: + - 'ci/validate-pr-title/**' + - '.github/workflows/pr_title.yml' + - '.github/workflows/pr_title_rules.yml' + +permissions: {} + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + # No setup-node step: the runner ships a current Node, and nothing under + # ci/validate-pr-title depends on anything outside the standard library. + - name: Test the title rules and the reporting + working-directory: ./ci/validate-pr-title + run: | + node validate.test.js + node check.test.js diff --git a/ci/validate-pr-title/check.js b/ci/validate-pr-title/check.js new file mode 100644 index 000000000..71648f3b1 --- /dev/null +++ b/ci/validate-pr-title/check.js @@ -0,0 +1,241 @@ +"use strict"; + +// Validates a pull request title and reports the verdict to GitHub: it posts a +// commit status and leaves a comment explaining a rejection, updating that comment +// in place while the title stays wrong and deleting it once the title is fixed. +// Everything here runs on the workflow's own GITHUB_TOKEN and uses only the Node +// standard library, so the job installs nothing at run time. + +const fs = require("node:fs"); +const { validate } = require("./validate"); + +// Hidden marker on the comment this job writes, so a later run can find that same +// comment and update or delete it rather than stacking a new one on every push. +const MARKER = ""; + +// The commit status context. Danger posted "Danger" here, but nothing required +// that string: the branch protection on main requires only the four Azure +// contexts, so the rename cost nothing and left no open pull request waiting on a +// check that stopped reporting. The OSS copy posts the same string, but there it +// is named by the master ruleset, so renaming it takes a matching ruleset edit. +// Making this a required check is a branch-protection edit, and until then a +// rejection is visible but not blocking, which is what Danger already was here. +const CONTEXT = "PR title"; + +const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com"; +const repo = process.env.GITHUB_REPOSITORY; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// GitHub occasionally answers a write with a 5xx, and a dropped verdict is worse +// than a slow one, so transient failures are retried. A 4xx is a permanent answer +// about this request, so it fails immediately instead of burning the retries. +async function request(method, path, body) { + let lastError; + for (let attempt = 1; attempt <= 3; attempt++) { + if (attempt > 1) { + await sleep(2000 * (attempt - 1)); + } + let response; + try { + response = await fetch(`${apiUrl}${path}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + "x-github-api-version": "2022-11-28", + ...(body ? { "content-type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + } catch (error) { + lastError = error; + continue; + } + if (response.status === 204) { + return null; + } + if (response.ok) { + return response.json(); + } + const detail = await response.text().catch(() => ""); + lastError = new Error( + `${method} ${path} answered ${response.status}: ${detail.slice(0, 300)}` + ); + if (response.status < 500 && response.status !== 429) { + break; + } + } + throw lastError; +} + +function readEvent() { + return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); +} + +// A pull_request event carries the title and the head commit directly. A merge +// group carries neither: it names the queued pull request only in its ref, so the +// number is recovered from there and the title read back from the API. GitHub +// writes the pull request title into the squash commit, and a title edited after +// the entry joins the queue passes through no other check, so the merge group is +// validated rather than rubber-stamped. This repository has no merge queue today; +// the path is kept so enabling one does not silently lose the check. +async function resolveTarget() { + const event = readEvent(); + if (process.env.GITHUB_EVENT_NAME === "pull_request") { + return { + event: "pull_request", + number: event.pull_request.number, + // Not GITHUB_SHA: on a pull_request event that is the throwaway merge commit, + // and a status posted there is invisible to the pull request. + sha: event.pull_request.head.sha, + title: event.pull_request.title, + }; + } + // refs/heads/gh-readonly-queue//pr--. A group holding more + // than one entry names only the last one, so that is the title being checked. + const ref = (event.merge_group && event.merge_group.head_ref) || process.env.GITHUB_REF || ""; + const match = ref.match(/^refs\/heads\/gh-readonly-queue\/.*\/pr-(\d+)-/); + if (!match) { + throw new Error(`cannot read a pull request number from ${ref}`); + } + const number = Number(match[1]); + const pullRequest = await request("GET", `/repos/${repo}/pulls/${number}`); + return { + event: "merge_group", + number, + sha: (event.merge_group && event.merge_group.head_sha) || process.env.GITHUB_SHA, + title: pullRequest.title, + }; +} + +// Every match, not just the first. A pull request that ends up carrying two of +// these — a race between two runs, or a write that half succeeded — would +// otherwise shed one comment per run and keep the rest, which reads to the author +// as a complaint that no longer clears when the title is fixed. +async function findComments(number) { + const found = []; + for (let page = 1; page <= 10; page++) { + const comments = await request( + "GET", + `/repos/${repo}/issues/${number}/comments?per_page=100&page=${page}` + ); + for (const comment of comments) { + if (typeof comment.body === "string" && comment.body.includes(MARKER)) { + found.push(comment); + } + } + if (comments.length < 100) { + break; + } + } + return found; +} + +function commentBody(title, reason) { + return [ + MARKER, + "### This pull request title does not follow the required format", + "", + // Four backticks so a title containing a fence of its own cannot break out. + "````", + title, + "````", + "", + reason, + "", + "_Edit the title and this comment removes itself on the next run._", + ].join("\n"); +} + +// The comment is an explanation, not the gate: the status is. A comment that +// cannot be written is reported and stepped over, so an unrelated API problem +// cannot fail a pull request whose title is perfectly valid. +async function syncComment(number, title, reason) { + const existing = await findComments(number); + if (!reason) { + for (const comment of existing) { + await request("DELETE", `/repos/${repo}/issues/comments/${comment.id}`); + } + return; + } + const body = commentBody(title, reason); + if (existing.length === 0) { + await request("POST", `/repos/${repo}/issues/${number}/comments`, { body }); + return; + } + // Keep one and reword it only when the reason actually changed, so a rerun on an + // unchanged bad title does not bump the comment and re-notify everyone watching. + if (existing[0].body !== body) { + await request("PATCH", `/repos/${repo}/issues/comments/${existing[0].id}`, { body }); + } + for (const duplicate of existing.slice(1)) { + await request("DELETE", `/repos/${repo}/issues/comments/${duplicate.id}`); + } +} + +async function run() { + // Falls back to the event's own commit so that a failure while resolving the + // target still has somewhere to publish a verdict. + let sha = process.env.GITHUB_SHA; + try { + const target = await resolveTarget(); + sha = target.sha; + + let reason = ""; + validate({ + title: target.title, + onError: (message) => { + reason = message; + }, + }); + + // A merge group has no conversation of its own, and commenting would land on + // the pull request a second time, so that path reports by status alone. + if (target.event === "pull_request") { + try { + await syncComment(target.number, target.title, reason); + } catch (error) { + console.log(`::warning::could not update the explanation comment: ${error.message}`); + } + } + + if (reason) { + await postStatus(sha, "failure", `PR #${target.number} title must match type(subType): description`); + console.log(`::error::${reason}`); + process.exitCode = 1; + return; + } + await postStatus(sha, "success", `Title of PR #${target.number} validated`); + console.log(`Title of PR #${target.number} is valid: ${target.title}`); + } catch (error) { + // A required check that never reports leaves a pull request stuck behind a + // check that is merely missing, and leaves a merge group to wait out its + // status-check timeout before being ejected with nothing naming the cause. + // Publish a verdict even when the run itself came apart. + console.log(`::error::${error.message}`); + process.exitCode = 1; + try { + await postStatus(sha, "failure", "PR title check could not run; see the workflow run"); + } catch (statusError) { + console.log(`::error::could not post the ${CONTEXT} status: ${statusError.message}`); + } + } +} + +function postStatus(sha, state, description) { + return request("POST", `/repos/${repo}/statuses/${sha}`, { + state, + context: CONTEXT, + // GitHub truncates a description past 140 characters. + description: description.slice(0, 140), + }); +} + +if (require.main === module) { + run(); +} + +module.exports = { run, MARKER, CONTEXT, commentBody }; diff --git a/ci/validate-pr-title/check.test.js b/ci/validate-pr-title/check.test.js new file mode 100644 index 000000000..417ed3fd3 --- /dev/null +++ b/ci/validate-pr-title/check.test.js @@ -0,0 +1,254 @@ +"use strict"; + +// Exercises check.js against a stubbed GitHub API. The point is the reporting +// behaviour rather than the rules themselves, which validate.test.js covers: that a +// rejection is explained exactly once, that fixing the title takes the explanation +// away again, that the status lands on the commit the pull request is actually +// showing, and that a verdict is published even when the run comes apart. + +const assert = require("node:assert").strict; +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +process.env.GITHUB_REPOSITORY = "questdb/java-questdb-client"; +process.env.GITHUB_API_URL = "https://api.github.com"; +process.env.GITHUB_TOKEN = "stub-token"; + +const { run, MARKER, CONTEXT, commentBody } = require("./check"); + +const VALID = "fix(qwp): repair sender state after rollback"; +const INVALID = "just some words"; + +// Records every call and answers from a small routing table, so a test can assert +// on what the job asked GitHub to do rather than on how it phrased it. +function stubApi({ comments = [], title = VALID, fail = null }) { + const calls = []; + global.fetch = async (url, options) => { + const method = options.method; + const route = String(url).replace("https://api.github.com", ""); + calls.push({ method, route, body: options.body ? JSON.parse(options.body) : null }); + + const answer = (status, payload) => ({ + ok: status < 400, + status, + json: async () => payload, + text: async () => JSON.stringify(payload), + }); + + if (fail && fail(method, route)) { + return answer(500, { message: "stub failure" }); + } + if (method === "GET" && /\/issues\/\d+\/comments/.test(route)) { + return answer(200, route.includes("page=1") ? comments : []); + } + if (method === "GET" && /\/pulls\/\d+$/.test(route)) { + return answer(200, { title }); + } + if (method === "DELETE") { + return answer(204, null); + } + return answer(201, { id: 4242 }); + }; + return calls; +} + +function pullRequestEvent(title) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pr-title-")); + const file = path.join(dir, "event.json"); + fs.writeFileSync( + file, + JSON.stringify({ pull_request: { number: 93, title, head: { sha: "headsha" } } }) + ); + process.env.GITHUB_EVENT_NAME = "pull_request"; + process.env.GITHUB_EVENT_PATH = file; + // What GITHUB_SHA is on a pull_request event: the throwaway merge commit, which + // is not where the status belongs. + process.env.GITHUB_SHA = "mergesha"; +} + +function mergeGroupEvent() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pr-title-")); + const file = path.join(dir, "event.json"); + fs.writeFileSync( + file, + JSON.stringify({ + merge_group: { + head_ref: "refs/heads/gh-readonly-queue/main/pr-93-abc123", + head_sha: "queuesha", + }, + }) + ); + process.env.GITHUB_EVENT_NAME = "merge_group"; + process.env.GITHUB_EVENT_PATH = file; + process.env.GITHUB_SHA = "queuesha"; +} + +const statusOf = (calls) => calls.find((call) => call.route.includes("/statuses/")); +const commentCalls = (calls) => + calls.filter((call) => call.method !== "GET" && call.route.includes("comments")); + +async function test(name, body) { + process.exitCode = 0; + await body(); + console.log(`ok - ${name}`); +} + +async function main() { + await test("an invalid title fails the status and explains itself once", async () => { + pullRequestEvent(INVALID); + const calls = stubApi({}); + await run(); + + const status = statusOf(calls); + assert.equal(status.route, "/repos/questdb/java-questdb-client/statuses/headsha"); + assert.equal(status.body.state, "failure"); + assert.equal(status.body.context, CONTEXT); + + const posted = commentCalls(calls); + assert.equal(posted.length, 1); + assert.equal(posted[0].method, "POST"); + assert.ok(posted[0].body.body.includes(MARKER)); + assert.ok(posted[0].body.body.includes(INVALID)); + assert.equal(process.exitCode, 1); + }); + + await test("the status is posted on the head commit, not the merge commit", async () => { + pullRequestEvent(VALID); + const calls = stubApi({}); + await run(); + assert.equal(statusOf(calls).route, "/repos/questdb/java-questdb-client/statuses/headsha"); + assert.ok( + !calls.some((call) => call.route.includes("mergesha")), + "a status on the merge commit is invisible to the pull request" + ); + }); + + await test("a repeat run on the same bad title does not stack a second comment", async () => { + pullRequestEvent(INVALID); + const existing = { id: 11, body: commentBody(INVALID, rejectionReason(INVALID)) }; + const calls = stubApi({ comments: [existing] }); + await run(); + + assert.equal(commentCalls(calls).length, 0, "identical comment must be left alone"); + assert.equal(statusOf(calls).body.state, "failure"); + }); + + await test("fixing the title deletes the comment and turns the status green", async () => { + pullRequestEvent(VALID); + const calls = stubApi({ comments: [{ id: 11, body: `${MARKER}\nold complaint` }] }); + await run(); + + const removed = commentCalls(calls); + assert.equal(removed.length, 1); + assert.equal(removed[0].method, "DELETE"); + assert.equal(removed[0].route, "/repos/questdb/java-questdb-client/issues/comments/11"); + assert.equal(statusOf(calls).body.state, "success"); + assert.equal(process.exitCode, 0); + }); + + // The OSS copy looks up a single comment, so a pull request carrying two sheds + // one per run and keeps the other. Fixing the title has to clear all of them. + await test("fixing the title clears every duplicate comment, not just the first", async () => { + pullRequestEvent(VALID); + const calls = stubApi({ + comments: [ + { id: 11, body: `${MARKER}\nold complaint` }, + { id: 12, body: `${MARKER}\na second copy` }, + ], + }); + await run(); + + const removed = commentCalls(calls); + assert.deepEqual( + removed.map((call) => `${call.method} ${call.route.split("/").pop()}`), + ["DELETE 11", "DELETE 12"] + ); + assert.equal(statusOf(calls).body.state, "success"); + }); + + await test("a duplicate is cleared while the surviving comment is reworded", async () => { + pullRequestEvent(INVALID); + const calls = stubApi({ + comments: [ + { id: 11, body: `${MARKER}\nstale wording` }, + { id: 12, body: `${MARKER}\nanother copy` }, + ], + }); + await run(); + + const touched = commentCalls(calls); + assert.deepEqual( + touched.map((call) => call.method), + ["PATCH", "DELETE"], + "one comment carries the explanation, the rest go away" + ); + assert.ok(touched[0].body.body.includes(INVALID)); + assert.equal(statusOf(calls).body.state, "failure"); + }); + + await test("a clean title with nothing to clean up touches no comment", async () => { + pullRequestEvent(VALID); + const calls = stubApi({}); + await run(); + assert.equal(commentCalls(calls).length, 0); + assert.equal(statusOf(calls).body.state, "success"); + }); + + await test("a merge group reads the queued title and reports by status only", async () => { + mergeGroupEvent(); + const calls = stubApi({ title: INVALID }); + await run(); + + assert.ok(calls.some((call) => call.route === "/repos/questdb/java-questdb-client/pulls/93")); + assert.equal(commentCalls(calls).length, 0, "a merge group must not comment"); + assert.equal(statusOf(calls).route, "/repos/questdb/java-questdb-client/statuses/queuesha"); + assert.equal(statusOf(calls).body.state, "failure"); + assert.equal(process.exitCode, 1); + }); + + await test("a comment that cannot be written does not fail a valid title", async () => { + pullRequestEvent(VALID); + const calls = stubApi({ + comments: [{ id: 11, body: `${MARKER}\nold complaint` }], + fail: (method) => method === "DELETE", + }); + await run(); + + assert.equal(statusOf(calls).body.state, "success", "the status is the gate, not the comment"); + assert.equal(process.exitCode, 0); + }); + + await test("an unreadable merge group ref still publishes a failure", async () => { + mergeGroupEvent(); + process.env.GITHUB_EVENT_PATH = (() => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pr-title-")); + const file = path.join(dir, "event.json"); + fs.writeFileSync(file, JSON.stringify({ merge_group: { head_ref: "refs/heads/nonsense" } })); + return file; + })(); + const calls = stubApi({}); + await run(); + + assert.equal(statusOf(calls).body.state, "failure"); + assert.equal(process.exitCode, 1); + }); + + // The scenarios deliberately leave process.exitCode at 1 behind them, since that + // is what the job under test sets on a rejection. + process.exitCode = 0; + console.log("\nall check.js scenarios passed"); +} + +// The exact rejection text, so the "no duplicate comment" case can build the body +// the job would have written on the previous run. +function rejectionReason(title) { + let reason = ""; + require("./validate").validate({ title, onError: (message) => (reason = message) }); + return reason; +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/ci/validate-pr-title/dangerfile.js b/ci/validate-pr-title/dangerfile.js deleted file mode 100644 index c60d62666..000000000 --- a/ci/validate-pr-title/dangerfile.js +++ /dev/null @@ -1,4 +0,0 @@ -const { danger, fail } = require("danger"); -const { validate } = require("./validate"); - -validate({ title: danger.github.pr.title, onError: fail }); diff --git a/ci/validate-pr-title/readme.md b/ci/validate-pr-title/readme.md index 18a284d3e..968e8bf3a 100644 --- a/ci/validate-pr-title/readme.md +++ b/ci/validate-pr-title/readme.md @@ -1,5 +1,47 @@ -This folder contains configuration files which are used to run validation rules on Github pull requests titles. +This folder holds the validation rules applied to GitHub pull request titles, and +the job that reports on them. -It is done by running [Danger JS](https://danger.systems/js/) tool in [github action](../../.github/workflows/danger.yml). +- `validate.js` — the rules themselves: `type(subType): description`. +- `check.js` — reads the title from the workflow event, posts the `PR title` commit + status, and leaves a comment explaining a rejection. The comment is updated in + place while the title stays wrong and deleted once it is fixed. +- Tests run with node and need no dependencies: `node ./validate.test.js` and + `node ./check.test.js`. -In addition, the validation rules are tested. Tests can be executed with node, by running `node ./validate.test.js` +Run by [.github/workflows/pr_title.yml](../../.github/workflows/pr_title.yml) on +pull requests and on merge groups. It authenticates with the workflow's own +`GITHUB_TOKEN`, so it needs no bot account and no personal access token. + +The tests live in a second workflow, +[pr_title_rules.yml](../../.github/workflows/pr_title_rules.yml), rather than +alongside the job that posts the status. Run as a step of that job, a failing or +flaky test stops check.js from running at all, no status is posted, and the pull +request sits behind a check that is merely missing. A path filter runs them only +when this folder or either workflow changes, which also keeps a second +near-identical row off every unrelated pull request: the workflow name and the +status context are both "PR title", so every job here costs a row that reads like +the verdict. + +## Replacing Danger + +This used to run [Danger JS](https://danger.systems/js/), which read the title and +reported through the questdb-butler account using a personal access token held in +the `DANGER_GITHUB_TOKEN` secret. Nothing about the job needed a separate identity, +and the token's expiry would have quietly stopped the check. `check.js` does the +same work on the workflow's own token, and `dangerfile.js` and the `yarn global add +danger` step are gone. Once this has settled, `DANGER_GITHUB_TOKEN` can be deleted +from the repository secrets. + +The status context is `PR title` rather than the `Danger` that Danger posted. The +rename was free here: the branch protection on `main` requires only the four +`questdb.java-questdb-client` Azure contexts, so nothing ever waited on `Danger`, +and every open pull request that carried one carried a green one. questdb/questdb +moved to the same name, but had to pay for it — its `master` ruleset names the +context, so the rename had to be paired with a ruleset edit, and the gap between +the two is a gap in which nothing can merge. + +That is the difference to keep in mind before renaming this one again: here the +string is free, there it is a contract with the ruleset. + +The subType list is deliberately shorter than the server repositories': this is a +client, so `sql`, `wal`, `repl` and the rest are rejected on purpose. diff --git a/ci/validate-pr-title/validate.js b/ci/validate-pr-title/validate.js index f3e018fed..a704b0d66 100644 --- a/ci/validate-pr-title/validate.js +++ b/ci/validate-pr-title/validate.js @@ -39,16 +39,21 @@ perf(sql): improve pattern matching performance for SELECT sub-queries \`\`\` `.trim(); -/* The basic valid PR title formats are: - * 1. allowedType(allowedSubtype): optional description - * 2. allowedType: optional description +/* The valid PR title formats are: + * 1. allowedType(allowedSubType): description + * 2. build: description * + * Note that format 2 is available to `build` alone. Every other type has to name + * a subType, so `feat: thing` is rejected while `build: 6.6` is accepted. + * + * A `!` before the colon is the Conventional Commits marker for a breaking + * change, as in `feat(qwp)!: ...`, and is accepted on either format. * consult ./validate.test.js for a full list * */ const prTitleRegex = new RegExp( `^(((?:${allowedTypes.join("|")})\\((?:${allowedSubTypes.join( "|", - )})\\))|build): .*`, + )})\\))|build)!?: .*`, ); function validate({ title, onError }) { diff --git a/ci/validate-pr-title/validate.test.js b/ci/validate-pr-title/validate.test.js index dc90e8436..8667f7f7f 100644 --- a/ci/validate-pr-title/validate.test.js +++ b/ci/validate-pr-title/validate.test.js @@ -2,27 +2,36 @@ const assert = require("node:assert").strict; const { validate, allowedTypes, allowedSubTypes } = require("./validate"); const testValid = (title) => - assert.doesNotThrow(() => - validate({ - title, - onError: () => { - throw `should accept "${title}"`; - }, - }) + assert.doesNotThrow( + () => + validate({ + title, + onError: () => { + throw new Error(`should accept "${title}"`); + }, + }), + `should accept "${title}"`, ); +// onError has to be a real callback here. Passing a bare `onError` identifier makes +// this assertion pass on the ReferenceError that raises instead of on the title +// being rejected, which lets every negative case below succeed against a validator +// that accepts everything. const testInvalid = (title) => assert.throws( - () => validate({ title, onError }), - `should NOT accept "${title}"` + () => + validate({ + title, + onError: () => { + throw new Error(`rejected "${title}"`); + }, + }), + `should NOT accept "${title}"`, ); allowedTypes.forEach((type) => { allowedSubTypes.forEach((subType) => { - testValid( - `${type}(${subType}): foo`, - `should accept "${type}(${subType}): foo"` - ); + testValid(`${type}(${subType}): foo`); }); }); @@ -36,3 +45,27 @@ testInvalid(`build(house)`); testInvalid(`foo: bar`); testInvalid(`update(bar): baz`); testInvalid(`ui: updating stuff`); + +// Titles this repository actually merges. +testValid("feat(qwp): add table options API to name the designated timestamp column"); +testValid("fix(ilp): fix a leaked socket when an HTTP sender fails"); +testValid("chore(build): build client native library with Maven"); +testValid("build: 6.6"); + +// The Conventional Commits breaking-change marker, on both accepted formats. +testValid("feat(qwp)!: drop the legacy sender constructor"); +testValid("build!: require JDK 17"); +testInvalid("feat(qwp)!"); +testInvalid("feat(nonsense)!: still an unknown area"); + +// Subtypes that belong to the server repositories, not this client. +testInvalid("fix(sql): not an area of this repository"); +testInvalid("fix(wal): not an area of this repository"); +testInvalid("fix(repl): not an area of this repository"); + +// Only `build` may skip the subType. Every other type has to name one. This is +// what rejects the automated "Bump version to x.y.z-SNAPSHOT" release titles. +testInvalid("chore: bump a dependency"); +testInvalid("Bump version to 1.3.10-SNAPSHOT"); + +console.log("all validate.js rules passed");