@intentius/chant 0.124.41 → 0.124.43

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.
Files changed (127) hide show
  1. package/dist/cli/handlers/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.js +3 -0
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/fan-out.js +27 -3
  5. package/dist/cli/handlers/pr.d.ts +3 -1
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/pr.js +113 -2
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/handlers/run.js +11 -0
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/main.js +8 -2
  12. package/dist/cli/parse-args.d.ts.map +1 -1
  13. package/dist/cli/parse-args.js +15 -0
  14. package/dist/cli/registry.d.ts +19 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/capability.d.ts +47 -0
  17. package/dist/components/capability.d.ts.map +1 -1
  18. package/dist/components/capability.js +44 -0
  19. package/dist/components/cli-support.d.ts +6 -0
  20. package/dist/components/cli-support.d.ts.map +1 -1
  21. package/dist/components/cli-support.js +8 -1
  22. package/dist/components/driver.d.ts +10 -0
  23. package/dist/components/driver.d.ts.map +1 -1
  24. package/dist/components/driver.js +52 -2
  25. package/dist/components/fan-out-run.d.ts +9 -0
  26. package/dist/components/fan-out-run.d.ts.map +1 -1
  27. package/dist/components/fan-out-run.js +17 -1
  28. package/dist/components/gate-plan.d.ts +2 -0
  29. package/dist/components/gate-plan.d.ts.map +1 -1
  30. package/dist/components/gate-plan.js +1 -0
  31. package/dist/components/pr-pipeline.d.ts +20 -0
  32. package/dist/components/pr-pipeline.d.ts.map +1 -1
  33. package/dist/components/pr-pipeline.js +54 -16
  34. package/dist/components/pr-run.d.ts +6 -0
  35. package/dist/components/pr-run.d.ts.map +1 -1
  36. package/dist/components/pr-run.js +3 -0
  37. package/dist/components/wave-plan.d.ts +14 -1
  38. package/dist/components/wave-plan.d.ts.map +1 -1
  39. package/dist/components/wave-plan.js +34 -2
  40. package/dist/op/activities/pr-refs.d.ts +10 -0
  41. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  42. package/dist/op/activities/pr-refs.js +26 -0
  43. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  44. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  45. package/dist/op/activities/predict-behaviour.js +2 -21
  46. package/dist/op/activities/review-agent.js +1 -1
  47. package/dist/op/index.d.ts +2 -2
  48. package/dist/op/index.d.ts.map +1 -1
  49. package/dist/op/index.js +1 -1
  50. package/dist/op/op-waves-run.d.ts +3 -79
  51. package/dist/op/op-waves-run.d.ts.map +1 -1
  52. package/dist/op/op-waves-run.js +4 -133
  53. package/dist/op/stage-hooks.d.ts +152 -0
  54. package/dist/op/stage-hooks.d.ts.map +1 -0
  55. package/dist/op/stage-hooks.js +199 -0
  56. package/dist/pr-forge.d.ts +56 -0
  57. package/dist/pr-forge.d.ts.map +1 -1
  58. package/dist/pr-forge.js +320 -45
  59. package/dist/pr-loop.d.ts +18 -1
  60. package/dist/pr-loop.d.ts.map +1 -1
  61. package/dist/pr-loop.js +7 -0
  62. package/dist/pr-requires.d.ts +47 -0
  63. package/dist/pr-requires.d.ts.map +1 -0
  64. package/dist/pr-requires.js +53 -0
  65. package/dist/wave-runtime.mjs +213 -213
  66. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  67. package/dist/workspace/checks/ci-workflow.js +19 -4
  68. package/dist/workspace/ci-init.d.ts.map +1 -1
  69. package/dist/workspace/ci-init.js +5 -4
  70. package/dist/workspace/ci-run.d.ts.map +1 -1
  71. package/dist/workspace/ci-run.js +2 -1
  72. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  73. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  74. package/dist/workspace/ci-workflow-at.js +17 -0
  75. package/dist/workspace/ci-workflow.d.ts +11 -3
  76. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  77. package/dist/workspace/ci-workflow.js +10 -2
  78. package/dist/workspace/declaration.d.ts +6 -3
  79. package/dist/workspace/declaration.d.ts.map +1 -1
  80. package/dist/workspace/declaration.schema.json +2 -2
  81. package/dist/workspace/pr-report.schema.json +23 -3
  82. package/dist/workspace/reason-codes.d.ts +1 -1
  83. package/dist/workspace/reason-codes.d.ts.map +1 -1
  84. package/dist/workspace/reason-codes.js +1 -1
  85. package/package.json +1 -1
  86. package/src/cli/handlers/build.ts +3 -0
  87. package/src/cli/handlers/fan-out.ts +27 -3
  88. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  89. package/src/cli/handlers/pr.ts +110 -2
  90. package/src/cli/handlers/run.ts +10 -0
  91. package/src/cli/main.ts +8 -2
  92. package/src/cli/parse-args.ts +11 -0
  93. package/src/cli/registry.ts +19 -0
  94. package/src/components/capability-targets.test.ts +162 -0
  95. package/src/components/capability.ts +71 -0
  96. package/src/components/cli-support.ts +14 -1
  97. package/src/components/driver.ts +54 -3
  98. package/src/components/fan-out-run.ts +19 -1
  99. package/src/components/gate-plan.ts +3 -0
  100. package/src/components/pr-pipeline.ts +64 -16
  101. package/src/components/pr-run.ts +9 -0
  102. package/src/components/wave-plan.ts +38 -3
  103. package/src/op/activities/pr-refs.ts +28 -0
  104. package/src/op/activities/predict-behaviour.ts +3 -23
  105. package/src/op/activities/review-agent.ts +1 -1
  106. package/src/op/index.ts +4 -0
  107. package/src/op/op-waves-run.ts +24 -228
  108. package/src/op/stage-hooks-async.test.ts +79 -0
  109. package/src/op/stage-hooks.ts +346 -0
  110. package/src/pr-forge-bundle.test.ts +2 -2
  111. package/src/pr-forge.ts +334 -36
  112. package/src/pr-loop-bundle.test.ts +2 -2
  113. package/src/pr-loop.ts +25 -1
  114. package/src/pr-requires.test.ts +248 -0
  115. package/src/pr-requires.ts +93 -0
  116. package/src/review-agent-bundle.test.ts +35 -0
  117. package/src/stage-hooks-bundle.test.ts +38 -0
  118. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  119. package/src/workspace/checks/ci-workflow.ts +18 -4
  120. package/src/workspace/ci-init.ts +5 -4
  121. package/src/workspace/ci-run.ts +2 -1
  122. package/src/workspace/ci-workflow-at.ts +23 -0
  123. package/src/workspace/ci-workflow.ts +13 -2
  124. package/src/workspace/declaration.schema.json +2 -2
  125. package/src/workspace/declaration.ts +6 -3
  126. package/src/workspace/pr-report.schema.json +23 -3
  127. package/src/workspace/reason-codes.ts +1 -1
package/src/pr-forge.ts CHANGED
@@ -411,6 +411,117 @@ export interface PrForge {
411
411
  * pipelines. Throws a {@link ForgeApiError} when the forge will not say.
412
412
  */
413
413
  scheduledRuns?(query: ScheduledRunsQuery): Promise<ScheduledRuns>;
414
+ /**
415
+ * The four checks an apply before merge requires (#4301, ws-106), each
416
+ * about pull request `pr` at `head`, the commit the job checked out. Each
417
+ * fails when the forge's head for `pr` is another commit. `approved`:
418
+ * someone other than the pull request's authors ({@link authors}) approved
419
+ * `head`, and no standing review asks for changes. On GitLab an approval
420
+ * counts when it came after the merge request's latest push, read from its
421
+ * system notes, and never from the token's own user. `approvers` narrows
422
+ * who counts (#4337), and a refusal names each approver left out and why.
423
+ */
424
+ prApproved?(pr: number, head: string, approvers?: PrApproverFilter): Promise<PrRequireResult>;
425
+ /**
426
+ * The forge says `pr` merges as it stands: no conflicts, and on GitHub no
427
+ * branch protection blocking it. Pending while the forge works it out. On
428
+ * Forgejo, which answers `mergeable: false` while it still checks, a false
429
+ * is pending too, so it counts as conflicts only once the reads run out.
430
+ */
431
+ prMergeable?(pr: number, head: string): Promise<PrRequireResult>;
432
+ /** `head` contains the tip of the branch `pr` merges into. */
433
+ prUndiverged?(pr: number, head: string): Promise<PrRequireResult>;
434
+ /**
435
+ * Every status and check reported on `head` passed, and at least one did.
436
+ * Statuses named in `ignore` are left out, such as the apply's own.
437
+ * Pending while one still runs or none has reported.
438
+ */
439
+ prChecksPassed?(pr: number, head: string, ignore?: readonly string[]): Promise<PrRequireResult>;
440
+ }
441
+
442
+ /**
443
+ * Who counts as an approver for {@link PrForge.prApproved} (#4337). With
444
+ * none set, any reviewer other than the pull request's authors counts.
445
+ */
446
+ export interface PrApproverFilter {
447
+ /**
448
+ * The forge's own rule for who may approve. GitHub: write access (admin,
449
+ * maintain or write), read per approver. Forgejo: the review is official,
450
+ * which Forgejo marks for someone with write access. GitLab: an access
451
+ * level of at least `minAccessLevel`, read per approver.
452
+ */
453
+ forgeRule?: boolean;
454
+ /** GitLab under `forgeRule`: the least access level that counts. Default 30, Developer. */
455
+ minAccessLevel?: number;
456
+ /** Logins whose approval never counts, compared case-insensitively, each with the reason a refusal gives if one is set. */
457
+ exclude?: readonly (string | PrExcludedApprover)[];
458
+ }
459
+
460
+ /** A login left out of the approvers, and the reason a refusal gives. */
461
+ export interface PrExcludedApprover {
462
+ login: string;
463
+ reason: string;
464
+ }
465
+
466
+ /** Why `login`'s approval does not count under `filter`'s `exclude`, or undefined. */
467
+ function excludedWhy(filter: PrApproverFilter | undefined, login: string): string | undefined {
468
+ for (const e of filter?.exclude ?? []) {
469
+ const who = typeof e === "string" ? e : e.login;
470
+ if (who.toLowerCase() === login.toLowerCase()) return typeof e === "string" ? `${login} is excluded` : e.reason;
471
+ }
472
+ return undefined;
473
+ }
474
+
475
+ /**
476
+ * The approvers who count, and why each other one did not: an author, then
477
+ * `exclude`, then the forge's rule (`rule` gives a reason or undefined).
478
+ */
479
+ async function countApprovers(
480
+ approved: readonly string[],
481
+ authors: ReadonlySet<string>,
482
+ filter: PrApproverFilter | undefined,
483
+ rule: (login: string) => Promise<string | undefined>,
484
+ ): Promise<{ by: string[]; not: string[] }> {
485
+ const by: string[] = [];
486
+ const not: string[] = [];
487
+ for (const login of approved) {
488
+ const why = authors.has(login) ? `${login} is one of its authors` : (excludedWhy(filter, login) ?? (filter?.forgeRule ? await rule(login) : undefined));
489
+ if (why) not.push(why);
490
+ else by.push(login);
491
+ }
492
+ return { by, not };
493
+ }
494
+
495
+ /** A refusal for no approver counted, naming each one left out. */
496
+ const noApprover = (line: string, not: readonly string[]): PrRequireResult => refuse(not.length > 0 ? `${line}: ${not.join("; ")}` : line);
497
+
498
+ /** GitLab's access level for Developer, the least that may push. */
499
+ const GITLAB_DEVELOPER = 30;
500
+
501
+ /** GitHub's permissions that may approve under {@link PrApproverFilter.forgeRule}. */
502
+ const GITHUB_WRITERS = new Set(["admin", "maintain", "write"]);
503
+
504
+ /** What one of the requires checks (#4301) found. */
505
+ export interface PrRequireResult {
506
+ passed: boolean;
507
+ /** Not settled yet: the forge is still working it out, or a check still runs. Asking again may pass. */
508
+ pending?: boolean;
509
+ /** One line for a person. */
510
+ reason: string;
511
+ }
512
+
513
+ const short = (sha: string | undefined): string => (sha ?? "nothing").slice(0, 8);
514
+ const pass = (reason: string): PrRequireResult => ({ passed: true, reason });
515
+ const refuse = (reason: string, pending = false): PrRequireResult => ({ passed: false, ...(pending ? { pending: true } : {}), reason });
516
+ const moved = (what: string, head: string, now: string | undefined): PrRequireResult => refuse(`${what}'s head is ${short(now)}, not ${short(head)}, the commit this job checked out`);
517
+
518
+ /** The verdict over statuses and checks: failing and still-running names, and how many reported. */
519
+ function checksVerdict(what: string, head: string, failing: string[], waiting: string[], count: number): PrRequireResult {
520
+ const names = (l: string[]) => [...new Set(l)].sort().join(", ");
521
+ if (failing.length > 0) return refuse(`${names(failing)} failed on ${short(head)}`);
522
+ if (waiting.length > 0) return refuse(`${names(waiting)} still running on ${short(head)}`, true);
523
+ if (count === 0) return refuse(`no check has reported on ${what}'s head ${short(head)} yet`, true);
524
+ return pass(`${count} check${count === 1 ? "" : "s"} passed on ${short(head)}`);
414
525
  }
415
526
 
416
527
  /** Which scheduled Op job {@link PrForge.scheduledRuns} asks about. */
@@ -624,6 +735,11 @@ export interface GithubForgeOptions {
624
735
  fetch?: ForgeFetch;
625
736
  }
626
737
 
738
+ /** GitLab's merge statuses while it works out whether a merge request can merge (#4301). */
739
+ const GITLAB_CHECKING = new Set(["checking", "unchecked", "preparing", "approvals_syncing", "cannot_be_merged_recheck"]);
740
+ /** GitLab's commit statuses still running. */
741
+ const GITLAB_RUNNING = new Set(["created", "waiting_for_resource", "preparing", "pending", "running", "scheduled"]);
742
+
627
743
  /** GitHub keeps a status description to 140 characters; Forgejo follows. */
628
744
  const GITHUB_DESCRIPTION = 140;
629
745
  /** GitLab keeps a status description to 255 characters. */
@@ -693,6 +809,33 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
693
809
  ...(url ? { target_url: url } : {}),
694
810
  });
695
811
  };
812
+ const authorsOf = async (pr: number): Promise<string[]> => {
813
+ const logins = new Set<string>();
814
+ const add = (v: unknown) => {
815
+ if (isObject(v) && typeof v.login === "string" && v.login !== "") logins.add(v.login);
816
+ };
817
+ const opened = await api.call("GET", `${repo}/pulls/${pr}`);
818
+ if (isObject(opened)) add(opened.user);
819
+ for (const c of await api.list(`${repo}/pulls/${pr}/commits`, pageParams, pageSize)) {
820
+ if (!isObject(c)) continue;
821
+ add(c.author);
822
+ add(c.committer);
823
+ }
824
+ return [...logins].sort();
825
+ };
826
+ const branchTip = async (branch: string): Promise<string | undefined> => {
827
+ let b: unknown;
828
+ try {
829
+ b = await api.call("GET", `${repo}/branches/${branch.split("/").map(encodeURIComponent).join("/")}`);
830
+ } catch (err) {
831
+ if (err instanceof ForgeApiError && err.status === 404) return undefined;
832
+ throw err;
833
+ }
834
+ // GitHub names the commit `sha`, Forgejo `id`.
835
+ const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
836
+ return commit ? str(commit.sha) ?? str(commit.id) : undefined;
837
+ };
838
+ const headOf = (v: unknown): string | undefined => (isObject(v) && isObject(v.head) ? str(v.head.sha) : undefined);
696
839
  return {
697
840
  kind,
698
841
  host,
@@ -720,19 +863,100 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
720
863
  }
721
864
  return [...standing].filter(([, state]) => state === "APPROVED").map(([login]) => login).sort();
722
865
  },
723
- async authors(pr) {
724
- const logins = new Set<string>();
725
- const add = (v: unknown) => {
726
- if (isObject(v) && typeof v.login === "string" && v.login !== "") logins.add(v.login);
866
+ authors: authorsOf,
867
+ async prApproved(pr, head, approvers) {
868
+ const got = await api.call("GET", `${repo}/pulls/${pr}`);
869
+ const at = headOf(got);
870
+ if (at !== head) return moved(`pull request ${pr}`, head, at);
871
+ // A reviewer's newest deciding review stands, as in approvers; an approval counts only of this head.
872
+ const standing = new Map<string, { state: string; commit?: string; official?: boolean }>();
873
+ for (const r of await api.list(`${repo}/pulls/${pr}/reviews`, pageParams, pageSize)) {
874
+ const login = loginOf(r);
875
+ if (!login || !isObject(r) || typeof r.state !== "string") continue;
876
+ const state = r.dismissed === true ? "DISMISSED" : r.state.toUpperCase();
877
+ if (state === "COMMENTED" || state === "COMMENT" || state === "PENDING") continue;
878
+ const commit = str(r.commit_id);
879
+ standing.set(login, { state, ...(commit ? { commit } : {}), ...(typeof r.official === "boolean" ? { official: r.official } : {}) });
880
+ }
881
+ const changes = [...standing].filter(([, r]) => r.state === "CHANGES_REQUESTED" || r.state === "REQUEST_CHANGES").map(([l]) => l).sort();
882
+ if (changes.length > 0) return refuse(`${changes.join(", ")} asked for changes`);
883
+ const authors = new Set(await authorsOf(pr));
884
+ const approved = [...standing].filter(([, r]) => r.state === "APPROVED" && r.commit === head).map(([l]) => l).sort();
885
+ const rule = async (login: string): Promise<string | undefined> => {
886
+ // Forgejo marks a review official when its author may write; terragucci read a missing flag as official too.
887
+ if (kind === "forgejo") return standing.get(login)?.official === false ? `${login}'s review is not official` : undefined;
888
+ let got: unknown;
889
+ try {
890
+ got = await api.call("GET", `${repo}/collaborators/${encodeURIComponent(login)}/permission`);
891
+ } catch (err) {
892
+ return `could not read ${login}'s access (${err instanceof Error ? err.message : String(err)})`;
893
+ }
894
+ const p = isObject(got) ? [str(got.permission), str(got.role_name)] : [];
895
+ return p.some((x) => x !== undefined && GITHUB_WRITERS.has(x)) ? undefined : `${login} has no write access`;
727
896
  };
728
- const opened = await api.call("GET", `${repo}/pulls/${pr}`);
729
- if (isObject(opened)) add(opened.user);
730
- for (const c of await api.list(`${repo}/pulls/${pr}/commits`, pageParams, pageSize)) {
731
- if (!isObject(c)) continue;
732
- add(c.author);
733
- add(c.committer);
897
+ const { by, not } = await countApprovers(approved, authors, approvers, rule);
898
+ if (by.length === 0) return noApprover(`no reviewer other than its authors approved its head ${short(head)}`, not);
899
+ return pass(`approved at ${short(head)} by ${by.join(", ")}`);
900
+ },
901
+ async prMergeable(pr, head) {
902
+ const got = await api.call("GET", `${repo}/pulls/${pr}`);
903
+ const at = headOf(got);
904
+ if (at !== head) return moved(`pull request ${pr}`, head, at);
905
+ const m = isObject(got) ? got : {};
906
+ const into = isObject(m.base) ? str(m.base.ref) : undefined;
907
+ // Forgejo answers mergeable false while it still checks for conflicts, and names no state that tells the two apart, so its false is asked again.
908
+ if (kind === "forgejo" && m.draft === true) return refuse("it is a draft");
909
+ if (kind === "forgejo" && m.mergeable === false) return refuse(`Forgejo reports conflicts with ${into ?? "its base"}, or is still checking for them`, true);
910
+ if (m.mergeable === false || m.mergeable_state === "dirty") return refuse(`the forge reports conflicts with ${into ?? "its base"}`);
911
+ if (m.mergeable !== true) return refuse("the forge has not worked out yet whether it can merge", true);
912
+ if (kind === "github" && m.mergeable_state === "blocked") return refuse(`branch protection on ${into ?? "its base"} blocks its merge`);
913
+ return pass(`it merges into ${into ?? "its base"} as it stands`);
914
+ },
915
+ async prUndiverged(pr, head) {
916
+ const got = await api.call("GET", `${repo}/pulls/${pr}`);
917
+ const at = headOf(got);
918
+ if (at !== head) return moved(`pull request ${pr}`, head, at);
919
+ const into = isObject(got) && isObject(got.base) ? str(got.base.ref) : undefined;
920
+ if (!into) return refuse(`the forge names no branch pull request ${pr} merges into`);
921
+ if (kind === "github") {
922
+ // behind_by: the commits on the base branch that the head does not contain.
923
+ const cmp = await api.call("GET", `${repo}/compare/${encodeURIComponent(into)}...${head}`);
924
+ const behind = isObject(cmp) ? num(cmp.behind_by) : undefined;
925
+ if (behind === undefined) return refuse(`the forge did not say how far ${short(head)} is behind ${into}`);
926
+ return behind === 0 ? pass(`${short(head)} contains ${into}`) : refuse(`${short(head)} is ${behind} commit${behind === 1 ? "" : "s"} behind ${into}`);
927
+ }
928
+ // Forgejo: the pull request's merge base is the base branch's tip exactly when the head contains it.
929
+ const tip = await branchTip(into);
930
+ const base = isObject(got) ? str(got.merge_base) : undefined;
931
+ if (!tip || !base) return refuse(`the forge did not say where ${into} is or where ${short(head)} left it`);
932
+ return base === tip ? pass(`${short(head)} contains ${into} at ${short(tip)}`) : refuse(`${short(head)} does not contain ${into} at ${short(tip)}`);
933
+ },
934
+ async prChecksPassed(_pr, head, ignore = []) {
935
+ const skip = new Set(ignore);
936
+ const failing: string[] = [];
937
+ const waiting: string[] = [];
938
+ let count = 0;
939
+ // The combined status holds each context's newest status.
940
+ const combined = await api.call("GET", `${repo}/commits/${head}/status`);
941
+ for (const st of isObject(combined) && Array.isArray(combined.statuses) ? combined.statuses : []) {
942
+ const context = isObject(st) ? str(st.context) : undefined;
943
+ if (!context || skip.has(context)) continue;
944
+ const state = str(st.state) ?? str(st.status) ?? "";
945
+ count++;
946
+ if (state === "failure" || state === "error" || state === "failed") failing.push(context);
947
+ else if (state === "pending" || state === "running" || state === "") waiting.push(context);
948
+ }
949
+ if (kind === "github") {
950
+ const runs = await api.call("GET", `${repo}/commits/${head}/check-runs?per_page=100`);
951
+ for (const run of isObject(runs) && Array.isArray(runs.check_runs) ? runs.check_runs : []) {
952
+ const name = (isObject(run) ? str(run.name) : undefined) ?? "a check";
953
+ if (skip.has(name) || !isObject(run)) continue;
954
+ count++;
955
+ if (run.status !== "completed") waiting.push(name);
956
+ else if (!["success", "neutral", "skipped"].includes(String(run.conclusion))) failing.push(name);
957
+ }
734
958
  }
735
- return [...logins].sort();
959
+ return checksVerdict(`pull request ${_pr}`, head, failing, waiting, count);
736
960
  },
737
961
  async pullRequestOpen(pr) {
738
962
  try {
@@ -817,18 +1041,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
817
1041
  }
818
1042
  return out.sort((a, b) => a.number - b.number);
819
1043
  },
820
- async branchHead(branch) {
821
- let b: unknown;
822
- try {
823
- b = await api.call("GET", `${repo}/branches/${branch.split("/").map(encodeURIComponent).join("/")}`);
824
- } catch (err) {
825
- if (err instanceof ForgeApiError && err.status === 404) return undefined;
826
- throw err;
827
- }
828
- // GitHub names the commit `sha`, Forgejo `id`.
829
- const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
830
- return commit ? str(commit.sha) ?? str(commit.id) : undefined;
831
- },
1044
+ branchHead: branchTip,
832
1045
  async scheduledRuns(q) {
833
1046
  const file = encodeURIComponent(q.workflow);
834
1047
  if (kind === "github") {
@@ -998,6 +1211,22 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
998
1211
  await api.call("PUT", `${project}/issues/${n}`, { state_event: "close" });
999
1212
  },
1000
1213
  };
1214
+ const authorsOf = async (mr: number): Promise<string[]> => {
1215
+ const logins = new Set<string>();
1216
+ const merge = await api.call("GET", `${notes}/${mr}`);
1217
+ const author = isObject(merge) ? authorOf(merge) : undefined;
1218
+ if (author) logins.add(author);
1219
+ const emails = new Set<string>();
1220
+ for (const c of await api.list(`${notes}/${mr}/commits`, pageParams, 100)) {
1221
+ if (!isObject(c)) continue;
1222
+ for (const email of [str(c.author_email), str(c.committer_email)]) if (email) emails.add(email.toLowerCase());
1223
+ }
1224
+ for (const email of [...emails].sort()) {
1225
+ const users = await api.call("GET", `/users?search=${encodeURIComponent(email)}`);
1226
+ for (const u of Array.isArray(users) ? users : []) if (isObject(u) && typeof u.username === "string") logins.add(u.username);
1227
+ }
1228
+ return [...logins].sort();
1229
+ };
1001
1230
  return {
1002
1231
  kind: "gitlab",
1003
1232
  host,
@@ -1029,21 +1258,90 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
1029
1258
  .filter((l): l is string => l !== undefined);
1030
1259
  return [...new Set(logins)].sort();
1031
1260
  },
1032
- async authors(mr) {
1033
- const logins = new Set<string>();
1034
- const merge = await api.call("GET", `${notes}/${mr}`);
1035
- const author = isObject(merge) ? authorOf(merge) : undefined;
1036
- if (author) logins.add(author);
1037
- const emails = new Set<string>();
1038
- for (const c of await api.list(`${notes}/${mr}/commits`, pageParams, 100)) {
1039
- if (!isObject(c)) continue;
1040
- for (const email of [str(c.author_email), str(c.committer_email)]) if (email) emails.add(email.toLowerCase());
1261
+ authors: authorsOf,
1262
+ async prApproved(mr, head, approvers) {
1263
+ const got = await api.call("GET", `${notes}/${mr}`);
1264
+ const at = isObject(got) ? str(got.sha) : undefined;
1265
+ if (at !== head) return moved(`merge request !${mr}`, head, at);
1266
+ // The token's own user approves for the pipeline, never as a reviewer.
1267
+ const me = await api.call("GET", "/user");
1268
+ const self = isObject(me) ? str(me.username) : undefined;
1269
+ if (!self) return refuse("GitLab did not say which user the job's token acts as, so no approval counts");
1270
+ // An approval counts once it came after the latest push: the newest version of the diff.
1271
+ const versions = await api.call("GET", `${notes}/${mr}/versions`);
1272
+ const since = Math.max(-Infinity, ...(Array.isArray(versions) ? versions : []).map((v) => Date.parse((isObject(v) ? str(v.created_at) : undefined) ?? "")).filter(Number.isFinite));
1273
+ const said = new Map<string, { what: string; at: number; id?: number }>();
1274
+ for (const n of await api.list(`${notes}/${mr}/notes?sort=asc&order_by=created_at`, pageParams, 100)) {
1275
+ if (!isObject(n) || n.system !== true) continue;
1276
+ const who = authorOf(n);
1277
+ const body = (str(n.body) ?? "").trim();
1278
+ const what = /^approved this merge request/.test(body) ? "approved" : /^unapproved this merge request/.test(body) ? "unapproved" : /^requested changes/.test(body) ? "changes" : undefined;
1279
+ const when = Date.parse(str(n.created_at) ?? "");
1280
+ if (!who || who === self || !what || !Number.isFinite(when)) continue;
1281
+ const before = said.get(who);
1282
+ const id = isObject(n.author) ? num(n.author.id) : undefined;
1283
+ if (!before || when >= before.at) said.set(who, { what, at: when, ...(id !== undefined ? { id } : {}) });
1284
+ }
1285
+ const changes = [...said].filter(([, v]) => v.what === "changes").map(([w]) => w).sort();
1286
+ if (changes.length > 0) return refuse(`${changes.join(", ")} requested changes`);
1287
+ const authors = new Set(await authorsOf(mr));
1288
+ const approved = [...said].filter(([, v]) => v.what === "approved" && v.at > since).map(([w]) => w).sort();
1289
+ const least = approvers?.minAccessLevel ?? GITLAB_DEVELOPER;
1290
+ const rule = async (login: string): Promise<string | undefined> => {
1291
+ const id = said.get(login)?.id;
1292
+ if (id === undefined) return `GitLab did not say who ${login} is`;
1293
+ let member: unknown;
1294
+ try {
1295
+ member = await api.call("GET", `${project}/members/all/${id}`);
1296
+ } catch (err) {
1297
+ if (err instanceof ForgeApiError && err.status === 404) return `${login} is not a member`;
1298
+ return `could not read ${login}'s access (${err instanceof Error ? err.message : String(err)})`;
1299
+ }
1300
+ const level = isObject(member) ? (num(member.access_level) ?? 0) : 0;
1301
+ return level >= least ? undefined : `${login} has access level ${level}, under ${least}`;
1302
+ };
1303
+ const { by, not } = await countApprovers(approved, authors, approvers, rule);
1304
+ if (by.length === 0) return noApprover(`no member other than its authors approved it after its latest push, ${short(head)}`, not);
1305
+ return pass(`approved after ${short(head)} by ${by.join(", ")}`);
1306
+ },
1307
+ async prMergeable(mr, head) {
1308
+ const got = await api.call("GET", `${notes}/${mr}`);
1309
+ const m = isObject(got) ? got : {};
1310
+ if (str(m.sha) !== head) return moved(`merge request !${mr}`, head, str(m.sha));
1311
+ const status = str(m.detailed_merge_status) ?? (m.merge_status === "can_be_merged" ? "mergeable" : (str(m.merge_status) ?? ""));
1312
+ const into = str(m.target_branch) ?? "its target branch";
1313
+ if (status === "conflict" || m.has_conflicts === true) return refuse(`GitLab reports conflicts with ${into}`);
1314
+ if (GITLAB_CHECKING.has(status)) return refuse("GitLab has not worked out yet whether it can merge", true);
1315
+ if (status !== "mergeable") return refuse(`GitLab says ${status || "nothing"} about merging it`);
1316
+ return pass(`it merges into ${into} as it stands`);
1317
+ },
1318
+ async prUndiverged(mr, head) {
1319
+ const got = await api.call("GET", `${notes}/${mr}?include_diverged_commits_count=true`);
1320
+ const m = isObject(got) ? got : {};
1321
+ if (str(m.sha) !== head) return moved(`merge request !${mr}`, head, str(m.sha));
1322
+ const into = str(m.target_branch) ?? "its target branch";
1323
+ const behind = num(m.diverged_commits_count);
1324
+ if (behind === undefined) return refuse(`GitLab did not say how far ${short(head)} is behind ${into}`);
1325
+ return behind === 0 ? pass(`${short(head)} contains ${into}`) : refuse(`${short(head)} is ${behind} commit${behind === 1 ? "" : "s"} behind ${into}`);
1326
+ },
1327
+ async prChecksPassed(mr, head, ignore = []) {
1328
+ const skip = new Set(ignore);
1329
+ // Each name's newest status; a job allowed to fail holds nothing back.
1330
+ const newest = new Map<string, Record<string, unknown>>();
1331
+ for (const st of await api.list(`${project}/repository/commits/${head}/statuses?all=true`, pageParams, 100)) {
1332
+ const name = isObject(st) ? str(st.name) : undefined;
1333
+ if (!name || skip.has(name) || !isObject(st)) continue;
1334
+ const had = newest.get(name);
1335
+ if (!had || (num(st.id) ?? 0) > (num(had.id) ?? 0)) newest.set(name, st);
1041
1336
  }
1042
- for (const email of [...emails].sort()) {
1043
- const users = await api.call("GET", `/users?search=${encodeURIComponent(email)}`);
1044
- for (const u of Array.isArray(users) ? users : []) if (isObject(u) && typeof u.username === "string") logins.add(u.username);
1337
+ const failing: string[] = [];
1338
+ const waiting: string[] = [];
1339
+ for (const [name, st] of newest) {
1340
+ const state = str(st.status) ?? "";
1341
+ if ((state === "failed" || state === "canceled") && st.allow_failure !== true) failing.push(name);
1342
+ else if (GITLAB_RUNNING.has(state)) waiting.push(name);
1045
1343
  }
1046
- return [...logins].sort();
1344
+ return checksVerdict(`merge request !${mr}`, head, failing, waiting, newest.size);
1047
1345
  },
1048
1346
  async pullRequestOpen(mr) {
1049
1347
  try {
@@ -41,7 +41,7 @@ describe("the pull-request loop bundle", () => {
41
41
  "packages/core/src/pr-forge.ts",
42
42
  "packages/core/src/pr-loop.ts",
43
43
  ]);
44
- // About 76 KB today, after the hooks line (#4288), the blocked set (#4291) and the scheduled-run reads (#4289).
45
- expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(80 * 1024);
44
+ // About 89 KB today, after the hooks line (#4288), the blocked set (#4291), the scheduled-run reads (#4289) and the requires checks (#4301).
45
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(96 * 1024);
46
46
  });
47
47
  });
package/src/pr-loop.ts CHANGED
@@ -48,6 +48,7 @@
48
48
  */
49
49
 
50
50
  import type { ChangeSetAction, ChangeSetDocument } from "./change-set";
51
+ import type { PrRequireOutcome } from "./pr-requires";
51
52
  import { GITHUB_COMMENT_LIMIT, groupChangeSet, renderPlanSummaryMarkdown } from "./plan-summary";
52
53
 
53
54
  /** The schema a pull-request report names in `$schema`. */
@@ -80,6 +81,9 @@ export const PR_REPORT_OVERDUE_FLOOR = "0.124.41";
80
81
  /** The first chant whose report can carry `policy` and the `policy-denied` and `policy-error` refusals (#4294). */
81
82
  export const PR_REPORT_POLICY_FLOOR = "0.124.41";
82
83
 
84
+ /** The first chant whose apply report can carry `beforeMerge` and the `requires-unmet` and `before-merge-off` refusals (#4301, ws-106). */
85
+ export const PR_REPORT_BEFORE_MERGE_FLOOR = "0.124.42";
86
+
83
87
  /** The gate a pull request's apply waits on, unless the pipeline names another. */
84
88
  export const PR_APPLY_GATE = "pr-apply";
85
89
 
@@ -275,7 +279,16 @@ export type PrRefusal =
275
279
  /** `ci.policy` at base denied the plan (#4294), and no override of it counts. */
276
280
  | "policy-denied"
277
281
  /** `ci.policy` at base could not be run, or gave no answer chant can read (#4294). */
278
- | "policy-error";
282
+ | "policy-error"
283
+ /** `pr-apply --before-merge` (#4301): the pull request is not approved, mergeable, undiverged or green; `beforeMerge` says which. */
284
+ | "requires-unmet"
285
+ /** `pr-apply --before-merge`: the declaration at base does not set `ci.workflow` to `apply-before-merge` for the member. */
286
+ | "before-merge-off";
287
+
288
+ /** An apply before merge (#4301, ws-106): the requires checks the forge answered, in order. */
289
+ export interface PrBeforeMerge {
290
+ requires: PrRequireOutcome[];
291
+ }
279
292
 
280
293
  /**
281
294
  * One wave of an apply with `--wave-gate` (#4003): what it planned, where it
@@ -445,6 +458,12 @@ export interface PrReport {
445
458
  * within contract 1 by chant {@link PR_REPORT_POLICY_FLOOR}.
446
459
  */
447
460
  policy?: PrPolicyReport;
461
+ /**
462
+ * `pr-apply --before-merge` (#4301): the requires checks. Absent on every
463
+ * other stage. Added within contract 1 by chant
464
+ * {@link PR_REPORT_BEFORE_MERGE_FLOOR}.
465
+ */
466
+ beforeMerge?: PrBeforeMerge;
448
467
  }
449
468
 
450
469
  /** The counts a member's entries add up to. */
@@ -771,6 +790,11 @@ export function prStatusDescription(report: PrReport): string {
771
790
  text = "refused: an earlier wave has not applied";
772
791
  break;
773
792
  }
793
+ if (report.refusal === "requires-unmet" || report.refusal === "before-merge-off") {
794
+ const unmet = (report.beforeMerge?.requires ?? []).filter((r) => !r.passed).map((r) => r.name);
795
+ text = report.refusal === "before-merge-off" ? "refused: this workspace does not apply before merge" : `refused: ${unmet.join(", ") || "a require"} unmet for an apply before merge`;
796
+ break;
797
+ }
774
798
  if (report.refusal === "policy-denied" || report.refusal === "policy-error") {
775
799
  text = report.refusal === "policy-denied" ? `refused: the policy denied the plan (${(report.policy?.rules ?? []).join(", ")})` : "refused: the policy could not be checked";
776
800
  break;