@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.
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +3 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +27 -3
- package/dist/cli/handlers/pr.d.ts +3 -1
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +113 -2
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +11 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +8 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +15 -0
- package/dist/cli/registry.d.ts +19 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability.d.ts +47 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +44 -0
- package/dist/components/cli-support.d.ts +6 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +8 -1
- package/dist/components/driver.d.ts +10 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +52 -2
- package/dist/components/fan-out-run.d.ts +9 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +17 -1
- package/dist/components/gate-plan.d.ts +2 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +1 -0
- package/dist/components/pr-pipeline.d.ts +20 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +54 -16
- package/dist/components/pr-run.d.ts +6 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +3 -0
- package/dist/components/wave-plan.d.ts +14 -1
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +34 -2
- package/dist/op/activities/pr-refs.d.ts +10 -0
- package/dist/op/activities/pr-refs.d.ts.map +1 -0
- package/dist/op/activities/pr-refs.js +26 -0
- package/dist/op/activities/predict-behaviour.d.ts +2 -4
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.js +2 -21
- package/dist/op/activities/review-agent.js +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/op-waves-run.d.ts +3 -79
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +4 -133
- package/dist/op/stage-hooks.d.ts +152 -0
- package/dist/op/stage-hooks.d.ts.map +1 -0
- package/dist/op/stage-hooks.js +199 -0
- package/dist/pr-forge.d.ts +56 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +320 -45
- package/dist/pr-loop.d.ts +18 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +7 -0
- package/dist/pr-requires.d.ts +47 -0
- package/dist/pr-requires.d.ts.map +1 -0
- package/dist/pr-requires.js +53 -0
- package/dist/wave-runtime.mjs +213 -213
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +19 -4
- package/dist/workspace/ci-init.d.ts.map +1 -1
- package/dist/workspace/ci-init.js +5 -4
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-workflow-at.d.ts +14 -0
- package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
- package/dist/workspace/ci-workflow-at.js +17 -0
- package/dist/workspace/ci-workflow.d.ts +11 -3
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +10 -2
- package/dist/workspace/declaration.d.ts +6 -3
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +2 -2
- package/dist/workspace/pr-report.schema.json +23 -3
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/build.ts +3 -0
- package/src/cli/handlers/fan-out.ts +27 -3
- package/src/cli/handlers/pr-before-merge.test.ts +276 -0
- package/src/cli/handlers/pr.ts +110 -2
- package/src/cli/handlers/run.ts +10 -0
- package/src/cli/main.ts +8 -2
- package/src/cli/parse-args.ts +11 -0
- package/src/cli/registry.ts +19 -0
- package/src/components/capability-targets.test.ts +162 -0
- package/src/components/capability.ts +71 -0
- package/src/components/cli-support.ts +14 -1
- package/src/components/driver.ts +54 -3
- package/src/components/fan-out-run.ts +19 -1
- package/src/components/gate-plan.ts +3 -0
- package/src/components/pr-pipeline.ts +64 -16
- package/src/components/pr-run.ts +9 -0
- package/src/components/wave-plan.ts +38 -3
- package/src/op/activities/pr-refs.ts +28 -0
- package/src/op/activities/predict-behaviour.ts +3 -23
- package/src/op/activities/review-agent.ts +1 -1
- package/src/op/index.ts +4 -0
- package/src/op/op-waves-run.ts +24 -228
- package/src/op/stage-hooks-async.test.ts +79 -0
- package/src/op/stage-hooks.ts +346 -0
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.ts +334 -36
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.ts +25 -1
- package/src/pr-requires.test.ts +248 -0
- package/src/pr-requires.ts +93 -0
- package/src/review-agent-bundle.test.ts +35 -0
- package/src/stage-hooks-bundle.test.ts +38 -0
- package/src/workspace/checks/ci-workflow.test.ts +9 -0
- package/src/workspace/checks/ci-workflow.ts +18 -4
- package/src/workspace/ci-init.ts +5 -4
- package/src/workspace/ci-run.ts +2 -1
- package/src/workspace/ci-workflow-at.ts +23 -0
- package/src/workspace/ci-workflow.ts +13 -2
- package/src/workspace/declaration.schema.json +2 -2
- package/src/workspace/declaration.ts +6 -3
- package/src/workspace/pr-report.schema.json +23 -3
- 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
|
-
|
|
724
|
-
|
|
725
|
-
const
|
|
726
|
-
|
|
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
|
|
729
|
-
if (
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
const
|
|
1035
|
-
const
|
|
1036
|
-
if (
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
|
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
|
|
45
|
-
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(
|
|
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;
|