@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 +1 -0
- package/dist/bin/pr-policy.js +1 -1
- package/dist/{chunk-PVXDVMME.js → chunk-TLGL2PTZ.js} +108 -12
- package/dist/index.d.ts +21 -1
- package/dist/index.js +1 -1
- package/package.json +2 -6
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). |
|
package/dist/bin/pr-policy.js
CHANGED
|
@@ -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 (
|
|
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 {
|
|
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 `
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmartz/pr-policy",
|
|
3
|
-
"version": "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",
|