@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.
@@ -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, labels) {
528
- const present = new Set(labels);
529
- const signedOff = present.has(CI_CHANGE_APPROVED_LABEL);
530
- const flagged = present.has(CI_APPROVAL_NEEDED_LABEL);
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.labels)
615
+ evaluate: async (pr) => decideCiChange(classifyWorkflowChanges(pr.workflowChanges), pr)
563
616
  };
564
617
 
565
- // src/checks/title/conventional.ts
566
- var COMMIT_TYPES = [
567
- "feat",
568
- "fix",
569
- "docs",
570
- "chore",
571
- "refactor",
572
- "test",
573
- "style",
574
- "perf",
575
- "ci",
576
- "build",
577
- "revert"
578
- ];
579
- var FUNCTIONAL_TYPES = [
580
- "feat",
581
- "fix",
582
- "perf",
583
- "revert"
584
- ];
585
- var TITLE = /^([a-z]+)(?:\(([^)]+)\))?(!)?: (\S.*)$/;
586
- function isCommitType(value) {
587
- return COMMIT_TYPES.includes(value);
588
- }
589
- function parseTitle(title) {
590
- const match = TITLE.exec(title);
591
- const [, type, scope, bang, subject] = match ?? [];
592
- if (type === void 0 || subject === void 0 || !isCommitType(type)) return null;
593
- return { type, scope, breaking: bang === "!", subject };
594
- }
595
- function isFunctionalType(type) {
596
- return FUNCTIONAL_TYPES.includes(type);
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 RELEASE_PLEASE_TITLE = /^chore(\([^)]*\))?!?:\s+release\b/;
599
- function isReleasePleaseTitle(title) {
600
- return RELEASE_PLEASE_TITLE.test(title);
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 block2 = parsed[field];
634
- if (typeof block2 !== "object" || block2 === null) continue;
635
- for (const [name, spec] of Object.entries(block2)) {
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/title/workflow-change.ts
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 isPinBumpOnly(change) {
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 block(message) {
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
- block(
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
- block(
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
- block(
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 \`ci\` change already forces sibling rebases through its type.)`
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
- block(
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
- block(
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
- const bundledCi = functional && breakingLabel;
744
- if (workflows.length > 0 && title.type !== "ci" && !bundledCi) {
745
- findings.push(
746
- block(
747
- `This PR changes CI (${workflows.map((path) => `\`${path}\``).join(", ")}), so it must be \`ci\`-typed: retitle to \`ci(<scope>): \u2026\`. If the CI change is bundled with a functional change, keep the ${FUNCTIONAL} type and apply \`${BREAKING_CHANGE_LABEL}\` instead.`
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 && title.type !== "ci") {
1253
+ if (bumps.length > 0 && mayRetitle) {
753
1254
  findings.push(
754
- block(
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 CHECKS = [titleCheck, ciChangeCheck];
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/ci-change/workflow-paths.ts
770
- var WORKFLOW_FILE = /^\.github\/workflows\/.+\.ya?ml$/;
771
- function isWorkflowPath(path) {
772
- return WORKFLOW_FILE.test(path);
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
- return {
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
- labels: view.labels.map((label) => label.name),
1101
- changedFiles: files.map((file) => file.filename),
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
- COMMIT_TYPES,
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
- isWorkflowPath,
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
  };