@rmartz/pr-policy 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,7 @@ new required-status name.
16
16
  | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
17
  | **Blocking labels** ([docs](docs/checks/merge-block.md)) | The PR carries `do not merge`, `dnm`, `blocked`, or `escalation needed` (shows as pending). |
18
18
  | **CI-change classification** ([docs](docs/checks/ci-change.md)) | A `.github/workflows/**` change loosens CI (a removed job, `continue-on-error`, a narrowed trigger, …) and no human has applied `CI change approved` (shows as pending). Owns `CI approval needed`. |
19
+ | **Base branch** ([docs](docs/checks/base-branch.md)) | The PR targets a branch other than the default and it isn't a non-epic PR on an open epic's branch. A stacked PR waits for its base PR (shows as pending); a base no open PR heads blocks. |
19
20
  | **Title-type rules** ([docs](docs/checks/title.md)) | The squash title isn't a valid Conventional Commit, puts `!` on a non-functional type, disagrees with the `breaking change` label, or types a linter/formatter bump as a non-release, non-`ci` type. |
20
21
  | **UAT sign-off** ([docs](docs/checks/uat.md)) | The PR isn't docs, tests, CI, dependency-only, or repo metadata, and no trusted user has applied `UAT passed` (or `tested`) or `no UAT needed` (shows as pending). |
21
22
  | **Dependabot claim verification** ([docs](docs/checks/dependabot.md)) | A Dependabot PR touches a non-dependency path, changes a dependency its description doesn't claim, pins a version other than the claimed one, or edits anything Dependabot never edits (a `package.json` script, a workflow step). |
@@ -7,7 +7,7 @@ import {
7
7
  postVerdict,
8
8
  resolveRepoTarget,
9
9
  selectChecks
10
- } from "../chunk-TFZMH2KF.js";
10
+ } from "../chunk-TLGL2PTZ.js";
11
11
 
12
12
  // src/bin/pr-policy.ts
13
13
  import { readFile } from "fs/promises";
@@ -21,6 +21,7 @@ var BLOCKING_LABELS = [
21
21
  BLOCKED_LABEL,
22
22
  ESCALATION_NEEDED_LABEL
23
23
  ];
24
+ var EPIC_LABEL = "epic";
24
25
  var BREAKING_CHANGE_LABEL = "breaking change";
25
26
  var HOTFIX_LABEL = "hotfix";
26
27
  var RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
@@ -1263,6 +1264,45 @@ var titleCheck = {
1263
1264
  evaluate: async (pr) => decideTitle(pr)
1264
1265
  };
1265
1266
 
1267
+ // src/checks/base-branch.ts
1268
+ var BASE_BRANCH_CHECK = "base-branch";
1269
+ var isEpic = (labels) => labels.some((label) => label.trim().toLowerCase() === EPIC_LABEL);
1270
+ function decideBaseBranch(pr) {
1271
+ if (pr.base === void 0) return { findings: [] };
1272
+ const { branch, defaultBranch, headOf } = pr.base;
1273
+ if (branch === defaultBranch) return { findings: [] };
1274
+ const target = `This PR targets \`${branch}\``;
1275
+ const [parent] = headOf;
1276
+ if (parent === void 0) {
1277
+ return {
1278
+ findings: [
1279
+ {
1280
+ check: BASE_BRANCH_CHECK,
1281
+ message: `${target}, which is not the default branch and is not the head of an open PR. Retarget it to \`${defaultBranch}\`, or to an open epic PR's branch.`,
1282
+ effect: "block"
1283
+ }
1284
+ ]
1285
+ };
1286
+ }
1287
+ const epicParent = headOf.find((open) => isEpic(open.labels));
1288
+ if (epicParent !== void 0 && !isEpic(pr.labels)) return { findings: [] };
1289
+ const waitFor = epicParent ?? parent;
1290
+ const reason = epicParent === void 0 ? `the branch of #${waitFor.number}, which is not an epic. Only an epic's branch accumulates other PRs` : `the branch of epic #${waitFor.number}. An epic merges into \`${defaultBranch}\` on its own, not into another epic`;
1291
+ return {
1292
+ findings: [
1293
+ {
1294
+ check: BASE_BRANCH_CHECK,
1295
+ message: `${target}, ${reason}. It can merge once #${waitFor.number} merges and GitHub retargets this PR to its base.`,
1296
+ effect: "hold"
1297
+ }
1298
+ ]
1299
+ };
1300
+ }
1301
+ var baseBranchCheck = {
1302
+ name: BASE_BRANCH_CHECK,
1303
+ evaluate: async (pr) => decideBaseBranch(pr)
1304
+ };
1305
+
1266
1306
  // src/checks/uat/index.ts
1267
1307
  var UAT_CHECK = "uat";
1268
1308
  var PASSING_LABELS = [UAT_PASSED_LABEL, LEGACY_UAT_PASSED_LABEL, NO_UAT_NEEDED_LABEL];
@@ -1302,6 +1342,7 @@ var uatCheck = {
1302
1342
  // src/checks/index.ts
1303
1343
  var CHECKS = [
1304
1344
  mergeBlockCheck,
1345
+ baseBranchCheck,
1305
1346
  titleCheck,
1306
1347
  ciChangeCheck,
1307
1348
  uatCheck,
@@ -1403,12 +1444,42 @@ function parseAuthor(value) {
1403
1444
  }
1404
1445
  return { login, type };
1405
1446
  }
1447
+ function parseOpenPullRequest(value, index) {
1448
+ const where = `facts.base.headOf[${index}]`;
1449
+ if (typeof value !== "object" || value === null) throw new Error(`${where} must be an object`);
1450
+ const { number, labels } = value;
1451
+ if (typeof number !== "number") throw new Error(`${where}.number must be a number`);
1452
+ if (!isStringArray(labels)) throw new Error(`${where}.labels must be a string array`);
1453
+ return { number, labels };
1454
+ }
1455
+ function parseBase(value) {
1456
+ if (value === void 0) return void 0;
1457
+ if (typeof value !== "object" || value === null) throw new Error("facts.base must be an object");
1458
+ const { branch, defaultBranch, headOf } = value;
1459
+ if (typeof branch !== "string" || typeof defaultBranch !== "string") {
1460
+ throw new Error("facts.base.branch / defaultBranch must be strings");
1461
+ }
1462
+ if (headOf !== void 0 && !Array.isArray(headOf)) {
1463
+ throw new Error("facts.base.headOf must be an array");
1464
+ }
1465
+ return { branch, defaultBranch, headOf: (headOf ?? []).map(parseOpenPullRequest) };
1466
+ }
1406
1467
  function parseFacts(raw) {
1407
1468
  const parsed = JSON.parse(raw);
1408
1469
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1409
1470
  throw new Error("facts must be a JSON object");
1410
1471
  }
1411
- const { title, body, author, labels, changedFiles, workflowChanges, manifestChanges, signOffs } = parsed;
1472
+ const {
1473
+ title,
1474
+ body,
1475
+ author,
1476
+ base,
1477
+ labels,
1478
+ changedFiles,
1479
+ workflowChanges,
1480
+ manifestChanges,
1481
+ signOffs
1482
+ } = parsed;
1412
1483
  if (typeof title !== "string") throw new Error("facts.title must be a string");
1413
1484
  if (!isOptionalString(body)) throw new Error("facts.body must be a string");
1414
1485
  if (!isStringArray(labels)) throw new Error("facts.labels must be a string array");
@@ -1422,8 +1493,10 @@ function parseFacts(raw) {
1422
1493
  signOffs: parseSignOffs(signOffs)
1423
1494
  };
1424
1495
  const parsedAuthor = parseAuthor(author);
1496
+ const parsedBase = parseBase(base);
1425
1497
  if (body !== void 0) pr.body = body;
1426
1498
  if (parsedAuthor !== void 0) pr.author = parsedAuthor;
1499
+ if (parsedBase !== void 0) pr.base = parsedBase;
1427
1500
  return pr;
1428
1501
  }
1429
1502
 
@@ -1730,6 +1803,29 @@ async function listFiles(target) {
1730
1803
  if (out === null) throw new Error(`could not list files of ${target.repo}#${target.pr}`);
1731
1804
  return out.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
1732
1805
  }
1806
+ async function gatherBase(target, view) {
1807
+ const branch = view.base.ref;
1808
+ const defaultBranch = view.base.repo.default_branch;
1809
+ if (branch === defaultBranch) return { branch, defaultBranch, headOf: [] };
1810
+ const head = encodeURIComponent(`${target.repo.split("/")[0] ?? ""}:${branch}`);
1811
+ const out = await ghCall(
1812
+ {
1813
+ argv: [
1814
+ "gh",
1815
+ "api",
1816
+ "--paginate",
1817
+ `repos/${target.repo}/pulls?state=open&head=${head}&per_page=100`,
1818
+ "--jq",
1819
+ ".[] | {number, labels: [.labels[].name]}"
1820
+ ]
1821
+ },
1822
+ null,
1823
+ { cwd: target.cwd }
1824
+ );
1825
+ if (out === null) throw new Error(`could not list the open PRs heading ${branch}`);
1826
+ const headOf = out.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
1827
+ return { branch, defaultBranch, headOf };
1828
+ }
1733
1829
  async function gatherFacts(target) {
1734
1830
  const view = await ghJson(target, [
1735
1831
  "gh",
@@ -1763,6 +1859,7 @@ async function gatherFacts(target) {
1763
1859
  title: view.title,
1764
1860
  body: view.body ?? "",
1765
1861
  author: { login: view.user.login, type: view.user.type },
1862
+ base: await gatherBase(target, view),
1766
1863
  labels,
1767
1864
  // A rename's old path counts too: moving code into docs/ is not docs-only.
1768
1865
  changedFiles: files.flatMap(
package/dist/index.d.ts CHANGED
@@ -36,6 +36,23 @@ interface PullRequestAuthor {
36
36
  login: string;
37
37
  type: string;
38
38
  }
39
+ /** An open PR, reduced to what the base-branch check reads. */
40
+ interface OpenPullRequest {
41
+ number: number;
42
+ labels: readonly string[];
43
+ }
44
+ /** Where a PR merges: its base branch, and what that branch is. */
45
+ interface PullRequestBase {
46
+ /** The branch the PR merges into. */
47
+ branch: string;
48
+ /** The repository's default branch. */
49
+ defaultBranch: string;
50
+ /**
51
+ * The open PRs whose head is `branch`. Empty for the default branch, which
52
+ * is never looked up.
53
+ */
54
+ headOf: readonly OpenPullRequest[];
55
+ }
39
56
  /**
40
57
  * The facts about a pull request that policy checks judge. Every field is a
41
58
  * property of the PR's current state — its content and the labels on it, never
@@ -51,6 +68,8 @@ interface PullRequestFacts {
51
68
  body?: string;
52
69
  /** Who opened the PR. `gatherFacts` always sets it. */
53
70
  author?: PullRequestAuthor;
71
+ /** The branch the PR merges into. `gatherFacts` always sets it. */
72
+ base?: PullRequestBase;
54
73
  labels: readonly string[];
55
74
  /** Every path the PR touches, including the old path of a rename. */
56
75
  changedFiles: readonly string[];
@@ -360,7 +379,8 @@ declare function evaluatePolicy(pr: PullRequestFacts, checks?: readonly PolicyCh
360
379
  /**
361
380
  * Parse and validate a JSON facts document into `PullRequestFacts`.
362
381
  * `workflowChanges`, `manifestChanges`, and `signOffs` are optional and default
363
- * to none; `body` and `author` are optional and stay absent. With no `signOffs`, no sign-off label counts: trust fails closed.
382
+ * to none; `body`, `author`, and `base` are optional and stay absent. With no
383
+ * `signOffs`, no sign-off label counts: trust fails closed.
364
384
  */
365
385
  declare function parseFacts(raw: string): PullRequestFacts;
366
386
 
package/dist/index.js CHANGED
@@ -48,7 +48,7 @@ import {
48
48
  sensitiveBumps,
49
49
  statusBody,
50
50
  titleCheck
51
- } from "./chunk-TFZMH2KF.js";
51
+ } from "./chunk-TLGL2PTZ.js";
52
52
  export {
53
53
  AMBIGUOUS_INDICATORS,
54
54
  BLOCKED_LABEL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmartz/pr-policy",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Read-only PR content classifiers — CI-change classification, title-type rules, and more — reported as one blocking pr-policy check-run. Distributed as a pinned Action kept current by Dependabot.",
5
5
  "keywords": [
6
6
  "pr-policy",
@@ -47,24 +47,20 @@
47
47
  "format": "prettier --write .",
48
48
  "format:check": "prettier --check .",
49
49
  "test": "vitest run",
50
- "test:watch": "vitest",
51
- "verify:release-notes": "node scripts/verify-changelog-render.mjs"
50
+ "test:watch": "vitest"
52
51
  },
53
52
  "dependencies": {
54
53
  "yaml": "^2.9.1"
55
54
  },
56
55
  "devDependencies": {
57
- "@semantic-release/release-notes-generator": "^14.1.1",
58
56
  "@types/node": "^26.6.2",
59
57
  "@typescript-eslint/eslint-plugin": "^8.70.0",
60
58
  "@typescript-eslint/parser": "^8.70.0",
61
59
  "@vitest/coverage-v8": "^5.0.1",
62
- "conventional-changelog-conventionalcommits": "^9.3.1",
63
60
  "eslint": "^10.11.0",
64
61
  "eslint-import-resolver-typescript": "^4.4.5",
65
62
  "eslint-plugin-import": "^2.32.0",
66
63
  "prettier": "^3.9.8",
67
- "semantic-release": "^25.0.9",
68
64
  "tsup": "^8.5.1",
69
65
  "typescript": "^6.0.3",
70
66
  "vite": "^8.3.0",