@rmartz/pr-policy 0.2.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.
@@ -0,0 +1,276 @@
1
+ /**
2
+ * One changed file's text on both sides of the pull request. An absent side
3
+ * means the PR added (no `baseText`) or deleted (no `headText`) the file.
4
+ */
5
+ interface FileChange {
6
+ /** Repo-relative path at head (or at base, for a deletion). */
7
+ path: string;
8
+ /** Content at the merge base — not the base tip, so others' merges don't count. */
9
+ baseText?: string;
10
+ /** Content at the PR head. */
11
+ headText?: string;
12
+ }
13
+ /**
14
+ * The facts about a pull request that policy checks judge. Every field is a
15
+ * property of the PR's own content — never its review history, which belongs to
16
+ * the separate lifecycle reconciler (rmartz/ai-tools#306).
17
+ */
18
+ interface PullRequestFacts {
19
+ title: string;
20
+ labels: readonly string[];
21
+ changedFiles: readonly string[];
22
+ /** Both sides of every changed `.github/workflows/**` file. */
23
+ workflowChanges: readonly FileChange[];
24
+ /** Both sides of every changed dependency manifest (`package.json`, `requirements*.txt`). */
25
+ manifestChanges: readonly FileChange[];
26
+ }
27
+ /**
28
+ * What a finding does to the `pr-policy` check-run:
29
+ *
30
+ * - `block` — red (`failure`). The author can fix it (a title edit, a code
31
+ * change), so it reads correctly as "this PR has a problem".
32
+ * - `hold` — pending (`in_progress`). Nothing is broken; the PR is waiting on a
33
+ * human act, such as applying a sign-off label. Posting it red would read as a
34
+ * broken build and invite a fix pass that cannot clear it.
35
+ * - `info` — listed in the summary, never gates.
36
+ */
37
+ declare const FINDING_EFFECTS: readonly ["block", "hold", "info"];
38
+ type FindingEffect = (typeof FINDING_EFFECTS)[number];
39
+ /** One policy observation, attributed to the check that produced it. */
40
+ interface Finding {
41
+ check: string;
42
+ message: string;
43
+ effect: FindingEffect;
44
+ /**
45
+ * A short line that can stand as the check-run title when nothing gates —
46
+ * e.g. "CI loosening signed off". The first headline wins.
47
+ */
48
+ headline?: boolean;
49
+ }
50
+ /**
51
+ * What one check reports: its findings, plus edits to labels it owns outright.
52
+ * A check never lists a label someone else owns.
53
+ */
54
+ interface CheckResult {
55
+ findings: readonly Finding[];
56
+ labelsToAdd?: readonly string[];
57
+ labelsToRemove?: readonly string[];
58
+ }
59
+ /**
60
+ * A read-only classifier. It reports and plans label edits; the caller applies
61
+ * them. It never mutates the PR itself.
62
+ */
63
+ interface PolicyCheck {
64
+ name: string;
65
+ evaluate(pr: PullRequestFacts): Promise<CheckResult>;
66
+ }
67
+
68
+ /**
69
+ * Every registered policy check, in report order. Each check lives in its own
70
+ * module under src/checks/ and is added here; all of them report into the single
71
+ * `pr-policy` check-run. See docs/adding-a-check.md.
72
+ */
73
+ declare const CHECKS: readonly PolicyCheck[];
74
+
75
+ /**
76
+ * The indicator vocabulary — the classifier's whole output surface.
77
+ *
78
+ * Each indicator names one recognised change to a workflow document and the
79
+ * class it falls in. The two classes are asymmetric on purpose: `loosening` is a
80
+ * change the CI directive requires a human to sign off, and `ambiguous` is a
81
+ * change the directive says to *treat as* a loosening because it cannot be shown
82
+ * to be benign. Both produce the same verdict; they are kept apart so the
83
+ * check-run output can tell a reader which it saw.
84
+ */
85
+ /** Changes that reduce CI coverage outright (review.md Step 5, "loosening indicators"). */
86
+ declare const LOOSENING_INDICATORS: readonly ["workflow-removed", "job-removed", "step-removed", "continue-on-error-added", "if-added", "trigger-narrowed", "matrix-reduced", "needs-reduced"];
87
+ /** Changes the directive says to treat as loosening because they are not clearly tightening. */
88
+ declare const AMBIGUOUS_INDICATORS: readonly ["runs-on-changed", "permissions-changed", "env-changed", "timeout-changed", "unparseable", "unclassified-change"];
89
+ type LooseningIndicator = (typeof LOOSENING_INDICATORS)[number];
90
+ type AmbiguousIndicator = (typeof AMBIGUOUS_INDICATORS)[number];
91
+ type IndicatorKind = LooseningIndicator | AmbiguousIndicator;
92
+ /** Which bucket an indicator falls in. Both hold the merge; only the wording differs. */
93
+ type IndicatorClass = 'loosening' | 'ambiguous';
94
+ /** One recognised change, bound to the file and the place in it that produced it. */
95
+ interface Indicator {
96
+ kind: IndicatorKind;
97
+ class: IndicatorClass;
98
+ /** Repo-relative path of the workflow file. */
99
+ file: string;
100
+ /** Where in the document — e.g. `jobs.build.steps[install]`, `on.pull_request.branches`. */
101
+ location: string;
102
+ /** One human-readable sentence naming what changed. */
103
+ detail: string;
104
+ }
105
+ /**
106
+ * The verdict for a whole pull request.
107
+ *
108
+ * - `no-change` — the diff touches no CI workflow file. Nothing to report.
109
+ * - `tightening` — every recognised change adds coverage or is maintenance.
110
+ * - `loosening` — at least one loosening *or* ambiguous indicator fired, so the
111
+ * `CI approval needed` merge gate applies.
112
+ */
113
+ declare const CI_CHANGE_VERDICTS: readonly ["no-change", "tightening", "loosening"];
114
+ type CiChangeVerdict = (typeof CI_CHANGE_VERDICTS)[number];
115
+
116
+ /** One workflow file's before/after text. */
117
+ type WorkflowFileChange = FileChange;
118
+ /** The verdict for a pull request, with the evidence that produced it. */
119
+ interface Classification {
120
+ verdict: CiChangeVerdict;
121
+ indicators: Indicator[];
122
+ }
123
+ /** Every indicator for a single workflow file. */
124
+ declare function classifyWorkflowFile(change: WorkflowFileChange): Indicator[];
125
+ /** The pull-request-wide verdict across every workflow file it touches. */
126
+ declare function classifyWorkflowChanges(changes: readonly WorkflowFileChange[]): Classification;
127
+
128
+ declare const CI_CHANGE_CHECK = "ci-change";
129
+ /** Decide the findings and label edits for one classified PR. */
130
+ declare function decideCiChange(classification: Classification, labels: readonly string[]): CheckResult;
131
+ declare const ciChangeCheck: PolicyCheck;
132
+
133
+ /**
134
+ * Which files this guard considers "a CI workflow".
135
+ *
136
+ * Scoped to `.github/workflows/**` as the CI directive is: those are the files
137
+ * whose content decides what CI runs. A composite action under
138
+ * `.github/actions/` can change behaviour too, but it is invoked *by* a workflow
139
+ * step, so a change there that matters shows up as a step change here — and
140
+ * widening the scope would make every consuming repo's Action edits carry a
141
+ * human sign-off gate.
142
+ */
143
+ declare function isWorkflowPath(path: string): boolean;
144
+
145
+ declare const TITLE_CHECK = "title";
146
+ /** Every title rule violated by this PR's current title, labels, and diff. */
147
+ declare function decideTitle(pr: PullRequestFacts): CheckResult;
148
+ declare const titleCheck: PolicyCheck;
149
+
150
+ /**
151
+ * The Conventional-Commit grammar a squash subject must satisfy. It mirrors the
152
+ * fleet's `pr-title-lint.yml` and `commit-convention.yml` exactly, so a title
153
+ * this accepts is one the post-merge tripwire accepts too.
154
+ */
155
+ declare const COMMIT_TYPES: readonly ["feat", "fix", "docs", "chore", "refactor", "test", "style", "perf", "ci", "build", "revert"];
156
+ type CommitType = (typeof COMMIT_TYPES)[number];
157
+ /**
158
+ * The only types that may carry `!`. Under semantic-release `!` cuts a major, so
159
+ * it is reserved for shippable functional change (rmartz/dotfiles#1559).
160
+ */
161
+ declare const FUNCTIONAL_TYPES: readonly ["feat", "fix", "perf", "revert"];
162
+ interface ParsedTitle {
163
+ type: CommitType;
164
+ scope?: string;
165
+ breaking: boolean;
166
+ subject: string;
167
+ }
168
+ /** Parse a title, or `null` when it is not a valid Conventional Commit. */
169
+ declare function parseTitle(title: string): ParsedTitle | null;
170
+
171
+ /**
172
+ * Detects a version change to a CI-sensitive linter or formatter. A new
173
+ * black/ruff/prettier/eslint/pylint can change results on files a PR never
174
+ * touched, so every in-flight PR must re-test against it — which the
175
+ * coordinator keys off the `ci` type. Comparison is structural: each manifest is
176
+ * parsed on both sides and the declared versions compared.
177
+ */
178
+
179
+ declare const CI_SENSITIVE_PACKAGES: readonly ["eslint", "black", "pylint", "ruff", "prettier"];
180
+ /** Whether a path is a dependency manifest the title check reads. */
181
+ declare function isManifestPath(path: string): boolean;
182
+ /**
183
+ * The CI-sensitive packages whose declared version changed between the two
184
+ * sides of any manifest. A package only added or only removed is not a bump.
185
+ */
186
+ declare function sensitiveBumps(changes: readonly FileChange[]): string[];
187
+
188
+ /**
189
+ * Names read outside this repo. Each is a fleet contract: consumers' rulesets
190
+ * require the check-run by literal name, the coordinator's gate model parks a PR
191
+ * on the label, and a human applies the sign-off label by hand. Renaming any of
192
+ * them is a coordinated cross-repo migration, never a local refactor — see
193
+ * docs/check-run-contract.md. `test/contract.test.ts` pins every value.
194
+ */
195
+ /**
196
+ * The one check-run this package posts. Every policy check reports into it, so
197
+ * adding a check never adds a required-status name.
198
+ */
199
+ declare const PR_POLICY_CHECK_NAME = "pr-policy";
200
+ /** The merge-gate label the CI-change check applies to an unsigned loosening. */
201
+ declare const CI_APPROVAL_NEEDED_LABEL = "CI approval needed";
202
+ /**
203
+ * The human sign-off on a CI loosening. Read-only here: this package never
204
+ * applies it.
205
+ */
206
+ declare const CI_CHANGE_APPROVED_LABEL = "CI change approved";
207
+ /**
208
+ * Labels the title check reads and never writes. They belong to the review and
209
+ * release flow: `breaking change` is the source of truth for a breaking PR,
210
+ * `hotfix` implies one, and release-please marks its release PRs with
211
+ * `autorelease: pending`.
212
+ */
213
+ declare const BREAKING_CHANGE_LABEL = "breaking change";
214
+ declare const HOTFIX_LABEL = "hotfix";
215
+ declare const RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
216
+
217
+ /**
218
+ * The overall result of one evaluation. `pending` posts the check-run as
219
+ * `in_progress` (no conclusion), so a PR waiting on a human sign-off shows as
220
+ * waiting rather than as a failing build; the required check still holds the
221
+ * merge until a later run completes it.
222
+ */
223
+ declare const POLICY_OUTCOMES: readonly ["success", "pending", "failure"];
224
+ type PolicyOutcome = (typeof POLICY_OUTCOMES)[number];
225
+ /** The body of the one `pr-policy` check-run. */
226
+ interface CheckRunReport {
227
+ outcome: PolicyOutcome;
228
+ title: string;
229
+ summary: string;
230
+ findings: readonly Finding[];
231
+ }
232
+ /**
233
+ * Fold every check's findings into the single check-run. Any `block` finding
234
+ * makes it `failure`; otherwise any `hold` makes it `pending`; otherwise it is
235
+ * `success`. A fixable problem outranks a pending sign-off, so the author sees
236
+ * the thing they can act on.
237
+ */
238
+ declare function buildReport(findings: readonly Finding[]): CheckRunReport;
239
+
240
+ /** The one check-run report, plus the label edits the checks planned. */
241
+ interface PolicyEvaluation extends CheckRunReport {
242
+ labelsToAdd: string[];
243
+ labelsToRemove: string[];
244
+ }
245
+ /** Run every policy check against one PR and fold the results into one report. */
246
+ declare function evaluatePolicy(pr: PullRequestFacts, checks?: readonly PolicyCheck[]): Promise<PolicyEvaluation>;
247
+
248
+ /**
249
+ * Parse and validate a JSON facts document into `PullRequestFacts`.
250
+ * `workflowChanges` and `manifestChanges` are optional and default to none.
251
+ */
252
+ declare function parseFacts(raw: string): PullRequestFacts;
253
+
254
+ interface PullRequestTarget {
255
+ repo: string;
256
+ pr: number;
257
+ cwd?: string;
258
+ }
259
+ /**
260
+ * Gather everything the checks judge. Workflow files and dependency manifests
261
+ * are read at the **merge base**, not the base tip, so a change merged into the
262
+ * base after this PR branched is not attributed to it.
263
+ */
264
+ declare function gatherFacts(target: PullRequestTarget): Promise<{
265
+ facts: PullRequestFacts;
266
+ headSha: string;
267
+ }>;
268
+ /** Apply the label edits the checks planned (labels this package owns). */
269
+ declare function applyLabelEdits(target: PullRequestTarget, evaluation: PolicyEvaluation): Promise<void>;
270
+
271
+ /** The request body for this evaluation's check-run state. */
272
+ declare function checkRunBody(evaluation: PolicyEvaluation, now: Date): Record<string, unknown>;
273
+ /** Post (or complete) the one `pr-policy` check-run on the head commit. */
274
+ declare function postCheckRun(target: PullRequestTarget, headSha: string, evaluation: PolicyEvaluation): Promise<void>;
275
+
276
+ export { AMBIGUOUS_INDICATORS, type AmbiguousIndicator, BREAKING_CHANGE_LABEL, CHECKS, CI_APPROVAL_NEEDED_LABEL, CI_CHANGE_APPROVED_LABEL, CI_CHANGE_CHECK, CI_CHANGE_VERDICTS, CI_SENSITIVE_PACKAGES, COMMIT_TYPES, type CheckResult, type CheckRunReport, type CiChangeVerdict, type Classification, type CommitType, FUNCTIONAL_TYPES, type FileChange, type Finding, HOTFIX_LABEL, type Indicator, type IndicatorClass, type IndicatorKind, LOOSENING_INDICATORS, type LooseningIndicator, POLICY_OUTCOMES, PR_POLICY_CHECK_NAME, type ParsedTitle, type PolicyCheck, type PolicyEvaluation, type PolicyOutcome, type PullRequestFacts, type PullRequestTarget, RELEASE_PLEASE_PENDING_LABEL, TITLE_CHECK, type WorkflowFileChange, applyLabelEdits, buildReport, checkRunBody, ciChangeCheck, classifyWorkflowChanges, classifyWorkflowFile, decideCiChange, decideTitle, evaluatePolicy, gatherFacts, isManifestPath, isWorkflowPath, parseFacts, parseTitle, postCheckRun, sensitiveBumps, titleCheck };
package/dist/index.js ADDED
@@ -0,0 +1,70 @@
1
+ import {
2
+ AMBIGUOUS_INDICATORS,
3
+ BREAKING_CHANGE_LABEL,
4
+ CHECKS,
5
+ CI_APPROVAL_NEEDED_LABEL,
6
+ CI_CHANGE_APPROVED_LABEL,
7
+ CI_CHANGE_CHECK,
8
+ CI_CHANGE_VERDICTS,
9
+ CI_SENSITIVE_PACKAGES,
10
+ COMMIT_TYPES,
11
+ FUNCTIONAL_TYPES,
12
+ HOTFIX_LABEL,
13
+ LOOSENING_INDICATORS,
14
+ POLICY_OUTCOMES,
15
+ PR_POLICY_CHECK_NAME,
16
+ RELEASE_PLEASE_PENDING_LABEL,
17
+ TITLE_CHECK,
18
+ applyLabelEdits,
19
+ buildReport,
20
+ checkRunBody,
21
+ ciChangeCheck,
22
+ classifyWorkflowChanges,
23
+ classifyWorkflowFile,
24
+ decideCiChange,
25
+ decideTitle,
26
+ evaluatePolicy,
27
+ gatherFacts,
28
+ isManifestPath,
29
+ isWorkflowPath,
30
+ parseFacts,
31
+ parseTitle,
32
+ postCheckRun,
33
+ sensitiveBumps,
34
+ titleCheck
35
+ } from "./chunk-QINHHFJN.js";
36
+ export {
37
+ AMBIGUOUS_INDICATORS,
38
+ BREAKING_CHANGE_LABEL,
39
+ CHECKS,
40
+ CI_APPROVAL_NEEDED_LABEL,
41
+ CI_CHANGE_APPROVED_LABEL,
42
+ CI_CHANGE_CHECK,
43
+ CI_CHANGE_VERDICTS,
44
+ CI_SENSITIVE_PACKAGES,
45
+ COMMIT_TYPES,
46
+ FUNCTIONAL_TYPES,
47
+ HOTFIX_LABEL,
48
+ LOOSENING_INDICATORS,
49
+ POLICY_OUTCOMES,
50
+ PR_POLICY_CHECK_NAME,
51
+ RELEASE_PLEASE_PENDING_LABEL,
52
+ TITLE_CHECK,
53
+ applyLabelEdits,
54
+ buildReport,
55
+ checkRunBody,
56
+ ciChangeCheck,
57
+ classifyWorkflowChanges,
58
+ classifyWorkflowFile,
59
+ decideCiChange,
60
+ decideTitle,
61
+ evaluatePolicy,
62
+ gatherFacts,
63
+ isManifestPath,
64
+ isWorkflowPath,
65
+ parseFacts,
66
+ parseTitle,
67
+ postCheckRun,
68
+ sensitiveBumps,
69
+ titleCheck
70
+ };
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@rmartz/pr-policy",
3
+ "version": "0.2.0",
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
+ "keywords": [
6
+ "pr-policy",
7
+ "pull-request",
8
+ "github-actions",
9
+ "check-run",
10
+ "merge-gate",
11
+ "conventional-commits"
12
+ ],
13
+ "homepage": "https://github.com/rmartz/pr-policy#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/rmartz/pr-policy/issues"
16
+ },
17
+ "type": "module",
18
+ "packageManager": "pnpm@9.12.0",
19
+ "engines": {
20
+ "node": ">=20.11.0"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "bin": {
29
+ "ai-pr-policy": "./dist/bin/pr-policy.js"
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "@rmartz:registry": "https://registry.npmjs.org/",
37
+ "access": "public"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/rmartz/pr-policy.git"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "typecheck": "tsc --noEmit",
46
+ "lint": "eslint .",
47
+ "format": "prettier --write .",
48
+ "format:check": "prettier --check .",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest",
51
+ "verify:release-notes": "node scripts/verify-changelog-render.mjs"
52
+ },
53
+ "dependencies": {
54
+ "yaml": "^2.9.1"
55
+ },
56
+ "devDependencies": {
57
+ "@semantic-release/release-notes-generator": "^14.1.1",
58
+ "@types/node": "^26.6.2",
59
+ "@typescript-eslint/eslint-plugin": "^8.70.0",
60
+ "@typescript-eslint/parser": "^8.70.0",
61
+ "@vitest/coverage-v8": "^5.0.1",
62
+ "conventional-changelog-conventionalcommits": "^9.3.1",
63
+ "eslint": "^10.11.0",
64
+ "eslint-import-resolver-typescript": "^4.4.5",
65
+ "eslint-plugin-import": "^2.32.0",
66
+ "prettier": "^3.9.8",
67
+ "semantic-release": "^25.0.9",
68
+ "tsup": "^8.5.1",
69
+ "typescript": "^6.0.3",
70
+ "vite": "^8.3.0",
71
+ "vitest": "^5.0.1"
72
+ },
73
+ "pnpm": {
74
+ "overrides": {
75
+ "esbuild": ">=0.28.1"
76
+ }
77
+ }
78
+ }