@rmartz/pr-policy 0.2.0 → 0.8.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-5VYRZAUT.js} +729 -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,404 @@ 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);
|
|
687
1150
|
}
|
|
688
1151
|
|
|
689
1152
|
// src/checks/title/index.ts
|
|
690
1153
|
var TITLE_CHECK = "title";
|
|
691
1154
|
var FUNCTIONAL = FUNCTIONAL_TYPES.map((type) => `\`${type}\``).join("/");
|
|
692
|
-
function
|
|
1155
|
+
function block2(message) {
|
|
693
1156
|
return { check: TITLE_CHECK, message, effect: "block" };
|
|
694
1157
|
}
|
|
695
1158
|
function decideTitle(pr) {
|
|
@@ -701,7 +1164,7 @@ function decideTitle(pr) {
|
|
|
701
1164
|
if (title === null) {
|
|
702
1165
|
return {
|
|
703
1166
|
findings: [
|
|
704
|
-
|
|
1167
|
+
block2(
|
|
705
1168
|
`\`${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
1169
|
)
|
|
707
1170
|
]
|
|
@@ -714,44 +1177,44 @@ function decideTitle(pr) {
|
|
|
714
1177
|
if (!functional) {
|
|
715
1178
|
if (title.breaking) {
|
|
716
1179
|
findings.push(
|
|
717
|
-
|
|
1180
|
+
block2(
|
|
718
1181
|
`\`!\` is only allowed on ${FUNCTIONAL}; on \`${title.type}\` it would cut a spurious major release. Drop the \`!\`.`
|
|
719
1182
|
)
|
|
720
1183
|
);
|
|
721
1184
|
}
|
|
722
1185
|
if (breakingLabel) {
|
|
723
1186
|
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
|
|
1187
|
+
block2(
|
|
1188
|
+
`\`${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
1189
|
)
|
|
727
1190
|
);
|
|
728
1191
|
}
|
|
729
1192
|
} else if (breakingIntent && !title.breaking) {
|
|
730
1193
|
findings.push(
|
|
731
|
-
|
|
1194
|
+
block2(
|
|
732
1195
|
`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
1196
|
)
|
|
734
1197
|
);
|
|
735
1198
|
} else if (!breakingIntent && title.breaking) {
|
|
736
1199
|
findings.push(
|
|
737
|
-
|
|
1200
|
+
block2(
|
|
738
1201
|
`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
1202
|
)
|
|
740
1203
|
);
|
|
741
1204
|
}
|
|
1205
|
+
const mayRetitle = !functional && title.type !== "ci";
|
|
742
1206
|
const workflows = substantiveWorkflowChanges(pr.workflowChanges);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
);
|
|
1207
|
+
if (workflows.length > 0 && mayRetitle) {
|
|
1208
|
+
findings.push({
|
|
1209
|
+
check: TITLE_CHECK,
|
|
1210
|
+
effect: "info",
|
|
1211
|
+
message: `This PR changes this repo's own CI (${workflows.map((path) => `\`${path}\``).join(", ")}); consider titling it \`ci(<scope>): \u2026\`.`
|
|
1212
|
+
});
|
|
750
1213
|
}
|
|
751
1214
|
const bumps = sensitiveBumps(pr.manifestChanges);
|
|
752
|
-
if (bumps.length > 0 &&
|
|
1215
|
+
if (bumps.length > 0 && mayRetitle) {
|
|
753
1216
|
findings.push(
|
|
754
|
-
|
|
1217
|
+
block2(
|
|
755
1218
|
`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
1219
|
)
|
|
757
1220
|
);
|
|
@@ -763,13 +1226,52 @@ var titleCheck = {
|
|
|
763
1226
|
evaluate: async (pr) => decideTitle(pr)
|
|
764
1227
|
};
|
|
765
1228
|
|
|
766
|
-
// src/checks/index.ts
|
|
767
|
-
var
|
|
1229
|
+
// src/checks/uat/index.ts
|
|
1230
|
+
var UAT_CHECK = "uat";
|
|
1231
|
+
var PASSING_LABELS = [UAT_PASSED_LABEL, LEGACY_UAT_PASSED_LABEL, NO_UAT_NEEDED_LABEL];
|
|
1232
|
+
function info2(message) {
|
|
1233
|
+
return { check: UAT_CHECK, message, effect: "info" };
|
|
1234
|
+
}
|
|
1235
|
+
function decideUat(pr) {
|
|
1236
|
+
const categories = trivialCategories(pr);
|
|
1237
|
+
if (categories !== void 0) {
|
|
1238
|
+
const kinds = categories.length > 0 ? categories.join(", ") : "no files";
|
|
1239
|
+
return { findings: [info2(`No UAT needed: the PR changes only ${kinds}.`)] };
|
|
1240
|
+
}
|
|
1241
|
+
const state = signOffState(pr, PASSING_LABELS);
|
|
1242
|
+
if (state.status === "trusted") {
|
|
1243
|
+
return {
|
|
1244
|
+
findings: [
|
|
1245
|
+
{
|
|
1246
|
+
...info2(`UAT signed off with \`${state.label}\` by \`${state.appliedBy.login}\``),
|
|
1247
|
+
headline: true
|
|
1248
|
+
}
|
|
1249
|
+
]
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
const hold = {
|
|
1253
|
+
check: UAT_CHECK,
|
|
1254
|
+
message: `Waiting for UAT. A person applies \`${UAT_PASSED_LABEL}\` after testing, or the review agent or a person applies \`${NO_UAT_NEEDED_LABEL}\`.`,
|
|
1255
|
+
effect: "hold"
|
|
1256
|
+
};
|
|
1257
|
+
const why = state.status === "untrusted" ? [info2(`\`${state.label}\` doesn't count: ${state.reason}.`)] : [];
|
|
1258
|
+
return { findings: [hold, ...why] };
|
|
1259
|
+
}
|
|
1260
|
+
var uatCheck = {
|
|
1261
|
+
name: UAT_CHECK,
|
|
1262
|
+
evaluate: async (pr) => decideUat(pr)
|
|
1263
|
+
};
|
|
768
1264
|
|
|
769
|
-
// src/checks/
|
|
770
|
-
var
|
|
771
|
-
|
|
772
|
-
|
|
1265
|
+
// src/checks/index.ts
|
|
1266
|
+
var CHECKS = [
|
|
1267
|
+
mergeBlockCheck,
|
|
1268
|
+
titleCheck,
|
|
1269
|
+
ciChangeCheck,
|
|
1270
|
+
uatCheck,
|
|
1271
|
+
dependabotCheck
|
|
1272
|
+
];
|
|
1273
|
+
function selectChecks(options = {}) {
|
|
1274
|
+
return options.skipUat ? CHECKS.filter((check) => check !== uatCheck) : CHECKS;
|
|
773
1275
|
}
|
|
774
1276
|
|
|
775
1277
|
// src/report.ts
|
|
@@ -830,22 +1332,62 @@ function parseFileChanges(field, value) {
|
|
|
830
1332
|
if (!Array.isArray(value)) throw new Error(`facts.${field} must be an array`);
|
|
831
1333
|
return value.map((item, index) => parseFileChange(field, item, index));
|
|
832
1334
|
}
|
|
1335
|
+
function parseActor(where, value) {
|
|
1336
|
+
if (value === void 0) return void 0;
|
|
1337
|
+
if (typeof value !== "object" || value === null) throw new Error(`${where} must be an object`);
|
|
1338
|
+
const { login, type, permission } = value;
|
|
1339
|
+
if (typeof login !== "string" || typeof type !== "string") {
|
|
1340
|
+
throw new Error(`${where}.login / type must be strings`);
|
|
1341
|
+
}
|
|
1342
|
+
if (!isRepoPermission(permission)) {
|
|
1343
|
+
throw new Error(`${where}.permission must be one of ${REPO_PERMISSIONS.join(", ")}`);
|
|
1344
|
+
}
|
|
1345
|
+
return { login, type, permission };
|
|
1346
|
+
}
|
|
1347
|
+
function parseSignOffs(value) {
|
|
1348
|
+
if (value === void 0) return [];
|
|
1349
|
+
if (!Array.isArray(value)) throw new Error("facts.signOffs must be an array");
|
|
1350
|
+
return value.map((item, index) => {
|
|
1351
|
+
const where = `facts.signOffs[${index}]`;
|
|
1352
|
+
if (typeof item !== "object" || item === null) throw new Error(`${where} must be an object`);
|
|
1353
|
+
const { label, appliedBy } = item;
|
|
1354
|
+
if (typeof label !== "string") throw new Error(`${where}.label must be a string`);
|
|
1355
|
+
const actor = parseActor(`${where}.appliedBy`, appliedBy);
|
|
1356
|
+
return actor === void 0 ? { label } : { label, appliedBy: actor };
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
function parseAuthor(value) {
|
|
1360
|
+
if (value === void 0) return void 0;
|
|
1361
|
+
if (typeof value !== "object" || value === null)
|
|
1362
|
+
throw new Error("facts.author must be an object");
|
|
1363
|
+
const { login, type } = value;
|
|
1364
|
+
if (typeof login !== "string" || typeof type !== "string") {
|
|
1365
|
+
throw new Error("facts.author.login / type must be strings");
|
|
1366
|
+
}
|
|
1367
|
+
return { login, type };
|
|
1368
|
+
}
|
|
833
1369
|
function parseFacts(raw) {
|
|
834
1370
|
const parsed = JSON.parse(raw);
|
|
835
1371
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
836
1372
|
throw new Error("facts must be a JSON object");
|
|
837
1373
|
}
|
|
838
|
-
const { title, labels, changedFiles, workflowChanges, manifestChanges } = parsed;
|
|
1374
|
+
const { title, body, author, labels, changedFiles, workflowChanges, manifestChanges, signOffs } = parsed;
|
|
839
1375
|
if (typeof title !== "string") throw new Error("facts.title must be a string");
|
|
1376
|
+
if (!isOptionalString(body)) throw new Error("facts.body must be a string");
|
|
840
1377
|
if (!isStringArray(labels)) throw new Error("facts.labels must be a string array");
|
|
841
1378
|
if (!isStringArray(changedFiles)) throw new Error("facts.changedFiles must be a string array");
|
|
842
|
-
|
|
1379
|
+
const pr = {
|
|
843
1380
|
title,
|
|
844
1381
|
labels,
|
|
845
1382
|
changedFiles,
|
|
846
1383
|
workflowChanges: parseFileChanges("workflowChanges", workflowChanges),
|
|
847
|
-
manifestChanges: parseFileChanges("manifestChanges", manifestChanges)
|
|
1384
|
+
manifestChanges: parseFileChanges("manifestChanges", manifestChanges),
|
|
1385
|
+
signOffs: parseSignOffs(signOffs)
|
|
848
1386
|
};
|
|
1387
|
+
const parsedAuthor = parseAuthor(author);
|
|
1388
|
+
if (body !== void 0) pr.body = body;
|
|
1389
|
+
if (parsedAuthor !== void 0) pr.author = parsedAuthor;
|
|
1390
|
+
return pr;
|
|
849
1391
|
}
|
|
850
1392
|
|
|
851
1393
|
// src/lib/bounded-subprocess.ts
|
|
@@ -1025,6 +1567,90 @@ async function postCheckRun(target, headSha, evaluation) {
|
|
|
1025
1567
|
const out = await ghCall({ argv, stdin: JSON.stringify(payload) }, null, { cwd: target.cwd });
|
|
1026
1568
|
if (out === null) throw new Error(`could not post the ${PR_POLICY_CHECK_NAME} check-run`);
|
|
1027
1569
|
}
|
|
1570
|
+
var STATUS_DESCRIPTION_MAX = 140;
|
|
1571
|
+
function statusBody(evaluation, runUrl) {
|
|
1572
|
+
return {
|
|
1573
|
+
state: evaluation.outcome,
|
|
1574
|
+
context: PR_POLICY_CHECK_NAME,
|
|
1575
|
+
description: evaluation.title.slice(0, STATUS_DESCRIPTION_MAX),
|
|
1576
|
+
...runUrl ? { target_url: runUrl } : {}
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
async function postCommitStatus(target, headSha, evaluation) {
|
|
1580
|
+
const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env;
|
|
1581
|
+
const runUrl = GITHUB_SERVER_URL && GITHUB_REPOSITORY && GITHUB_RUN_ID ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` : void 0;
|
|
1582
|
+
const out = await ghCall(
|
|
1583
|
+
{
|
|
1584
|
+
argv: ["gh", "api", "-X", "POST", `repos/${target.repo}/statuses/${headSha}`, "--input", "-"],
|
|
1585
|
+
stdin: JSON.stringify(statusBody(evaluation, runUrl))
|
|
1586
|
+
},
|
|
1587
|
+
null,
|
|
1588
|
+
{ cwd: target.cwd }
|
|
1589
|
+
);
|
|
1590
|
+
if (out === null) {
|
|
1591
|
+
console.warn(
|
|
1592
|
+
`::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)`
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
async function postVerdict(target, headSha, evaluation) {
|
|
1597
|
+
await postCheckRun(target, headSha, evaluation);
|
|
1598
|
+
await postCommitStatus(target, headSha, evaluation);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// src/github/sign-offs.ts
|
|
1602
|
+
async function listLabelEvents(target) {
|
|
1603
|
+
const out = await ghCall(
|
|
1604
|
+
{
|
|
1605
|
+
argv: [
|
|
1606
|
+
"gh",
|
|
1607
|
+
"api",
|
|
1608
|
+
"--paginate",
|
|
1609
|
+
`repos/${target.repo}/issues/${target.pr}/events`,
|
|
1610
|
+
"--jq",
|
|
1611
|
+
'.[] | select(.event == "labeled") | {label: .label.name, login: .actor.login, type: .actor.type}'
|
|
1612
|
+
]
|
|
1613
|
+
},
|
|
1614
|
+
null,
|
|
1615
|
+
{ cwd: target.cwd }
|
|
1616
|
+
);
|
|
1617
|
+
if (out === null) throw new Error(`could not list label events of ${target.repo}#${target.pr}`);
|
|
1618
|
+
return out.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
|
|
1619
|
+
}
|
|
1620
|
+
async function lookupPermission(target, login) {
|
|
1621
|
+
const out = await ghCall(
|
|
1622
|
+
{
|
|
1623
|
+
argv: [
|
|
1624
|
+
"gh",
|
|
1625
|
+
"api",
|
|
1626
|
+
`repos/${target.repo}/collaborators/${encodeURIComponent(login)}/permission`,
|
|
1627
|
+
"--jq",
|
|
1628
|
+
"{permission, role_name}"
|
|
1629
|
+
]
|
|
1630
|
+
},
|
|
1631
|
+
null,
|
|
1632
|
+
{ cwd: target.cwd }
|
|
1633
|
+
);
|
|
1634
|
+
if (out === null) return "none";
|
|
1635
|
+
const { permission, role_name: roleName } = JSON.parse(out);
|
|
1636
|
+
if (isRepoPermission(roleName)) return roleName;
|
|
1637
|
+
return isRepoPermission(permission) ? permission : "none";
|
|
1638
|
+
}
|
|
1639
|
+
async function gatherSignOffs(target, labels) {
|
|
1640
|
+
const present = SIGN_OFF_LABELS.filter((label) => labels.includes(label));
|
|
1641
|
+
if (present.length === 0) return [];
|
|
1642
|
+
const latest = /* @__PURE__ */ new Map();
|
|
1643
|
+
for (const event of await listLabelEvents(target)) latest.set(event.label, event);
|
|
1644
|
+
return Promise.all(
|
|
1645
|
+
present.map(async (label) => {
|
|
1646
|
+
const event = latest.get(label);
|
|
1647
|
+
if (event === void 0 || event.login === null) return { label };
|
|
1648
|
+
const type = event.type ?? "unknown actor";
|
|
1649
|
+
const permission = type === "User" ? await lookupPermission(target, event.login) : "none";
|
|
1650
|
+
return { label, appliedBy: { login: event.login, type, permission } };
|
|
1651
|
+
})
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1028
1654
|
|
|
1029
1655
|
// src/github/pull-request.ts
|
|
1030
1656
|
async function ghJson(target, argv) {
|
|
@@ -1095,12 +1721,19 @@ async function gatherFacts(target) {
|
|
|
1095
1721
|
workflowChanges = await Promise.all(workflowFiles.map(read));
|
|
1096
1722
|
manifestChanges = await Promise.all(manifestFiles.map(read));
|
|
1097
1723
|
}
|
|
1724
|
+
const labels = view.labels.map((label) => label.name);
|
|
1098
1725
|
const facts = {
|
|
1099
1726
|
title: view.title,
|
|
1100
|
-
|
|
1101
|
-
|
|
1727
|
+
body: view.body ?? "",
|
|
1728
|
+
author: { login: view.user.login, type: view.user.type },
|
|
1729
|
+
labels,
|
|
1730
|
+
// A rename's old path counts too: moving code into docs/ is not docs-only.
|
|
1731
|
+
changedFiles: files.flatMap(
|
|
1732
|
+
(file) => file.previous_filename === void 0 || file.previous_filename === null ? [file.filename] : [file.previous_filename, file.filename]
|
|
1733
|
+
),
|
|
1102
1734
|
workflowChanges,
|
|
1103
|
-
manifestChanges
|
|
1735
|
+
manifestChanges,
|
|
1736
|
+
signOffs: await gatherSignOffs(target, labels)
|
|
1104
1737
|
};
|
|
1105
1738
|
return { facts, headSha };
|
|
1106
1739
|
}
|
|
@@ -1117,6 +1750,11 @@ export {
|
|
|
1117
1750
|
PR_POLICY_CHECK_NAME,
|
|
1118
1751
|
CI_APPROVAL_NEEDED_LABEL,
|
|
1119
1752
|
CI_CHANGE_APPROVED_LABEL,
|
|
1753
|
+
DO_NOT_MERGE_LABEL,
|
|
1754
|
+
DNM_LABEL,
|
|
1755
|
+
BLOCKED_LABEL,
|
|
1756
|
+
ESCALATION_NEEDED_LABEL,
|
|
1757
|
+
BLOCKING_LABELS,
|
|
1120
1758
|
BREAKING_CHANGE_LABEL,
|
|
1121
1759
|
HOTFIX_LABEL,
|
|
1122
1760
|
RELEASE_PLEASE_PENDING_LABEL,
|
|
@@ -1128,17 +1766,26 @@ export {
|
|
|
1128
1766
|
CI_CHANGE_CHECK,
|
|
1129
1767
|
decideCiChange,
|
|
1130
1768
|
ciChangeCheck,
|
|
1131
|
-
|
|
1132
|
-
FUNCTIONAL_TYPES,
|
|
1133
|
-
parseTitle,
|
|
1769
|
+
parseClaims,
|
|
1134
1770
|
CI_SENSITIVE_PACKAGES,
|
|
1135
1771
|
isManifestPath,
|
|
1136
1772
|
sensitiveBumps,
|
|
1773
|
+
isWorkflowPath,
|
|
1774
|
+
DEPENDABOT_CHECK,
|
|
1775
|
+
DEPENDABOT_LOGIN,
|
|
1776
|
+
decideDependabot,
|
|
1777
|
+
dependabotCheck,
|
|
1778
|
+
MERGE_BLOCK_CHECK,
|
|
1779
|
+
decideMergeBlock,
|
|
1780
|
+
mergeBlockCheck,
|
|
1781
|
+
COMMIT_TYPES,
|
|
1782
|
+
FUNCTIONAL_TYPES,
|
|
1783
|
+
parseTitle,
|
|
1137
1784
|
TITLE_CHECK,
|
|
1138
1785
|
decideTitle,
|
|
1139
1786
|
titleCheck,
|
|
1140
1787
|
CHECKS,
|
|
1141
|
-
|
|
1788
|
+
selectChecks,
|
|
1142
1789
|
POLICY_OUTCOMES,
|
|
1143
1790
|
buildReport,
|
|
1144
1791
|
evaluatePolicy,
|
|
@@ -1146,6 +1793,8 @@ export {
|
|
|
1146
1793
|
resolveRepoTarget,
|
|
1147
1794
|
checkRunBody,
|
|
1148
1795
|
postCheckRun,
|
|
1796
|
+
statusBody,
|
|
1797
|
+
postVerdict,
|
|
1149
1798
|
gatherFacts,
|
|
1150
1799
|
applyLabelEdits
|
|
1151
1800
|
};
|