@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 +1 -0
- package/dist/bin/pr-policy.js +1 -1
- package/dist/{chunk-TFZMH2KF.js → chunk-TLGL2PTZ.js} +98 -1
- 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";
|
|
@@ -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 {
|
|
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 `
|
|
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",
|