@rmartz/pr-policy 0.2.0 → 0.9.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 +12 -8
- package/dist/bin/pr-policy.js +17 -12
- package/dist/{chunk-QINHHFJN.js → chunk-PVXDVMME.js} +767 -80
- package/dist/index.d.ts +122 -5
- package/dist/index.js +33 -1
- package/package.json +1 -1
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
var PR_POLICY_CHECK_NAME = "pr-policy";
|
|
3
3
|
var CI_APPROVAL_NEEDED_LABEL = "CI approval needed";
|
|
4
4
|
var CI_CHANGE_APPROVED_LABEL = "CI change approved";
|
|
5
|
+
var UAT_PASSED_LABEL = "UAT passed";
|
|
6
|
+
var LEGACY_UAT_PASSED_LABEL = "tested";
|
|
7
|
+
var NO_UAT_NEEDED_LABEL = "no UAT needed";
|
|
8
|
+
var SIGN_OFF_LABELS = [
|
|
9
|
+
CI_CHANGE_APPROVED_LABEL,
|
|
10
|
+
UAT_PASSED_LABEL,
|
|
11
|
+
LEGACY_UAT_PASSED_LABEL,
|
|
12
|
+
NO_UAT_NEEDED_LABEL
|
|
13
|
+
];
|
|
14
|
+
var DO_NOT_MERGE_LABEL = "do not merge";
|
|
15
|
+
var DNM_LABEL = "dnm";
|
|
16
|
+
var BLOCKED_LABEL = "blocked";
|
|
17
|
+
var ESCALATION_NEEDED_LABEL = "escalation needed";
|
|
18
|
+
var BLOCKING_LABELS = [
|
|
19
|
+
DO_NOT_MERGE_LABEL,
|
|
20
|
+
DNM_LABEL,
|
|
21
|
+
BLOCKED_LABEL,
|
|
22
|
+
ESCALATION_NEEDED_LABEL
|
|
23
|
+
];
|
|
5
24
|
var BREAKING_CHANGE_LABEL = "breaking change";
|
|
6
25
|
var HOTFIX_LABEL = "hotfix";
|
|
7
26
|
var RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
|
|
@@ -519,15 +538,42 @@ function classifyWorkflowChanges(changes) {
|
|
|
519
538
|
return { verdict: indicators.length > 0 ? "loosening" : "tightening", indicators };
|
|
520
539
|
}
|
|
521
540
|
|
|
541
|
+
// src/policy.ts
|
|
542
|
+
var REPO_PERMISSIONS = ["admin", "maintain", "write", "triage", "read", "none"];
|
|
543
|
+
|
|
544
|
+
// src/sign-off.ts
|
|
545
|
+
function isRepoPermission(value) {
|
|
546
|
+
return REPO_PERMISSIONS.some((permission) => permission === value);
|
|
547
|
+
}
|
|
548
|
+
var MERGE_PERMISSIONS = /* @__PURE__ */ new Set(["admin", "maintain", "write"]);
|
|
549
|
+
function vet(actor) {
|
|
550
|
+
if (actor === void 0) return "no labeling event names a live account";
|
|
551
|
+
if (actor.type !== "User") return `\`${actor.login}\` is a ${actor.type}, not a person`;
|
|
552
|
+
if (!MERGE_PERMISSIONS.has(actor.permission)) {
|
|
553
|
+
return `\`${actor.login}\` has \`${actor.permission}\` permission; write, maintain, or admin is required`;
|
|
554
|
+
}
|
|
555
|
+
return actor;
|
|
556
|
+
}
|
|
557
|
+
function signOffState(pr, names) {
|
|
558
|
+
const present = names.filter((name) => pr.labels.includes(name));
|
|
559
|
+
let untrusted;
|
|
560
|
+
for (const label of present) {
|
|
561
|
+
const vetted = vet(pr.signOffs.find((signOff) => signOff.label === label)?.appliedBy);
|
|
562
|
+
if (typeof vetted !== "string") return { status: "trusted", label, appliedBy: vetted };
|
|
563
|
+
untrusted ??= { status: "untrusted", label, reason: vetted };
|
|
564
|
+
}
|
|
565
|
+
return untrusted ?? { status: "absent" };
|
|
566
|
+
}
|
|
567
|
+
|
|
522
568
|
// src/checks/ci-change/index.ts
|
|
523
569
|
var CI_CHANGE_CHECK = "ci-change";
|
|
524
570
|
function indicatorMessage(indicator) {
|
|
525
571
|
return `\`${indicator.file}\` \`${indicator.location}\` \u2014 ${indicator.kind} (${indicator.class}): ${indicator.detail}`;
|
|
526
572
|
}
|
|
527
|
-
function decideCiChange(classification,
|
|
528
|
-
const
|
|
529
|
-
const signedOff =
|
|
530
|
-
const flagged =
|
|
573
|
+
function decideCiChange(classification, pr) {
|
|
574
|
+
const approval = signOffState(pr, [CI_CHANGE_APPROVED_LABEL]);
|
|
575
|
+
const signedOff = approval.status === "trusted";
|
|
576
|
+
const flagged = pr.labels.includes(CI_APPROVAL_NEEDED_LABEL);
|
|
531
577
|
const loosening = classification.verdict === "loosening";
|
|
532
578
|
const labelsToAdd = loosening && !flagged ? [CI_APPROVAL_NEEDED_LABEL] : [];
|
|
533
579
|
const labelsToRemove = !loosening && flagged && !signedOff ? [CI_APPROVAL_NEEDED_LABEL] : [];
|
|
@@ -550,54 +596,68 @@ function decideCiChange(classification, labels) {
|
|
|
550
596
|
message: `This PR loosens CI and is waiting for a human to review the change and apply \`${CI_CHANGE_APPROVED_LABEL}\`. No code change clears this. Ambiguous indicators are treated as loosening because a miss skips the sign-off entirely.`,
|
|
551
597
|
effect: "hold"
|
|
552
598
|
};
|
|
599
|
+
const untrusted = approval.status === "untrusted" ? [
|
|
600
|
+
{
|
|
601
|
+
check: CI_CHANGE_CHECK,
|
|
602
|
+
message: `\`${approval.label}\` doesn't count: ${approval.reason}.`,
|
|
603
|
+
effect: "info"
|
|
604
|
+
}
|
|
605
|
+
] : [];
|
|
553
606
|
const evidence = classification.indicators.map((indicator) => ({
|
|
554
607
|
check: CI_CHANGE_CHECK,
|
|
555
608
|
message: indicatorMessage(indicator),
|
|
556
609
|
effect: "info"
|
|
557
610
|
}));
|
|
558
|
-
return { findings: [headline, ...evidence], labelsToAdd, labelsToRemove };
|
|
611
|
+
return { findings: [headline, ...untrusted, ...evidence], labelsToAdd, labelsToRemove };
|
|
559
612
|
}
|
|
560
613
|
var ciChangeCheck = {
|
|
561
614
|
name: CI_CHANGE_CHECK,
|
|
562
|
-
evaluate: async (pr) => decideCiChange(classifyWorkflowChanges(pr.workflowChanges), pr
|
|
615
|
+
evaluate: async (pr) => decideCiChange(classifyWorkflowChanges(pr.workflowChanges), pr)
|
|
563
616
|
};
|
|
564
617
|
|
|
565
|
-
// src/checks/
|
|
566
|
-
var
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
"
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
"
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
]
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
|
|
618
|
+
// src/checks/dependabot/claims.ts
|
|
619
|
+
var NAME = /(?:\[([^\[\]\n]+)\]\([^()\n]*\)|`([^`]+)`|([^\s[\]`]+))/.source;
|
|
620
|
+
var VERSION = /(\S+?)\.?(?=\s|$)/.source;
|
|
621
|
+
var BUMP = new RegExp(String.raw`^Bumps ${NAME} from \S+ to ${VERSION}`, "gm");
|
|
622
|
+
var UPDATE = new RegExp(String.raw`^Updates ${NAME} from \S+ to ${VERSION}`, "gm");
|
|
623
|
+
var CELL = /`[^`|]+`/.source;
|
|
624
|
+
var TABLE_ROW = new RegExp(String.raw`^\| ${NAME} \| ${CELL} \| \x60([^\x60|]+)\x60 \|`, "gm");
|
|
625
|
+
var GROUP_HEADER = /^Bumps the \S+ group\b[^:\n]*:(.*)$/gm;
|
|
626
|
+
var LINK = /\[([^\[\]\n]+)\]\([^()\n]*\)/g;
|
|
627
|
+
var REQUIREMENTS = new RegExp(String.raw`^Updates the requirements on ${NAME}`, "gm");
|
|
628
|
+
var REMOVAL = new RegExp(String.raw`^Removes ${NAME}`, "gm");
|
|
629
|
+
function clean(name) {
|
|
630
|
+
return name.replace(/\u200b/g, "").trim();
|
|
631
|
+
}
|
|
632
|
+
function nameOf(match) {
|
|
633
|
+
return clean(match[1] ?? match[2] ?? match[3] ?? "");
|
|
634
|
+
}
|
|
635
|
+
function parseClaims(body) {
|
|
636
|
+
const claims = /* @__PURE__ */ new Map();
|
|
637
|
+
for (const pattern of [TABLE_ROW, BUMP, UPDATE]) {
|
|
638
|
+
for (const match of body.matchAll(pattern)) {
|
|
639
|
+
const name = nameOf(match);
|
|
640
|
+
if (name !== "" && match[4] !== void 0) claims.set(name, { name, to: match[4] });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
for (const pattern of [REQUIREMENTS, REMOVAL]) {
|
|
644
|
+
for (const match of body.matchAll(pattern)) {
|
|
645
|
+
const name = nameOf(match);
|
|
646
|
+
if (name !== "" && !claims.has(name)) claims.set(name, { name });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
for (const header of body.matchAll(GROUP_HEADER)) {
|
|
650
|
+
for (const link of (header[1] ?? "").matchAll(LINK)) {
|
|
651
|
+
const name = clean(link[1] ?? "");
|
|
652
|
+
if (name !== "" && !claims.has(name)) claims.set(name, { name });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return [...claims.values()];
|
|
597
656
|
}
|
|
598
|
-
var
|
|
599
|
-
function
|
|
600
|
-
|
|
657
|
+
var TITLE_BUMP = /\bbump (\S+) from \S+ to \S+/i;
|
|
658
|
+
function titlePackage(title) {
|
|
659
|
+
const name = TITLE_BUMP.exec(title)?.[1];
|
|
660
|
+
return name === void 0 || name.toLowerCase() === "the" ? void 0 : name;
|
|
601
661
|
}
|
|
602
662
|
|
|
603
663
|
// src/checks/title/sensitive-bump.ts
|
|
@@ -630,9 +690,9 @@ function npmVersions(text) {
|
|
|
630
690
|
const versions = /* @__PURE__ */ new Map();
|
|
631
691
|
if (typeof parsed !== "object" || parsed === null) return versions;
|
|
632
692
|
for (const field of DEPENDENCY_FIELDS) {
|
|
633
|
-
const
|
|
634
|
-
if (typeof
|
|
635
|
-
for (const [name, spec] of Object.entries(
|
|
693
|
+
const block3 = parsed[field];
|
|
694
|
+
if (typeof block3 !== "object" || block3 === null) continue;
|
|
695
|
+
for (const [name, spec] of Object.entries(block3)) {
|
|
636
696
|
if (typeof spec === "string") versions.set(name, spec);
|
|
637
697
|
}
|
|
638
698
|
}
|
|
@@ -666,8 +726,28 @@ function sensitiveBumps(changes) {
|
|
|
666
726
|
return [...bumped];
|
|
667
727
|
}
|
|
668
728
|
|
|
669
|
-
// src/checks/
|
|
729
|
+
// src/checks/ci-change/workflow-paths.ts
|
|
730
|
+
var WORKFLOW_FILE = /^\.github\/workflows\/.+\.ya?ml$/;
|
|
731
|
+
function isWorkflowPath(path) {
|
|
732
|
+
return WORKFLOW_FILE.test(path);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/checks/dependabot/actions.ts
|
|
670
736
|
import { parse as parse2 } from "yaml";
|
|
737
|
+
function usesValues(node, into = []) {
|
|
738
|
+
const seq = asArray(node);
|
|
739
|
+
if (seq) {
|
|
740
|
+
for (const entry of seq) usesValues(entry, into);
|
|
741
|
+
return into;
|
|
742
|
+
}
|
|
743
|
+
const map = asRecord(node);
|
|
744
|
+
if (!map) return into;
|
|
745
|
+
for (const [key, value] of Object.entries(map)) {
|
|
746
|
+
if (key === "uses" && typeof value === "string") into.push(value);
|
|
747
|
+
else usesValues(value, into);
|
|
748
|
+
}
|
|
749
|
+
return into;
|
|
750
|
+
}
|
|
671
751
|
function parses(text) {
|
|
672
752
|
try {
|
|
673
753
|
return { doc: parse2(text) };
|
|
@@ -675,21 +755,440 @@ function parses(text) {
|
|
|
675
755
|
return null;
|
|
676
756
|
}
|
|
677
757
|
}
|
|
678
|
-
function
|
|
679
|
-
if (change.baseText === void 0 || change.headText === void 0) return false;
|
|
758
|
+
function diffWorkflow(change) {
|
|
680
759
|
const before = parses(change.baseText);
|
|
681
760
|
const after = parses(change.headText);
|
|
761
|
+
if (before === null || after === null) return { kind: "unparseable" };
|
|
762
|
+
if (!deepEqual(normalizeUses(before.doc), normalizeUses(after.doc))) {
|
|
763
|
+
return { kind: "structural" };
|
|
764
|
+
}
|
|
765
|
+
const was = usesValues(before.doc);
|
|
766
|
+
const now = usesValues(after.doc);
|
|
767
|
+
const actions = now.flatMap((uses, index) => uses === was[index] ? [] : [stripActionRef(uses)]);
|
|
768
|
+
return { kind: "pins", actions: [...new Set(actions)] };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// src/checks/dependabot/manifests.ts
|
|
772
|
+
function packageName(key) {
|
|
773
|
+
const leaf = key.slice(key.lastIndexOf(">") + 1);
|
|
774
|
+
const at = leaf.indexOf("@", 1);
|
|
775
|
+
return at === -1 ? leaf : leaf.slice(0, at);
|
|
776
|
+
}
|
|
777
|
+
function collect(where, block3, into) {
|
|
778
|
+
const record = asRecord(block3);
|
|
779
|
+
if (record === void 0) return;
|
|
780
|
+
const parent = where.slice(where.lastIndexOf(" ") + 1);
|
|
781
|
+
for (const [key, value] of Object.entries(record)) {
|
|
782
|
+
const at = `${where} ${key}`;
|
|
783
|
+
if (typeof value === "string") {
|
|
784
|
+
into.set(at, { name: packageName(key === "." ? parent : key), spec: value });
|
|
785
|
+
} else {
|
|
786
|
+
collect(at, value, into);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function splitNpm(text) {
|
|
791
|
+
let parsed;
|
|
792
|
+
try {
|
|
793
|
+
parsed = JSON.parse(text);
|
|
794
|
+
} catch {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
const doc = asRecord(parsed);
|
|
798
|
+
if (doc === void 0) return null;
|
|
799
|
+
const entries = /* @__PURE__ */ new Map();
|
|
800
|
+
const rest = { ...doc };
|
|
801
|
+
for (const field of [...DEPENDENCY_FIELDS, "overrides", "resolutions"]) {
|
|
802
|
+
collect(field, doc[field], entries);
|
|
803
|
+
delete rest[field];
|
|
804
|
+
}
|
|
805
|
+
const pnpm = asRecord(doc["pnpm"]);
|
|
806
|
+
if (pnpm !== void 0) {
|
|
807
|
+
collect("pnpm.overrides", pnpm["overrides"], entries);
|
|
808
|
+
const { overrides: _overrides, ...pnpmRest } = pnpm;
|
|
809
|
+
rest["pnpm"] = pnpmRest;
|
|
810
|
+
}
|
|
811
|
+
const manager = doc["packageManager"];
|
|
812
|
+
if (typeof manager === "string") {
|
|
813
|
+
const at = manager.indexOf("@", 1);
|
|
814
|
+
const name = at === -1 ? manager : manager.slice(0, at);
|
|
815
|
+
const spec = at === -1 ? "" : manager.slice(at + 1).replace(/\+.*/, "");
|
|
816
|
+
entries.set("packageManager", { name, spec });
|
|
817
|
+
delete rest["packageManager"];
|
|
818
|
+
}
|
|
819
|
+
return { entries, rest };
|
|
820
|
+
}
|
|
821
|
+
function diffEntries(before, after) {
|
|
822
|
+
const edits = [];
|
|
823
|
+
for (const key of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) {
|
|
824
|
+
const was = before.get(key);
|
|
825
|
+
const now = after.get(key);
|
|
826
|
+
if (was?.spec === now?.spec && was?.name === now?.name) continue;
|
|
827
|
+
edits.push({ name: (now ?? was)?.name ?? key, before: was?.spec, after: now?.spec });
|
|
828
|
+
}
|
|
829
|
+
return edits;
|
|
830
|
+
}
|
|
831
|
+
function diffNpm(baseText, headText) {
|
|
832
|
+
const before = splitNpm(baseText);
|
|
833
|
+
const after = splitNpm(headText);
|
|
834
|
+
if (before === null || after === null) return null;
|
|
835
|
+
const otherChanges = [.../* @__PURE__ */ new Set([...Object.keys(before.rest), ...Object.keys(after.rest)])].filter((key) => !deepEqual(before.rest[key], after.rest[key])).map((key) => `\`${key}\``);
|
|
836
|
+
return { edits: diffEntries(before.entries, after.entries), otherChanges };
|
|
837
|
+
}
|
|
838
|
+
function otherLines(text) {
|
|
839
|
+
return text.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !REQUIREMENT_LINE.test(line.replace(/#.*/, "")));
|
|
840
|
+
}
|
|
841
|
+
function diffPip(baseText, headText) {
|
|
842
|
+
const toEntries = (text) => new Map([...pipVersions(text)].map(([name, spec]) => [name, { name, spec }]));
|
|
843
|
+
const edits = diffEntries(toEntries(baseText), toEntries(headText));
|
|
844
|
+
const otherChanges = deepEqual(otherLines(baseText), otherLines(headText)) ? [] : ["lines other than pinned requirements"];
|
|
845
|
+
return { edits, otherChanges };
|
|
846
|
+
}
|
|
847
|
+
function diffManifest(change) {
|
|
848
|
+
return change.path.endsWith("package.json") ? diffNpm(change.baseText, change.headText) : diffPip(change.baseText, change.headText);
|
|
849
|
+
}
|
|
850
|
+
function manifestKey(path, name) {
|
|
851
|
+
return path.endsWith("package.json") ? name : normalize(name);
|
|
852
|
+
}
|
|
853
|
+
function specMentions(spec, version) {
|
|
854
|
+
const escaped = version.replace(/^v/, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
855
|
+
return new RegExp(`(?:^|[^\\w.])v?${escaped}(?:$|[^\\w.-])`).test(spec);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// src/checks/title/workflow-change.ts
|
|
859
|
+
import { parse as parse3 } from "yaml";
|
|
860
|
+
function parses2(text) {
|
|
861
|
+
try {
|
|
862
|
+
return { doc: parse3(text) };
|
|
863
|
+
} catch {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
function isPinBumpOnly(change) {
|
|
868
|
+
if (change.baseText === void 0 || change.headText === void 0) return false;
|
|
869
|
+
const before = parses2(change.baseText);
|
|
870
|
+
const after = parses2(change.headText);
|
|
682
871
|
if (before === null || after === null) return false;
|
|
683
872
|
return deepEqual(normalizeUses(before.doc), normalizeUses(after.doc));
|
|
684
873
|
}
|
|
874
|
+
function onlyWorkflowCall(doc) {
|
|
875
|
+
if (typeof doc !== "object" || doc === null) return false;
|
|
876
|
+
const on = doc["on"];
|
|
877
|
+
if (on === "workflow_call") return true;
|
|
878
|
+
if (Array.isArray(on)) return on.length === 1 && on[0] === "workflow_call";
|
|
879
|
+
if (typeof on === "object" && on !== null) {
|
|
880
|
+
const events = Object.keys(on);
|
|
881
|
+
return events.length === 1 && events[0] === "workflow_call";
|
|
882
|
+
}
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
function isShippedReusableWorkflow(change) {
|
|
886
|
+
const text = change.headText ?? change.baseText;
|
|
887
|
+
if (text === void 0) return false;
|
|
888
|
+
const parsed = parses2(text);
|
|
889
|
+
return parsed !== null && onlyWorkflowCall(parsed.doc);
|
|
890
|
+
}
|
|
685
891
|
function substantiveWorkflowChanges(changes) {
|
|
686
|
-
return changes.filter((change) => !isPinBumpOnly(change)).map((change) => change.path);
|
|
892
|
+
return changes.filter((change) => !isShippedReusableWorkflow(change) && !isPinBumpOnly(change)).map((change) => change.path);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// src/checks/uat/trivial.ts
|
|
896
|
+
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
|
+
var LOCKFILES = /* @__PURE__ */ new Set([
|
|
900
|
+
"pnpm-lock.yaml",
|
|
901
|
+
"package-lock.json",
|
|
902
|
+
"npm-shrinkwrap.json",
|
|
903
|
+
"yarn.lock",
|
|
904
|
+
"poetry.lock",
|
|
905
|
+
"uv.lock",
|
|
906
|
+
"Pipfile.lock",
|
|
907
|
+
"Cargo.lock",
|
|
908
|
+
"go.sum"
|
|
909
|
+
]);
|
|
910
|
+
var ROOT_METADATA = /^(LICEN[CS]E|COPYING)(\..+)?$|^CODEOWNERS$|^\.(gitignore|gitattributes|editorconfig)$/;
|
|
911
|
+
function isDependencyOnlyManifest(change) {
|
|
912
|
+
if (change?.baseText === void 0 || change.headText === void 0) return false;
|
|
913
|
+
const strip = (text) => {
|
|
914
|
+
let parsed;
|
|
915
|
+
try {
|
|
916
|
+
parsed = JSON.parse(text);
|
|
917
|
+
} catch {
|
|
918
|
+
return void 0;
|
|
919
|
+
}
|
|
920
|
+
const record = asRecord(parsed);
|
|
921
|
+
if (record === void 0) return void 0;
|
|
922
|
+
const rest = { ...record };
|
|
923
|
+
for (const field of [...DEPENDENCY_FIELDS, "packageManager"]) delete rest[field];
|
|
924
|
+
return rest;
|
|
925
|
+
};
|
|
926
|
+
const before = strip(change.baseText);
|
|
927
|
+
const after = strip(change.headText);
|
|
928
|
+
return before !== void 0 && after !== void 0 && deepEqual(before, after);
|
|
929
|
+
}
|
|
930
|
+
function categoryOf(path, pr) {
|
|
931
|
+
const parts = path.split("/");
|
|
932
|
+
const name = parts[parts.length - 1] ?? "";
|
|
933
|
+
const dirs = parts.slice(0, -1);
|
|
934
|
+
if (name.endsWith(".md") || parts[0] === "docs") return "docs";
|
|
935
|
+
if (dirs.some((dir) => TEST_DIRS.has(dir)) || TEST_FILE.test(name)) return "tests";
|
|
936
|
+
if (path.startsWith(".github/workflows/") || path.startsWith(".github/actions/")) {
|
|
937
|
+
const change = pr.workflowChanges.find((file) => file.path === path);
|
|
938
|
+
return change !== void 0 && isShippedReusableWorkflow(change) ? void 0 : "ci";
|
|
939
|
+
}
|
|
940
|
+
if (LOCKFILES.has(name)) return "dependencies";
|
|
941
|
+
if (isManifestPath(path)) {
|
|
942
|
+
if (name !== "package.json") return "dependencies";
|
|
943
|
+
const change = pr.manifestChanges.find((file) => file.path === path);
|
|
944
|
+
return isDependencyOnlyManifest(change) ? "dependencies" : void 0;
|
|
945
|
+
}
|
|
946
|
+
if (parts[0] === ".github" || parts[0] === ".vscode") return "metadata";
|
|
947
|
+
if (parts.length === 1 && ROOT_METADATA.test(name)) return "metadata";
|
|
948
|
+
return void 0;
|
|
949
|
+
}
|
|
950
|
+
function trivialCategories(pr) {
|
|
951
|
+
const found = /* @__PURE__ */ new Set();
|
|
952
|
+
for (const path of pr.changedFiles) {
|
|
953
|
+
const category = categoryOf(path, pr);
|
|
954
|
+
if (category === void 0) return void 0;
|
|
955
|
+
found.add(category);
|
|
956
|
+
}
|
|
957
|
+
return TRIVIAL_CATEGORIES.filter((category) => found.has(category));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// src/checks/dependabot/paths.ts
|
|
961
|
+
var OTHER_ECOSYSTEM_FILES = /* @__PURE__ */ new Set([
|
|
962
|
+
"go.mod",
|
|
963
|
+
"Cargo.toml",
|
|
964
|
+
"pyproject.toml",
|
|
965
|
+
"Pipfile",
|
|
966
|
+
"setup.cfg",
|
|
967
|
+
"setup.py",
|
|
968
|
+
"Gemfile",
|
|
969
|
+
"Gemfile.lock",
|
|
970
|
+
"composer.json",
|
|
971
|
+
"composer.lock",
|
|
972
|
+
".terraform.lock.hcl",
|
|
973
|
+
"action.yml",
|
|
974
|
+
"action.yaml"
|
|
975
|
+
]);
|
|
976
|
+
var DOCKERFILE = /^(Dockerfile|Containerfile)([.-].+)?$|\.Dockerfile$|^docker-compose.*\.ya?ml$/;
|
|
977
|
+
function dependencyPathKind(path) {
|
|
978
|
+
if (isWorkflowPath(path)) return "workflow";
|
|
979
|
+
if (isManifestPath(path)) return "manifest";
|
|
980
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
981
|
+
if (LOCKFILES.has(name)) return "lockfile";
|
|
982
|
+
if (OTHER_ECOSYSTEM_FILES.has(name) || DOCKERFILE.test(name)) return "other-ecosystem";
|
|
983
|
+
return void 0;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// src/checks/dependabot/index.ts
|
|
987
|
+
var DEPENDABOT_CHECK = "dependabot";
|
|
988
|
+
var DEPENDABOT_LOGIN = "dependabot[bot]";
|
|
989
|
+
var RECREATE = "Drop the change, or comment `@dependabot recreate` to rebuild the PR.";
|
|
990
|
+
function block(message) {
|
|
991
|
+
return { check: DEPENDABOT_CHECK, message: `${message} ${RECREATE}`, effect: "block" };
|
|
992
|
+
}
|
|
993
|
+
function info(message) {
|
|
994
|
+
return { check: DEPENDABOT_CHECK, message, effect: "info" };
|
|
995
|
+
}
|
|
996
|
+
function indexClaims(claims, key) {
|
|
997
|
+
return new Map(claims.map((claim) => [key(claim.name), claim]));
|
|
998
|
+
}
|
|
999
|
+
function isComplete(change) {
|
|
1000
|
+
return change.baseText !== void 0 && change.headText !== void 0;
|
|
1001
|
+
}
|
|
1002
|
+
function manifestProblems(change, claims) {
|
|
1003
|
+
if (!isComplete(change)) {
|
|
1004
|
+
return [`\`${change.path}\` was added or deleted; Dependabot only edits existing manifests.`];
|
|
1005
|
+
}
|
|
1006
|
+
const diff = diffManifest(change);
|
|
1007
|
+
if (diff === null) return [`\`${change.path}\` doesn't parse, so its changes can't be verified.`];
|
|
1008
|
+
const claimed = indexClaims(claims, (name) => manifestKey(change.path, name));
|
|
1009
|
+
const problems = diff.otherChanges.map(
|
|
1010
|
+
(field) => `\`${change.path}\` changes ${field}, which Dependabot never edits.`
|
|
1011
|
+
);
|
|
1012
|
+
for (const edit of diff.edits) {
|
|
1013
|
+
const claim = claimed.get(manifestKey(change.path, edit.name));
|
|
1014
|
+
const specs = `\`${edit.before ?? "(none)"}\` \u2192 \`${edit.after ?? "(none)"}\``;
|
|
1015
|
+
if (claim === void 0) {
|
|
1016
|
+
problems.push(
|
|
1017
|
+
`\`${change.path}\` changes \`${edit.name}\` (${specs}), which the description doesn't claim.`
|
|
1018
|
+
);
|
|
1019
|
+
} else if (claim.to !== void 0 && edit.after !== void 0 && !specMentions(edit.after, claim.to)) {
|
|
1020
|
+
problems.push(
|
|
1021
|
+
`\`${change.path}\` sets \`${edit.name}\` to \`${edit.after}\`, but the description claims ${claim.to}.`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return problems;
|
|
1026
|
+
}
|
|
1027
|
+
function workflowProblems(change, claims) {
|
|
1028
|
+
if (!isComplete(change)) {
|
|
1029
|
+
return [`\`${change.path}\` was added or deleted; Dependabot only edits existing workflows.`];
|
|
1030
|
+
}
|
|
1031
|
+
const diff = diffWorkflow(change);
|
|
1032
|
+
if (diff.kind === "unparseable") {
|
|
1033
|
+
return [`\`${change.path}\` doesn't parse, so its changes can't be verified.`];
|
|
1034
|
+
}
|
|
1035
|
+
if (diff.kind === "structural") {
|
|
1036
|
+
return [
|
|
1037
|
+
`\`${change.path}\` changes more than action refs; Dependabot only moves \`uses:\` pins.`
|
|
1038
|
+
];
|
|
1039
|
+
}
|
|
1040
|
+
const claimed = indexClaims(claims, (name) => name.toLowerCase());
|
|
1041
|
+
return diff.actions.filter((action) => !claimed.has(action.toLowerCase())).map(
|
|
1042
|
+
(action) => `\`${change.path}\` moves \`${action}\`, which the description doesn't claim.`
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
function decideDependabot(pr) {
|
|
1046
|
+
if (pr.author?.login !== DEPENDABOT_LOGIN) return { findings: [] };
|
|
1047
|
+
const claims = parseClaims(pr.body ?? "");
|
|
1048
|
+
if (claims.length === 0) {
|
|
1049
|
+
return {
|
|
1050
|
+
findings: [
|
|
1051
|
+
block(
|
|
1052
|
+
"The description names no update (`Bumps \u2026` / ``Updates `\u2026` from \u2026 to \u2026``), so the PR's content can't be checked against it. It was edited or truncated."
|
|
1053
|
+
)
|
|
1054
|
+
]
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
const problems = [];
|
|
1058
|
+
const named = titlePackage(pr.title);
|
|
1059
|
+
const sameName = (claim) => claim.name.toLowerCase() === named?.toLowerCase();
|
|
1060
|
+
if (named !== void 0 && !claims.some(sameName)) {
|
|
1061
|
+
problems.push(`The title bumps \`${named}\`, but the description doesn't claim it.`);
|
|
1062
|
+
}
|
|
1063
|
+
const unverified = [];
|
|
1064
|
+
for (const path of new Set(pr.changedFiles)) {
|
|
1065
|
+
const kind = dependencyPathKind(path);
|
|
1066
|
+
if (kind === void 0) {
|
|
1067
|
+
problems.push(`\`${path}\` isn't a dependency file; Dependabot never edits it.`);
|
|
1068
|
+
} else if (kind === "lockfile" || kind === "other-ecosystem") {
|
|
1069
|
+
unverified.push(`\`${path}\``);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
for (const change of pr.manifestChanges) problems.push(...manifestProblems(change, claims));
|
|
1073
|
+
for (const change of pr.workflowChanges) problems.push(...workflowProblems(change, claims));
|
|
1074
|
+
const findings = problems.map(block);
|
|
1075
|
+
if (unverified.length > 0) {
|
|
1076
|
+
findings.push(info(`Allowed but not content-verified: ${unverified.join(", ")}.`));
|
|
1077
|
+
}
|
|
1078
|
+
if (problems.length === 0) {
|
|
1079
|
+
const count = `${claims.length} claimed update${claims.length === 1 ? "" : "s"}`;
|
|
1080
|
+
findings.unshift({ ...info(`Dependabot PR matches its ${count}`), headline: true });
|
|
1081
|
+
}
|
|
1082
|
+
return { findings };
|
|
1083
|
+
}
|
|
1084
|
+
var dependabotCheck = {
|
|
1085
|
+
name: DEPENDABOT_CHECK,
|
|
1086
|
+
evaluate: async (pr) => decideDependabot(pr)
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
// src/checks/merge-block.ts
|
|
1090
|
+
var MERGE_BLOCK_CHECK = "merge-block";
|
|
1091
|
+
function normalizeLabel(label) {
|
|
1092
|
+
return label.toLowerCase().replace(/[-_\s]+/g, " ").trim();
|
|
1093
|
+
}
|
|
1094
|
+
var BLOCKING = new Set(BLOCKING_LABELS.map(normalizeLabel));
|
|
1095
|
+
function decideMergeBlock(pr) {
|
|
1096
|
+
const present = pr.labels.filter((label) => BLOCKING.has(normalizeLabel(label)));
|
|
1097
|
+
if (present.length === 0) return { findings: [] };
|
|
1098
|
+
const names = present.map((label) => `\`${label}\``).join(", ");
|
|
1099
|
+
return {
|
|
1100
|
+
findings: [
|
|
1101
|
+
{
|
|
1102
|
+
check: MERGE_BLOCK_CHECK,
|
|
1103
|
+
message: `Held by ${names}. The PR can merge once ${present.length === 1 ? "the label is" : "the labels are"} removed.`,
|
|
1104
|
+
effect: "hold"
|
|
1105
|
+
}
|
|
1106
|
+
]
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
var mergeBlockCheck = {
|
|
1110
|
+
name: MERGE_BLOCK_CHECK,
|
|
1111
|
+
evaluate: async (pr) => decideMergeBlock(pr)
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
// src/checks/title/conventional.ts
|
|
1115
|
+
var COMMIT_TYPES = [
|
|
1116
|
+
"feat",
|
|
1117
|
+
"fix",
|
|
1118
|
+
"docs",
|
|
1119
|
+
"chore",
|
|
1120
|
+
"refactor",
|
|
1121
|
+
"test",
|
|
1122
|
+
"style",
|
|
1123
|
+
"perf",
|
|
1124
|
+
"ci",
|
|
1125
|
+
"build",
|
|
1126
|
+
"revert"
|
|
1127
|
+
];
|
|
1128
|
+
var FUNCTIONAL_TYPES = [
|
|
1129
|
+
"feat",
|
|
1130
|
+
"fix",
|
|
1131
|
+
"perf",
|
|
1132
|
+
"revert"
|
|
1133
|
+
];
|
|
1134
|
+
var TITLE = /^([a-z]+)(?:\(([^)]+)\))?(!)?: (\S.*)$/;
|
|
1135
|
+
function isCommitType(value) {
|
|
1136
|
+
return COMMIT_TYPES.includes(value);
|
|
1137
|
+
}
|
|
1138
|
+
function parseTitle(title) {
|
|
1139
|
+
const match = TITLE.exec(title);
|
|
1140
|
+
const [, type, scope, bang, subject] = match ?? [];
|
|
1141
|
+
if (type === void 0 || subject === void 0 || !isCommitType(type)) return null;
|
|
1142
|
+
return { type, scope, breaking: bang === "!", subject };
|
|
1143
|
+
}
|
|
1144
|
+
function isFunctionalType(type) {
|
|
1145
|
+
return FUNCTIONAL_TYPES.includes(type);
|
|
1146
|
+
}
|
|
1147
|
+
var RELEASE_PLEASE_TITLE = /^chore(\([^)]*\))?!?:\s+release\b/;
|
|
1148
|
+
function isReleasePleaseTitle(title) {
|
|
1149
|
+
return RELEASE_PLEASE_TITLE.test(title);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// 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
|
+
function isMarkdownPath(path) {
|
|
1156
|
+
return path.toLowerCase().endsWith(".md");
|
|
1157
|
+
}
|
|
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
|
+
var quote = (paths) => {
|
|
1164
|
+
const shown = paths.slice(0, 5).map((path) => `\`${path}\``);
|
|
1165
|
+
if (paths.length > 5) shown.push(`and ${paths.length - 5} more`);
|
|
1166
|
+
return shown.join(", ");
|
|
1167
|
+
};
|
|
1168
|
+
function typePathViolation(type, changedFiles) {
|
|
1169
|
+
const paths = [...new Set(changedFiles)];
|
|
1170
|
+
if (type === "docs") {
|
|
1171
|
+
const offending = paths.filter((path) => !isMarkdownPath(path));
|
|
1172
|
+
if (offending.length === 0) return void 0;
|
|
1173
|
+
return `A \`docs\` PR may change only Markdown files, but this one changes ${quote(offending)}. \`docs\` doesn't release, so that change would never ship. Retitle to the type of the non-docs change, or split it into its own PR.`;
|
|
1174
|
+
}
|
|
1175
|
+
if (type === "refactor") {
|
|
1176
|
+
const offending = paths.filter((path) => isTestPath(path) && !isMarkdownPath(path));
|
|
1177
|
+
if (offending.length === 0) return void 0;
|
|
1178
|
+
return `A \`refactor\` PR must leave the tests untouched, so they can confirm behaviour is unchanged, but this one changes ${quote(offending)}. Land the test changes first as a \`test:\` PR, then refactor against them.`;
|
|
1179
|
+
}
|
|
1180
|
+
if (type === "test") {
|
|
1181
|
+
const offending = paths.filter((path) => !isTestPath(path) && !isMarkdownPath(path));
|
|
1182
|
+
if (offending.length === 0) return void 0;
|
|
1183
|
+
return `A \`test\` PR may change only test files and docs, so it locks in current behaviour without changing it, but this one changes ${quote(offending)}. \`test\` doesn't release, so that change would never ship. Retitle to the type of the non-test change, or split it into its own PR.`;
|
|
1184
|
+
}
|
|
1185
|
+
return void 0;
|
|
687
1186
|
}
|
|
688
1187
|
|
|
689
1188
|
// src/checks/title/index.ts
|
|
690
1189
|
var TITLE_CHECK = "title";
|
|
691
1190
|
var FUNCTIONAL = FUNCTIONAL_TYPES.map((type) => `\`${type}\``).join("/");
|
|
692
|
-
function
|
|
1191
|
+
function block2(message) {
|
|
693
1192
|
return { check: TITLE_CHECK, message, effect: "block" };
|
|
694
1193
|
}
|
|
695
1194
|
function decideTitle(pr) {
|
|
@@ -701,7 +1200,7 @@ function decideTitle(pr) {
|
|
|
701
1200
|
if (title === null) {
|
|
702
1201
|
return {
|
|
703
1202
|
findings: [
|
|
704
|
-
|
|
1203
|
+
block2(
|
|
705
1204
|
`\`${pr.title}\` is not a Conventional Commit. Expected \`<type>[(<scope>)][!]: <subject>\` with a type of ${COMMIT_TYPES.join(", ")}. Edit the title; it becomes the squash commit on \`main\`.`
|
|
706
1205
|
)
|
|
707
1206
|
]
|
|
@@ -714,44 +1213,46 @@ function decideTitle(pr) {
|
|
|
714
1213
|
if (!functional) {
|
|
715
1214
|
if (title.breaking) {
|
|
716
1215
|
findings.push(
|
|
717
|
-
|
|
1216
|
+
block2(
|
|
718
1217
|
`\`!\` is only allowed on ${FUNCTIONAL}; on \`${title.type}\` it would cut a spurious major release. Drop the \`!\`.`
|
|
719
1218
|
)
|
|
720
1219
|
);
|
|
721
1220
|
}
|
|
722
1221
|
if (breakingLabel) {
|
|
723
1222
|
findings.push(
|
|
724
|
-
|
|
725
|
-
`\`${BREAKING_CHANGE_LABEL}\` is only meaningful on ${FUNCTIONAL}. Remove the label, or retitle to the functional type if this really is a breaking code change. (A
|
|
1223
|
+
block2(
|
|
1224
|
+
`\`${BREAKING_CHANGE_LABEL}\` is only meaningful on ${FUNCTIONAL}. Remove the label, or retitle to the functional type if this really is a breaking code change. (A CI change doesn't need it: sibling rebases key off the changed paths.)`
|
|
726
1225
|
)
|
|
727
1226
|
);
|
|
728
1227
|
}
|
|
729
1228
|
} else if (breakingIntent && !title.breaking) {
|
|
730
1229
|
findings.push(
|
|
731
|
-
|
|
1230
|
+
block2(
|
|
732
1231
|
`The PR is labelled breaking but the title has no \`!\`, so the release would miss the major. Retitle to \`${title.type}${title.scope ? `(${title.scope})` : ""}!: ${title.subject}\`.`
|
|
733
1232
|
)
|
|
734
1233
|
);
|
|
735
1234
|
} else if (!breakingIntent && title.breaking) {
|
|
736
1235
|
findings.push(
|
|
737
|
-
|
|
1236
|
+
block2(
|
|
738
1237
|
`The title marks a breaking change but the PR lacks the \`${BREAKING_CHANGE_LABEL}\` label, which is the source of truth. Add the label, or drop the \`!\`.`
|
|
739
1238
|
)
|
|
740
1239
|
);
|
|
741
1240
|
}
|
|
1241
|
+
const pathViolation = typePathViolation(title.type, pr.changedFiles);
|
|
1242
|
+
if (pathViolation !== void 0) findings.push(block2(pathViolation));
|
|
1243
|
+
const mayRetitle = !functional && title.type !== "ci";
|
|
742
1244
|
const workflows = substantiveWorkflowChanges(pr.workflowChanges);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
);
|
|
1245
|
+
if (workflows.length > 0 && mayRetitle) {
|
|
1246
|
+
findings.push({
|
|
1247
|
+
check: TITLE_CHECK,
|
|
1248
|
+
effect: "info",
|
|
1249
|
+
message: `This PR changes this repo's own CI (${workflows.map((path) => `\`${path}\``).join(", ")}); consider titling it \`ci(<scope>): \u2026\`.`
|
|
1250
|
+
});
|
|
750
1251
|
}
|
|
751
1252
|
const bumps = sensitiveBumps(pr.manifestChanges);
|
|
752
|
-
if (bumps.length > 0 &&
|
|
1253
|
+
if (bumps.length > 0 && mayRetitle) {
|
|
753
1254
|
findings.push(
|
|
754
|
-
|
|
1255
|
+
block2(
|
|
755
1256
|
`This PR changes the version of ${bumps.map((name) => `\`${name}\``).join(", ")}, which can change lint/format results on every in-flight PR. Retitle to \`ci(deps): \u2026\` so siblings re-test; do not use \`!\` or \`${BREAKING_CHANGE_LABEL}\` for it.`
|
|
756
1257
|
)
|
|
757
1258
|
);
|
|
@@ -763,13 +1264,52 @@ var titleCheck = {
|
|
|
763
1264
|
evaluate: async (pr) => decideTitle(pr)
|
|
764
1265
|
};
|
|
765
1266
|
|
|
766
|
-
// src/checks/index.ts
|
|
767
|
-
var
|
|
1267
|
+
// src/checks/uat/index.ts
|
|
1268
|
+
var UAT_CHECK = "uat";
|
|
1269
|
+
var PASSING_LABELS = [UAT_PASSED_LABEL, LEGACY_UAT_PASSED_LABEL, NO_UAT_NEEDED_LABEL];
|
|
1270
|
+
function info2(message) {
|
|
1271
|
+
return { check: UAT_CHECK, message, effect: "info" };
|
|
1272
|
+
}
|
|
1273
|
+
function decideUat(pr) {
|
|
1274
|
+
const categories = trivialCategories(pr);
|
|
1275
|
+
if (categories !== void 0) {
|
|
1276
|
+
const kinds = categories.length > 0 ? categories.join(", ") : "no files";
|
|
1277
|
+
return { findings: [info2(`No UAT needed: the PR changes only ${kinds}.`)] };
|
|
1278
|
+
}
|
|
1279
|
+
const state = signOffState(pr, PASSING_LABELS);
|
|
1280
|
+
if (state.status === "trusted") {
|
|
1281
|
+
return {
|
|
1282
|
+
findings: [
|
|
1283
|
+
{
|
|
1284
|
+
...info2(`UAT signed off with \`${state.label}\` by \`${state.appliedBy.login}\``),
|
|
1285
|
+
headline: true
|
|
1286
|
+
}
|
|
1287
|
+
]
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
const hold = {
|
|
1291
|
+
check: UAT_CHECK,
|
|
1292
|
+
message: `Waiting for UAT. A person applies \`${UAT_PASSED_LABEL}\` after testing, or the review agent or a person applies \`${NO_UAT_NEEDED_LABEL}\`.`,
|
|
1293
|
+
effect: "hold"
|
|
1294
|
+
};
|
|
1295
|
+
const why = state.status === "untrusted" ? [info2(`\`${state.label}\` doesn't count: ${state.reason}.`)] : [];
|
|
1296
|
+
return { findings: [hold, ...why] };
|
|
1297
|
+
}
|
|
1298
|
+
var uatCheck = {
|
|
1299
|
+
name: UAT_CHECK,
|
|
1300
|
+
evaluate: async (pr) => decideUat(pr)
|
|
1301
|
+
};
|
|
768
1302
|
|
|
769
|
-
// src/checks/
|
|
770
|
-
var
|
|
771
|
-
|
|
772
|
-
|
|
1303
|
+
// src/checks/index.ts
|
|
1304
|
+
var CHECKS = [
|
|
1305
|
+
mergeBlockCheck,
|
|
1306
|
+
titleCheck,
|
|
1307
|
+
ciChangeCheck,
|
|
1308
|
+
uatCheck,
|
|
1309
|
+
dependabotCheck
|
|
1310
|
+
];
|
|
1311
|
+
function selectChecks(options = {}) {
|
|
1312
|
+
return options.skipUat ? CHECKS.filter((check) => check !== uatCheck) : CHECKS;
|
|
773
1313
|
}
|
|
774
1314
|
|
|
775
1315
|
// src/report.ts
|
|
@@ -830,22 +1370,62 @@ function parseFileChanges(field, value) {
|
|
|
830
1370
|
if (!Array.isArray(value)) throw new Error(`facts.${field} must be an array`);
|
|
831
1371
|
return value.map((item, index) => parseFileChange(field, item, index));
|
|
832
1372
|
}
|
|
1373
|
+
function parseActor(where, value) {
|
|
1374
|
+
if (value === void 0) return void 0;
|
|
1375
|
+
if (typeof value !== "object" || value === null) throw new Error(`${where} must be an object`);
|
|
1376
|
+
const { login, type, permission } = value;
|
|
1377
|
+
if (typeof login !== "string" || typeof type !== "string") {
|
|
1378
|
+
throw new Error(`${where}.login / type must be strings`);
|
|
1379
|
+
}
|
|
1380
|
+
if (!isRepoPermission(permission)) {
|
|
1381
|
+
throw new Error(`${where}.permission must be one of ${REPO_PERMISSIONS.join(", ")}`);
|
|
1382
|
+
}
|
|
1383
|
+
return { login, type, permission };
|
|
1384
|
+
}
|
|
1385
|
+
function parseSignOffs(value) {
|
|
1386
|
+
if (value === void 0) return [];
|
|
1387
|
+
if (!Array.isArray(value)) throw new Error("facts.signOffs must be an array");
|
|
1388
|
+
return value.map((item, index) => {
|
|
1389
|
+
const where = `facts.signOffs[${index}]`;
|
|
1390
|
+
if (typeof item !== "object" || item === null) throw new Error(`${where} must be an object`);
|
|
1391
|
+
const { label, appliedBy } = item;
|
|
1392
|
+
if (typeof label !== "string") throw new Error(`${where}.label must be a string`);
|
|
1393
|
+
const actor = parseActor(`${where}.appliedBy`, appliedBy);
|
|
1394
|
+
return actor === void 0 ? { label } : { label, appliedBy: actor };
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
function parseAuthor(value) {
|
|
1398
|
+
if (value === void 0) return void 0;
|
|
1399
|
+
if (typeof value !== "object" || value === null)
|
|
1400
|
+
throw new Error("facts.author must be an object");
|
|
1401
|
+
const { login, type } = value;
|
|
1402
|
+
if (typeof login !== "string" || typeof type !== "string") {
|
|
1403
|
+
throw new Error("facts.author.login / type must be strings");
|
|
1404
|
+
}
|
|
1405
|
+
return { login, type };
|
|
1406
|
+
}
|
|
833
1407
|
function parseFacts(raw) {
|
|
834
1408
|
const parsed = JSON.parse(raw);
|
|
835
1409
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
836
1410
|
throw new Error("facts must be a JSON object");
|
|
837
1411
|
}
|
|
838
|
-
const { title, labels, changedFiles, workflowChanges, manifestChanges } = parsed;
|
|
1412
|
+
const { title, body, author, labels, changedFiles, workflowChanges, manifestChanges, signOffs } = parsed;
|
|
839
1413
|
if (typeof title !== "string") throw new Error("facts.title must be a string");
|
|
1414
|
+
if (!isOptionalString(body)) throw new Error("facts.body must be a string");
|
|
840
1415
|
if (!isStringArray(labels)) throw new Error("facts.labels must be a string array");
|
|
841
1416
|
if (!isStringArray(changedFiles)) throw new Error("facts.changedFiles must be a string array");
|
|
842
|
-
|
|
1417
|
+
const pr = {
|
|
843
1418
|
title,
|
|
844
1419
|
labels,
|
|
845
1420
|
changedFiles,
|
|
846
1421
|
workflowChanges: parseFileChanges("workflowChanges", workflowChanges),
|
|
847
|
-
manifestChanges: parseFileChanges("manifestChanges", manifestChanges)
|
|
1422
|
+
manifestChanges: parseFileChanges("manifestChanges", manifestChanges),
|
|
1423
|
+
signOffs: parseSignOffs(signOffs)
|
|
848
1424
|
};
|
|
1425
|
+
const parsedAuthor = parseAuthor(author);
|
|
1426
|
+
if (body !== void 0) pr.body = body;
|
|
1427
|
+
if (parsedAuthor !== void 0) pr.author = parsedAuthor;
|
|
1428
|
+
return pr;
|
|
849
1429
|
}
|
|
850
1430
|
|
|
851
1431
|
// src/lib/bounded-subprocess.ts
|
|
@@ -1025,6 +1605,90 @@ async function postCheckRun(target, headSha, evaluation) {
|
|
|
1025
1605
|
const out = await ghCall({ argv, stdin: JSON.stringify(payload) }, null, { cwd: target.cwd });
|
|
1026
1606
|
if (out === null) throw new Error(`could not post the ${PR_POLICY_CHECK_NAME} check-run`);
|
|
1027
1607
|
}
|
|
1608
|
+
var STATUS_DESCRIPTION_MAX = 140;
|
|
1609
|
+
function statusBody(evaluation, runUrl) {
|
|
1610
|
+
return {
|
|
1611
|
+
state: evaluation.outcome,
|
|
1612
|
+
context: PR_POLICY_CHECK_NAME,
|
|
1613
|
+
description: evaluation.title.slice(0, STATUS_DESCRIPTION_MAX),
|
|
1614
|
+
...runUrl ? { target_url: runUrl } : {}
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
async function postCommitStatus(target, headSha, evaluation) {
|
|
1618
|
+
const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env;
|
|
1619
|
+
const runUrl = GITHUB_SERVER_URL && GITHUB_REPOSITORY && GITHUB_RUN_ID ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` : void 0;
|
|
1620
|
+
const out = await ghCall(
|
|
1621
|
+
{
|
|
1622
|
+
argv: ["gh", "api", "-X", "POST", `repos/${target.repo}/statuses/${headSha}`, "--input", "-"],
|
|
1623
|
+
stdin: JSON.stringify(statusBody(evaluation, runUrl))
|
|
1624
|
+
},
|
|
1625
|
+
null,
|
|
1626
|
+
{ cwd: target.cwd }
|
|
1627
|
+
);
|
|
1628
|
+
if (out === null) {
|
|
1629
|
+
console.warn(
|
|
1630
|
+
`::warning::could not set the ${PR_POLICY_CHECK_NAME} commit status on ${headSha} \u2014 does the caller workflow grant \`statuses: write\`? (rmartz/pr-policy#24)`
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
async function postVerdict(target, headSha, evaluation) {
|
|
1635
|
+
await postCheckRun(target, headSha, evaluation);
|
|
1636
|
+
await postCommitStatus(target, headSha, evaluation);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// src/github/sign-offs.ts
|
|
1640
|
+
async function listLabelEvents(target) {
|
|
1641
|
+
const out = await ghCall(
|
|
1642
|
+
{
|
|
1643
|
+
argv: [
|
|
1644
|
+
"gh",
|
|
1645
|
+
"api",
|
|
1646
|
+
"--paginate",
|
|
1647
|
+
`repos/${target.repo}/issues/${target.pr}/events`,
|
|
1648
|
+
"--jq",
|
|
1649
|
+
'.[] | select(.event == "labeled") | {label: .label.name, login: .actor.login, type: .actor.type}'
|
|
1650
|
+
]
|
|
1651
|
+
},
|
|
1652
|
+
null,
|
|
1653
|
+
{ cwd: target.cwd }
|
|
1654
|
+
);
|
|
1655
|
+
if (out === null) throw new Error(`could not list label events of ${target.repo}#${target.pr}`);
|
|
1656
|
+
return out.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
|
|
1657
|
+
}
|
|
1658
|
+
async function lookupPermission(target, login) {
|
|
1659
|
+
const out = await ghCall(
|
|
1660
|
+
{
|
|
1661
|
+
argv: [
|
|
1662
|
+
"gh",
|
|
1663
|
+
"api",
|
|
1664
|
+
`repos/${target.repo}/collaborators/${encodeURIComponent(login)}/permission`,
|
|
1665
|
+
"--jq",
|
|
1666
|
+
"{permission, role_name}"
|
|
1667
|
+
]
|
|
1668
|
+
},
|
|
1669
|
+
null,
|
|
1670
|
+
{ cwd: target.cwd }
|
|
1671
|
+
);
|
|
1672
|
+
if (out === null) return "none";
|
|
1673
|
+
const { permission, role_name: roleName } = JSON.parse(out);
|
|
1674
|
+
if (isRepoPermission(roleName)) return roleName;
|
|
1675
|
+
return isRepoPermission(permission) ? permission : "none";
|
|
1676
|
+
}
|
|
1677
|
+
async function gatherSignOffs(target, labels) {
|
|
1678
|
+
const present = SIGN_OFF_LABELS.filter((label) => labels.includes(label));
|
|
1679
|
+
if (present.length === 0) return [];
|
|
1680
|
+
const latest = /* @__PURE__ */ new Map();
|
|
1681
|
+
for (const event of await listLabelEvents(target)) latest.set(event.label, event);
|
|
1682
|
+
return Promise.all(
|
|
1683
|
+
present.map(async (label) => {
|
|
1684
|
+
const event = latest.get(label);
|
|
1685
|
+
if (event === void 0 || event.login === null) return { label };
|
|
1686
|
+
const type = event.type ?? "unknown actor";
|
|
1687
|
+
const permission = type === "User" ? await lookupPermission(target, event.login) : "none";
|
|
1688
|
+
return { label, appliedBy: { login: event.login, type, permission } };
|
|
1689
|
+
})
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1028
1692
|
|
|
1029
1693
|
// src/github/pull-request.ts
|
|
1030
1694
|
async function ghJson(target, argv) {
|
|
@@ -1095,12 +1759,19 @@ async function gatherFacts(target) {
|
|
|
1095
1759
|
workflowChanges = await Promise.all(workflowFiles.map(read));
|
|
1096
1760
|
manifestChanges = await Promise.all(manifestFiles.map(read));
|
|
1097
1761
|
}
|
|
1762
|
+
const labels = view.labels.map((label) => label.name);
|
|
1098
1763
|
const facts = {
|
|
1099
1764
|
title: view.title,
|
|
1100
|
-
|
|
1101
|
-
|
|
1765
|
+
body: view.body ?? "",
|
|
1766
|
+
author: { login: view.user.login, type: view.user.type },
|
|
1767
|
+
labels,
|
|
1768
|
+
// A rename's old path counts too: moving code into docs/ is not docs-only.
|
|
1769
|
+
changedFiles: files.flatMap(
|
|
1770
|
+
(file) => file.previous_filename === void 0 || file.previous_filename === null ? [file.filename] : [file.previous_filename, file.filename]
|
|
1771
|
+
),
|
|
1102
1772
|
workflowChanges,
|
|
1103
|
-
manifestChanges
|
|
1773
|
+
manifestChanges,
|
|
1774
|
+
signOffs: await gatherSignOffs(target, labels)
|
|
1104
1775
|
};
|
|
1105
1776
|
return { facts, headSha };
|
|
1106
1777
|
}
|
|
@@ -1117,6 +1788,11 @@ export {
|
|
|
1117
1788
|
PR_POLICY_CHECK_NAME,
|
|
1118
1789
|
CI_APPROVAL_NEEDED_LABEL,
|
|
1119
1790
|
CI_CHANGE_APPROVED_LABEL,
|
|
1791
|
+
DO_NOT_MERGE_LABEL,
|
|
1792
|
+
DNM_LABEL,
|
|
1793
|
+
BLOCKED_LABEL,
|
|
1794
|
+
ESCALATION_NEEDED_LABEL,
|
|
1795
|
+
BLOCKING_LABELS,
|
|
1120
1796
|
BREAKING_CHANGE_LABEL,
|
|
1121
1797
|
HOTFIX_LABEL,
|
|
1122
1798
|
RELEASE_PLEASE_PENDING_LABEL,
|
|
@@ -1128,17 +1804,26 @@ export {
|
|
|
1128
1804
|
CI_CHANGE_CHECK,
|
|
1129
1805
|
decideCiChange,
|
|
1130
1806
|
ciChangeCheck,
|
|
1131
|
-
|
|
1132
|
-
FUNCTIONAL_TYPES,
|
|
1133
|
-
parseTitle,
|
|
1807
|
+
parseClaims,
|
|
1134
1808
|
CI_SENSITIVE_PACKAGES,
|
|
1135
1809
|
isManifestPath,
|
|
1136
1810
|
sensitiveBumps,
|
|
1811
|
+
isWorkflowPath,
|
|
1812
|
+
DEPENDABOT_CHECK,
|
|
1813
|
+
DEPENDABOT_LOGIN,
|
|
1814
|
+
decideDependabot,
|
|
1815
|
+
dependabotCheck,
|
|
1816
|
+
MERGE_BLOCK_CHECK,
|
|
1817
|
+
decideMergeBlock,
|
|
1818
|
+
mergeBlockCheck,
|
|
1819
|
+
COMMIT_TYPES,
|
|
1820
|
+
FUNCTIONAL_TYPES,
|
|
1821
|
+
parseTitle,
|
|
1137
1822
|
TITLE_CHECK,
|
|
1138
1823
|
decideTitle,
|
|
1139
1824
|
titleCheck,
|
|
1140
1825
|
CHECKS,
|
|
1141
|
-
|
|
1826
|
+
selectChecks,
|
|
1142
1827
|
POLICY_OUTCOMES,
|
|
1143
1828
|
buildReport,
|
|
1144
1829
|
evaluatePolicy,
|
|
@@ -1146,6 +1831,8 @@ export {
|
|
|
1146
1831
|
resolveRepoTarget,
|
|
1147
1832
|
checkRunBody,
|
|
1148
1833
|
postCheckRun,
|
|
1834
|
+
statusBody,
|
|
1835
|
+
postVerdict,
|
|
1149
1836
|
gatherFacts,
|
|
1150
1837
|
applyLabelEdits
|
|
1151
1838
|
};
|