@rmartz/pr-policy 0.9.0 → 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-PVXDVMME.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";
@@ -892,10 +893,17 @@ function substantiveWorkflowChanges(changes) {
892
893
  return changes.filter((change) => !isShippedReusableWorkflow(change) && !isPinBumpOnly(change)).map((change) => change.path);
893
894
  }
894
895
 
896
+ // src/checks/test-paths.ts
897
+ var TEST_DIRS = /* @__PURE__ */ new Set(["test", "tests", "__tests__", "__snapshots__", "__mocks__", "e2e"]);
898
+ var TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$|^test_.*\.py$|_test\.(py|go)$|^conftest\.py$/;
899
+ function isTestPath(path) {
900
+ const parts = path.split("/");
901
+ const name = parts.at(-1) ?? "";
902
+ return parts.slice(0, -1).some((dir) => TEST_DIRS.has(dir)) || TEST_FILE.test(name);
903
+ }
904
+
895
905
  // src/checks/uat/trivial.ts
896
906
  var TRIVIAL_CATEGORIES = ["docs", "tests", "ci", "dependencies", "metadata"];
897
- var TEST_DIRS = /* @__PURE__ */ new Set(["test", "tests", "__tests__", "__snapshots__", "__mocks__", "e2e"]);
898
- var TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$|^test_.*\.py$|_test\.py$|^conftest\.py$/;
899
907
  var LOCKFILES = /* @__PURE__ */ new Set([
900
908
  "pnpm-lock.yaml",
901
909
  "package-lock.json",
@@ -930,9 +938,8 @@ function isDependencyOnlyManifest(change) {
930
938
  function categoryOf(path, pr) {
931
939
  const parts = path.split("/");
932
940
  const name = parts[parts.length - 1] ?? "";
933
- const dirs = parts.slice(0, -1);
934
941
  if (name.endsWith(".md") || parts[0] === "docs") return "docs";
935
- if (dirs.some((dir) => TEST_DIRS.has(dir)) || TEST_FILE.test(name)) return "tests";
942
+ if (isTestPath(path)) return "tests";
936
943
  if (path.startsWith(".github/workflows/") || path.startsWith(".github/actions/")) {
937
944
  const change = pr.workflowChanges.find((file) => file.path === path);
938
945
  return change !== void 0 && isShippedReusableWorkflow(change) ? void 0 : "ci";
@@ -1150,16 +1157,9 @@ function isReleasePleaseTitle(title) {
1150
1157
  }
1151
1158
 
1152
1159
  // src/checks/title/type-paths.ts
1153
- var TEST_DIRS2 = /* @__PURE__ */ new Set(["test", "tests", "__tests__"]);
1154
- var TEST_FILE2 = /\.(test|spec)\.[^/]+$|^test_[^/]*\.py$|_test\.(py|go)$/;
1155
1160
  function isMarkdownPath(path) {
1156
1161
  return path.toLowerCase().endsWith(".md");
1157
1162
  }
1158
- function isTestPath(path) {
1159
- const parts = path.split("/");
1160
- const name = parts.at(-1) ?? "";
1161
- return parts.slice(0, -1).some((dir) => TEST_DIRS2.has(dir)) || TEST_FILE2.test(name);
1162
- }
1163
1163
  var quote = (paths) => {
1164
1164
  const shown = paths.slice(0, 5).map((path) => `\`${path}\``);
1165
1165
  if (paths.length > 5) shown.push(`and ${paths.length - 5} more`);
@@ -1264,6 +1264,45 @@ var titleCheck = {
1264
1264
  evaluate: async (pr) => decideTitle(pr)
1265
1265
  };
1266
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
+
1267
1306
  // src/checks/uat/index.ts
1268
1307
  var UAT_CHECK = "uat";
1269
1308
  var PASSING_LABELS = [UAT_PASSED_LABEL, LEGACY_UAT_PASSED_LABEL, NO_UAT_NEEDED_LABEL];
@@ -1303,6 +1342,7 @@ var uatCheck = {
1303
1342
  // src/checks/index.ts
1304
1343
  var CHECKS = [
1305
1344
  mergeBlockCheck,
1345
+ baseBranchCheck,
1306
1346
  titleCheck,
1307
1347
  ciChangeCheck,
1308
1348
  uatCheck,
@@ -1404,12 +1444,42 @@ function parseAuthor(value) {
1404
1444
  }
1405
1445
  return { login, type };
1406
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
+ }
1407
1467
  function parseFacts(raw) {
1408
1468
  const parsed = JSON.parse(raw);
1409
1469
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1410
1470
  throw new Error("facts must be a JSON object");
1411
1471
  }
1412
- 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;
1413
1483
  if (typeof title !== "string") throw new Error("facts.title must be a string");
1414
1484
  if (!isOptionalString(body)) throw new Error("facts.body must be a string");
1415
1485
  if (!isStringArray(labels)) throw new Error("facts.labels must be a string array");
@@ -1423,8 +1493,10 @@ function parseFacts(raw) {
1423
1493
  signOffs: parseSignOffs(signOffs)
1424
1494
  };
1425
1495
  const parsedAuthor = parseAuthor(author);
1496
+ const parsedBase = parseBase(base);
1426
1497
  if (body !== void 0) pr.body = body;
1427
1498
  if (parsedAuthor !== void 0) pr.author = parsedAuthor;
1499
+ if (parsedBase !== void 0) pr.base = parsedBase;
1428
1500
  return pr;
1429
1501
  }
1430
1502
 
@@ -1731,6 +1803,29 @@ async function listFiles(target) {
1731
1803
  if (out === null) throw new Error(`could not list files of ${target.repo}#${target.pr}`);
1732
1804
  return out.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
1733
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
+ }
1734
1829
  async function gatherFacts(target) {
1735
1830
  const view = await ghJson(target, [
1736
1831
  "gh",
@@ -1764,6 +1859,7 @@ async function gatherFacts(target) {
1764
1859
  title: view.title,
1765
1860
  body: view.body ?? "",
1766
1861
  author: { login: view.user.login, type: view.user.type },
1862
+ base: await gatherBase(target, view),
1767
1863
  labels,
1768
1864
  // A rename's old path counts too: moving code into docs/ is not docs-only.
1769
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-PVXDVMME.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.0",
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",