@intentius/chant 0.124.7 → 0.124.9
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/op/gate.d.ts +16 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +2 -1
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +57 -7
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +42 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/pr-forge.d.ts +29 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/identity.d.ts +3 -2
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +3 -2
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/pr-run.ts +1 -1
- package/src/lifecycle/converge-ledger.ts +1 -1
- package/src/lifecycle/gate-ledger.test.ts +37 -0
- package/src/lifecycle/gate-ledger.ts +1 -1
- package/src/lifecycle/git.ts +1 -1
- package/src/lifecycle/override-ledger.ts +1 -1
- package/src/lifecycle/run-ledger.ts +1 -1
- package/src/lifecycle/work-lease.ts +1 -1
- package/src/op/gate-review.test.ts +53 -1
- package/src/op/gate.ts +31 -9
- package/src/op/op-waves-notify.ts +13 -1
- package/src/op/op-waves-point.test.ts +254 -0
- package/src/op/op-waves-run.ts +231 -40
- package/src/op/op-waves.test.ts +95 -2
- package/src/op/op-waves.ts +68 -0
- package/src/pr-forge.test.ts +58 -0
- package/src/pr-forge.ts +113 -2
- package/src/workspace/identity.test.ts +4 -1
- package/src/workspace/identity.ts +4 -3
- package/src/workspace/write-scope.ts +4 -3
package/src/op/op-waves.ts
CHANGED
|
@@ -53,9 +53,21 @@
|
|
|
53
53
|
* apply; like the policy they are read from the spec at the base commit, and
|
|
54
54
|
* one whose `onFailure` is `approve` holds the wave for an approval whatever
|
|
55
55
|
* its policy says.
|
|
56
|
+
*
|
|
57
|
+
* ## A gate that asks a decision point (#3836)
|
|
58
|
+
*
|
|
59
|
+
* With `point` (on the spec, or on a wave, whose own wins), read at base like
|
|
60
|
+
* the policy, a wave that needs an approval asks that workspace decision
|
|
61
|
+
* point instead of reading the ledger for a `chant approve`, the way an Op
|
|
62
|
+
* gate with a `point` does (#3170): `gate.planDigest` is the wave's set
|
|
63
|
+
* digest, so each plan is its own question. An open question waits (exit 3,
|
|
64
|
+
* the pending fact cites it), an answer the gate passes on records the
|
|
65
|
+
* resolution that cites it and the wave goes on, and any other answer refuses
|
|
66
|
+
* the wave (exit 8). See `./op-waves-run.ts`.
|
|
56
67
|
*/
|
|
57
68
|
|
|
58
69
|
import type { OpEnvironment, OpSetupStep } from "../lexicon";
|
|
70
|
+
import { gatePointProblems, type GatePoint } from "./gate-point";
|
|
59
71
|
import { notifyConfigProblem, type NotifyConfig } from "../notify";
|
|
60
72
|
|
|
61
73
|
/**
|
|
@@ -92,6 +104,40 @@ export const GATE_APPROVAL_SOURCES: readonly GateApprovalSource[] = ["ledger", "
|
|
|
92
104
|
/** What a failing hook does: fail the wave, or hold it for an approval of its set digest. */
|
|
93
105
|
export type OpWaveHookFailure = "fail" | "approve";
|
|
94
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The decision point a wave's gate asks (#3836): a point's name, or `{ name,
|
|
109
|
+
* inputs?, pass?, store?, timeout? }` as an Op gate's `point` takes, plus the
|
|
110
|
+
* pending fact's `timeout` (default 48h). The point must declare the input
|
|
111
|
+
* `gate.planDigest`, which is the wave's set digest.
|
|
112
|
+
*/
|
|
113
|
+
export type OpWavePoint = GatePoint & {
|
|
114
|
+
/** How long the pending fact that cites an open question stands, such as `"48h"`. */
|
|
115
|
+
timeout?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** A wave's or a spec's `point` as an {@link OpWavePoint}, or undefined when it names none. */
|
|
119
|
+
export function opWavePointOf(point: string | OpWavePoint | undefined): OpWavePoint | undefined {
|
|
120
|
+
if (point === undefined) return undefined;
|
|
121
|
+
return typeof point === "string" ? { name: point } : point;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What is wrong with a wave's `point`, empty when nothing is (#3836). */
|
|
125
|
+
export function opWavePointProblems(point: unknown): string[] {
|
|
126
|
+
if (point !== null && typeof point === "object" && !Array.isArray(point) && "timeout" in point) {
|
|
127
|
+
const { timeout, ...rest } = point as Record<string, unknown>;
|
|
128
|
+
const problems = gatePointProblems(rest);
|
|
129
|
+
if (typeof timeout !== "string" || !/^(\d+(ms|s|m|h|d))+$/.test(timeout)) problems.push("`point.timeout` is a duration such as \"48h\"");
|
|
130
|
+
return problems;
|
|
131
|
+
}
|
|
132
|
+
return gatePointProblems(point);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The point wave `name`'s gate asks (#3836): the wave's own, else the spec's, else none. */
|
|
136
|
+
export function opWavePointAt(spec: Pick<OpWavesSpec, "point" | "waves">, name: string): OpWavePoint | undefined {
|
|
137
|
+
const wave = spec.waves.find((w) => w.name === name);
|
|
138
|
+
return opWavePointOf(wave?.point ?? spec.point);
|
|
139
|
+
}
|
|
140
|
+
|
|
95
141
|
export const OP_WAVE_HOOK_FAILURES: readonly OpWaveHookFailure[] = ["fail", "approve"];
|
|
96
142
|
|
|
97
143
|
/** One stage hook (#3762): a command run before or after a wave's plan or apply. */
|
|
@@ -181,6 +227,11 @@ export interface OpWave {
|
|
|
181
227
|
* Like `gate`, the runner reads it from the spec file at the base commit.
|
|
182
228
|
*/
|
|
183
229
|
approval?: GateApprovalSource;
|
|
230
|
+
/**
|
|
231
|
+
* The decision point this wave's gate asks (#3836), in place of the spec's.
|
|
232
|
+
* Read at base like `gate`. See {@link OpWavesSpec.point}.
|
|
233
|
+
*/
|
|
234
|
+
point?: string | OpWavePoint;
|
|
184
235
|
/**
|
|
185
236
|
* Split the wave into a deciding job and this many share jobs, each
|
|
186
237
|
* applying a slice of the runs. Default 1: one job plans, decides and
|
|
@@ -266,6 +317,15 @@ export interface OpWavesSpec {
|
|
|
266
317
|
* the rules (`@intentius/chant/override-gate`).
|
|
267
318
|
*/
|
|
268
319
|
override?: OpWavesOverride;
|
|
320
|
+
/**
|
|
321
|
+
* The decision point every wave's gate asks (#3836), unless a wave names its
|
|
322
|
+
* own. Read at the base commit, like the policy: a wave that needs an
|
|
323
|
+
* approval asks the point with `gate.planDigest` set to its set digest, and
|
|
324
|
+
* only an answer the point's `pass` names (default yes) for that digest
|
|
325
|
+
* lets it apply. `chant approve` does not pass such a gate, and the wave's
|
|
326
|
+
* `approval` mode does not apply: the point's deciders and quorum decide.
|
|
327
|
+
*/
|
|
328
|
+
point?: string | OpWavePoint;
|
|
269
329
|
waves: OpWave[];
|
|
270
330
|
/**
|
|
271
331
|
* A scheduled job that starts again a wave job whose approval has arrived
|
|
@@ -442,6 +502,10 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
442
502
|
if (typeof value !== "string" || value.trim() === "") throw new Error(`${where}: override.overriders must be a path, such as ".chant/overriders".`);
|
|
443
503
|
}
|
|
444
504
|
}
|
|
505
|
+
if (spec.point !== undefined) {
|
|
506
|
+
const problems = opWavePointProblems(spec.point);
|
|
507
|
+
if (problems.length > 0) throw new Error(`${where}: ${problems.join("; ")}.`);
|
|
508
|
+
}
|
|
445
509
|
if (spec.neverOverMcp !== undefined && typeof spec.neverOverMcp !== "boolean") throw new Error(`${where}: neverOverMcp must be true or false.`);
|
|
446
510
|
if (spec.notify !== undefined) {
|
|
447
511
|
const problem = notifyConfigProblem(spec.notify, "notify");
|
|
@@ -461,6 +525,10 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
461
525
|
throw new Error(`${where}: wave "${wave.name}" has approval "${wave.approval}"; use ${GATE_APPROVAL_SOURCES.join(", ")}.`);
|
|
462
526
|
}
|
|
463
527
|
assertOpWaveHooks(`${where}, wave "${wave.name}"`, wave.hooks);
|
|
528
|
+
if (wave.point !== undefined) {
|
|
529
|
+
const problems = opWavePointProblems(wave.point);
|
|
530
|
+
if (problems.length > 0) throw new Error(`${where}: wave "${wave.name}": ${problems.join("; ")}.`);
|
|
531
|
+
}
|
|
464
532
|
if (wave.discover !== undefined && typeof wave.discover !== "boolean") throw new Error(`${where}: wave "${wave.name}" has discover ${JSON.stringify(wave.discover)}; use true or false.`);
|
|
465
533
|
if (wave.rest !== undefined && typeof wave.rest !== "boolean") throw new Error(`${where}: wave "${wave.name}" has rest ${JSON.stringify(wave.rest)}; use true or false.`);
|
|
466
534
|
if (wave.discover) {
|
package/src/pr-forge.test.ts
CHANGED
|
@@ -824,6 +824,64 @@ describe("pull requests over REST (#3798)", () => {
|
|
|
824
824
|
expect(l.calls.map((c) => c.body)).toEqual([{ description: "d" }, { add_labels: "minor,breaking" }]);
|
|
825
825
|
});
|
|
826
826
|
|
|
827
|
+
test("Forgejo's 404 on the pull list of a just-pushed empty repository is no pull request (#3845)", async () => {
|
|
828
|
+
const empty = fakeFetch({ "GET /api/v1/repos/acme/infra": { default_branch: "main" } });
|
|
829
|
+
expect(await forgejoForge({ ...fjOpts, fetch: empty.fetch }).findPullRequest("chant/x")).toBeUndefined();
|
|
830
|
+
// A repository that is really missing 404s on its own read, and that still throws.
|
|
831
|
+
const gone = fakeFetch({});
|
|
832
|
+
await expect(forgejoForge({ ...fjOpts, fetch: gone.fetch }).findPullRequest("chant/x")).rejects.toThrow(/404/);
|
|
833
|
+
// GitHub's list 404 stays an error.
|
|
834
|
+
await expect(githubForge({ ...gh, fetch: fakeFetch({}).fetch }).findPullRequest("chant/x")).rejects.toThrow(/404/);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
const answering = (status: number, body: unknown, calls: Call[] = []): ForgeFetch => async (url, init) => {
|
|
838
|
+
calls.push({ method: init.method, url, headers: init.headers, ...(init.body ? { body: JSON.parse(init.body) } : {}) });
|
|
839
|
+
return { ok: status < 400, status, text: async () => (body === undefined ? "" : JSON.stringify(body)) };
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
test("mergePullRequest merges only at the reviewed sha on GitHub", async () => {
|
|
843
|
+
const f = fakeFetch({
|
|
844
|
+
"PUT /repos/acme/infra/pulls/5/merge": { merged: true, sha: "m1" },
|
|
845
|
+
"GET /repos/acme/infra/pulls/5": { number: 5, head: { ref: "chant/x", repo: { full_name: "acme/infra" } } },
|
|
846
|
+
"DELETE /repos/acme/infra/git/refs/heads/chant/x": null,
|
|
847
|
+
});
|
|
848
|
+
const r = await githubForge({ ...gh, fetch: f.fetch }).mergePullRequest(5, { sha: SHA, method: "squash", title: "t", message: "m", deleteBranch: true });
|
|
849
|
+
expect(r).toEqual({ merged: true, sha: "m1" });
|
|
850
|
+
expect(f.calls[0].body).toEqual({ sha: SHA, merge_method: "squash", commit_title: "t", commit_message: "m" });
|
|
851
|
+
expect(f.calls.map((c) => c.method)).toEqual(["PUT", "GET", "DELETE"]);
|
|
852
|
+
const calls: Call[] = [];
|
|
853
|
+
const moved = await githubForge({ ...gh, fetch: answering(409, { message: "Head branch was modified. Review and try the merge again." }, calls) }).mergePullRequest(5, { sha: SHA });
|
|
854
|
+
expect(moved).toEqual({ merged: false, reason: "Head branch was modified. Review and try the merge again." });
|
|
855
|
+
expect(calls[0].body).toEqual({ sha: SHA, merge_method: "merge" });
|
|
856
|
+
const blocked = await githubForge({ ...gh, fetch: answering(405, { message: "Required status check is expected." }) }).mergePullRequest(5, { sha: SHA });
|
|
857
|
+
expect(blocked.merged).toBe(false);
|
|
858
|
+
await expect(githubForge({ ...gh, fetch: answering(401, { message: "Bad credentials" }) }).mergePullRequest(5, { sha: SHA })).rejects.toThrow(/401/);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test("mergePullRequest on Forgejo sends head_commit_id and reads the merge commit back", async () => {
|
|
862
|
+
const f = fakeFetch({
|
|
863
|
+
"POST /api/v1/repos/acme/infra/pulls/5/merge": null,
|
|
864
|
+
"GET /api/v1/repos/acme/infra/pulls/5": { number: 5, merge_commit_sha: "m2" },
|
|
865
|
+
});
|
|
866
|
+
const r = await forgejoForge({ ...fjOpts, fetch: f.fetch }).mergePullRequest(5, { sha: SHA, method: "rebase", title: "t", message: "m", deleteBranch: true });
|
|
867
|
+
expect(r).toEqual({ merged: true, sha: "m2" });
|
|
868
|
+
expect(f.calls[0].body).toEqual({ Do: "rebase", head_commit_id: SHA, MergeTitleField: "t", MergeMessageField: "m", delete_branch_after_merge: true });
|
|
869
|
+
const moved = await forgejoForge({ ...fjOpts, fetch: answering(409, { message: "head commit id does not match" }) }).mergePullRequest(5, { sha: SHA });
|
|
870
|
+
expect(moved).toEqual({ merged: false, reason: "head commit id does not match" });
|
|
871
|
+
expect((await forgejoForge({ ...fjOpts, fetch: answering(405, undefined) }).mergePullRequest(5, { sha: SHA })).merged).toBe(false);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("mergePullRequest on GitLab passes sha and squash, and refuses rebase", async () => {
|
|
875
|
+
const f = fakeFetch({ "PUT /api/v4/projects/group%2Finfra/merge_requests/7/merge": { state: "merged", merge_commit_sha: "m3" } });
|
|
876
|
+
const g = gitlabForge({ ...gl, fetch: f.fetch });
|
|
877
|
+
expect(await g.mergePullRequest(7, { sha: SHA, method: "squash", title: "t", message: "m", deleteBranch: true })).toEqual({ merged: true, sha: "m3" });
|
|
878
|
+
expect(f.calls[0].body).toEqual({ sha: SHA, squash: true, squash_commit_message: "t\n\nm", merge_commit_message: "t\n\nm", should_remove_source_branch: true });
|
|
879
|
+
const moved = await gitlabForge({ ...gl, fetch: answering(409, { message: "SHA does not match HEAD of source branch" }) }).mergePullRequest(7, { sha: SHA });
|
|
880
|
+
expect(moved).toEqual({ merged: false, reason: "SHA does not match HEAD of source branch" });
|
|
881
|
+
expect((await gitlabForge({ ...gl, fetch: answering(405, { message: "Branch cannot be merged" }) }).mergePullRequest(7, { sha: SHA })).merged).toBe(false);
|
|
882
|
+
await expect(g.mergePullRequest(7, { sha: SHA, method: "rebase" })).rejects.toThrow(/rebase/);
|
|
883
|
+
});
|
|
884
|
+
|
|
827
885
|
test("a repository with no default branch is refused by name", async () => {
|
|
828
886
|
const { fetch } = fakeFetch({ "GET /api/v1/repos/acme/infra": {} });
|
|
829
887
|
await expect(forgejoForge({ ...fjOpts, fetch }).openPullRequest({ head: "h", title: "t", body: "b" })).rejects.toThrow(/no default branch.*pass base/);
|
package/src/pr-forge.ts
CHANGED
|
@@ -212,6 +212,48 @@ export interface PullRequest {
|
|
|
212
212
|
base?: string;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/** What {@link PrForge.mergePullRequest} merges, and how. */
|
|
216
|
+
export interface MergeOptions {
|
|
217
|
+
/** The head commit the caller reviewed. The merge happens only while the pull request's head is still this commit. */
|
|
218
|
+
sha: string;
|
|
219
|
+
/** Default `merge`. GitLab has no one-call rebase merge, so `rebase` is refused there. */
|
|
220
|
+
method?: "merge" | "squash" | "rebase";
|
|
221
|
+
/** The merge (squash) commit's title. */
|
|
222
|
+
title?: string;
|
|
223
|
+
/** The merge (squash) commit's message body. */
|
|
224
|
+
message?: string;
|
|
225
|
+
/** Delete the head branch after a merge. A failure to delete does not undo the merge. */
|
|
226
|
+
deleteBranch?: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The outcome of {@link PrForge.mergePullRequest}. */
|
|
230
|
+
export interface MergeResult {
|
|
231
|
+
merged: boolean;
|
|
232
|
+
/** The commit the merge produced, when the forge says. */
|
|
233
|
+
sha?: string;
|
|
234
|
+
/** Why it was not merged: the head moved, a conflict, a failing check, a required review. */
|
|
235
|
+
reason?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Statuses a forge answers a refused merge with: not allowed, head moved, conflict, invalid. */
|
|
239
|
+
const MERGE_REFUSED = new Set([405, 406, 409, 422]);
|
|
240
|
+
|
|
241
|
+
/** A refused merge as a result, any other failure rethrown. */
|
|
242
|
+
function refusedMerge(err: unknown): MergeResult {
|
|
243
|
+
if (err instanceof ForgeApiError && MERGE_REFUSED.has(err.status)) {
|
|
244
|
+
let reason = err.detail;
|
|
245
|
+
try {
|
|
246
|
+
const j = JSON.parse(err.detail) as Record<string, unknown>;
|
|
247
|
+
const m = j.message ?? j.error;
|
|
248
|
+
if (typeof m === "string") reason = m;
|
|
249
|
+
} catch {
|
|
250
|
+
/* not JSON: the raw text is the reason */
|
|
251
|
+
}
|
|
252
|
+
return { merged: false, reason: reason.slice(0, 300) || `the forge refused the merge (${err.status})` };
|
|
253
|
+
}
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
|
|
215
257
|
export interface PrForge {
|
|
216
258
|
kind: ForgeKind;
|
|
217
259
|
/** Lower-cased, such as `github.com`. */
|
|
@@ -290,6 +332,13 @@ export interface PrForge {
|
|
|
290
332
|
editPullRequest(pr: number, change: { title?: string; body?: string }): Promise<PullRequest | undefined>;
|
|
291
333
|
/** Add labels to a pull request by name. Forgejo needs them to exist; GitHub and GitLab make them. */
|
|
292
334
|
addLabels(pr: number, labels: readonly string[]): Promise<void>;
|
|
335
|
+
/**
|
|
336
|
+
* Merge pull request `pr` only if its head is still `opts.sha`. A moved
|
|
337
|
+
* head, a conflict or a branch rule the forge enforces comes back as
|
|
338
|
+
* `{ merged: false, reason }`; auth, missing-pull-request and server errors
|
|
339
|
+
* still throw.
|
|
340
|
+
*/
|
|
341
|
+
mergePullRequest(pr: number, opts: MergeOptions): Promise<MergeResult>;
|
|
293
342
|
}
|
|
294
343
|
|
|
295
344
|
/** A GitHub or Forgejo client. */
|
|
@@ -301,7 +350,7 @@ export class ForgeApiError extends Error {
|
|
|
301
350
|
readonly method: string,
|
|
302
351
|
readonly url: string,
|
|
303
352
|
readonly status: number,
|
|
304
|
-
detail: string,
|
|
353
|
+
readonly detail: string,
|
|
305
354
|
) {
|
|
306
355
|
super(`${method} ${url} answered ${status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
|
|
307
356
|
this.name = "ForgeApiError";
|
|
@@ -604,7 +653,18 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
604
653
|
// head filter, so every open page is read and matched here.
|
|
605
654
|
const owner = opts.repo.split("/")[0];
|
|
606
655
|
const query = kind === "github" ? `?state=open&head=${encodeURIComponent(`${owner}:${head}`)}` : "?state=open";
|
|
607
|
-
|
|
656
|
+
let listed: unknown[];
|
|
657
|
+
try {
|
|
658
|
+
listed = await api.list(`${repo}/pulls${query}`, pageParams, pageSize);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
// Forgejo answers the list with 404 while it still counts the repository
|
|
661
|
+
// as empty (just after the first push). That is no pull request; a
|
|
662
|
+
// repository that is really missing 404s on its own read too.
|
|
663
|
+
if (kind !== "forgejo" || !(err instanceof ForgeApiError) || err.status !== 404) throw err;
|
|
664
|
+
await api.call("GET", repo);
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
const open = listed.filter((p) => fromHere(p, head));
|
|
608
668
|
return open.map(pull).filter((p): p is PullRequest => p !== undefined).sort((a, b) => a.number - b.number)[0];
|
|
609
669
|
},
|
|
610
670
|
async editPullRequest(pr, change) {
|
|
@@ -614,6 +674,38 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
614
674
|
if (labels.length === 0) return;
|
|
615
675
|
await api.call("POST", `${repo}/issues/${pr}/labels`, { labels: [...labels] });
|
|
616
676
|
},
|
|
677
|
+
async mergePullRequest(pr, m) {
|
|
678
|
+
const method = m.method ?? "merge";
|
|
679
|
+
let res: unknown;
|
|
680
|
+
try {
|
|
681
|
+
res =
|
|
682
|
+
kind === "github"
|
|
683
|
+
? await api.call("PUT", `${repo}/pulls/${pr}/merge`, {
|
|
684
|
+
sha: m.sha,
|
|
685
|
+
merge_method: method,
|
|
686
|
+
...(m.title !== undefined ? { commit_title: m.title } : {}),
|
|
687
|
+
...(m.message !== undefined ? { commit_message: m.message } : {}),
|
|
688
|
+
})
|
|
689
|
+
: await api.call("POST", `${repo}/pulls/${pr}/merge`, {
|
|
690
|
+
Do: method,
|
|
691
|
+
head_commit_id: m.sha,
|
|
692
|
+
...(m.title !== undefined ? { MergeTitleField: m.title } : {}),
|
|
693
|
+
...(m.message !== undefined ? { MergeMessageField: m.message } : {}),
|
|
694
|
+
...(m.deleteBranch ? { delete_branch_after_merge: true } : {}),
|
|
695
|
+
});
|
|
696
|
+
} catch (err) {
|
|
697
|
+
return refusedMerge(err);
|
|
698
|
+
}
|
|
699
|
+
if (isObject(res) && res.merged === false) return { merged: false, reason: str(res.message) ?? "the forge did not merge it" };
|
|
700
|
+
// Forgejo answers an empty body, so the merge commit is read back.
|
|
701
|
+
const after = await api.call("GET", `${repo}/pulls/${pr}`).catch(() => undefined);
|
|
702
|
+
const sha = (isObject(res) ? str(res.sha) : undefined) ?? (isObject(after) ? str(after.merge_commit_sha) : undefined);
|
|
703
|
+
if (kind === "github" && m.deleteBranch && isObject(after) && isObject(after.head) && str(after.head.ref) && fromHere(after, str(after.head.ref) as string)) {
|
|
704
|
+
const ref = (str(after.head.ref) as string).split("/").map(encodeURIComponent).join("/");
|
|
705
|
+
await api.call("DELETE", `${repo}/git/refs/heads/${ref}`).catch(() => undefined);
|
|
706
|
+
}
|
|
707
|
+
return { merged: true, ...(sha ? { sha } : {}) };
|
|
708
|
+
},
|
|
617
709
|
};
|
|
618
710
|
}
|
|
619
711
|
|
|
@@ -773,6 +865,25 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
773
865
|
if (labels.length === 0) return;
|
|
774
866
|
await api.call("PUT", `${notes}/${mr}`, { add_labels: labels.join(",") });
|
|
775
867
|
},
|
|
868
|
+
async mergePullRequest(mr, m) {
|
|
869
|
+
const method = m.method ?? "merge";
|
|
870
|
+
if (method === "rebase") throw new Error("GitLab has no one-call rebase merge; use method merge or squash");
|
|
871
|
+
const text = m.title !== undefined ? (m.message ? `${m.title}\n\n${m.message}` : m.title) : m.message;
|
|
872
|
+
let res: unknown;
|
|
873
|
+
try {
|
|
874
|
+
res = await api.call("PUT", `${notes}/${mr}/merge`, {
|
|
875
|
+
sha: m.sha,
|
|
876
|
+
...(method === "squash" ? { squash: true, ...(text !== undefined ? { squash_commit_message: text } : {}) } : {}),
|
|
877
|
+
...(text !== undefined ? { merge_commit_message: text } : {}),
|
|
878
|
+
...(m.deleteBranch ? { should_remove_source_branch: true } : {}),
|
|
879
|
+
});
|
|
880
|
+
} catch (err) {
|
|
881
|
+
return refusedMerge(err);
|
|
882
|
+
}
|
|
883
|
+
const sha = isObject(res) ? (str(res.merge_commit_sha) ?? str(res.squash_commit_sha)) : undefined;
|
|
884
|
+
if (isObject(res) && res.state !== undefined && res.state !== "merged") return { merged: false, reason: `the merge request is ${String(res.state)}` };
|
|
885
|
+
return { merged: true, ...(sha ? { sha } : {}) };
|
|
886
|
+
},
|
|
776
887
|
};
|
|
777
888
|
}
|
|
778
889
|
|
|
@@ -202,7 +202,10 @@ describe.skipIf(!hasSshKeygen)("identity.gates", () => {
|
|
|
202
202
|
|
|
203
203
|
const roster = memoryGateLedgerPort({ pending: [pending], resolutions: [approval("alex")] });
|
|
204
204
|
expect((await evaluateGate(roster, input)).satisfied).toBe(true);
|
|
205
|
-
|
|
205
|
+
const held = await evaluateGate({ ...roster, approvalRule: async () => rule }, input);
|
|
206
|
+
expect(held.satisfied).toBe(false);
|
|
207
|
+
// #3841: the approval the rule refused is named, with why.
|
|
208
|
+
expect(!held.satisfied && held.refused?.map((x) => [x.resolution.resolvedBy, x.reason])).toEqual([["alex", expect.stringMatching(/not signed/)]]);
|
|
206
209
|
const sealed = memoryGateLedgerPort({ pending: [pending], resolutions: [approval("alex"), signed(alice, approval("github:alice"))] });
|
|
207
210
|
const check = await evaluateGate({ ...sealed, approvalRule: async () => rule }, input);
|
|
208
211
|
expect(check.satisfied && check.resolution.resolvedBy).toBe("github:alice");
|
|
@@ -179,8 +179,9 @@ export function gateAdmissionFrom(source: GateRuleSource, gate: string): GateAdm
|
|
|
179
179
|
* The rule for `gate` in the workspace holding `cwd`, read at base (the
|
|
180
180
|
* target branch tip), or null when there is no declaration or it names none.
|
|
181
181
|
* What a run, `chant approve` and `workspace status` ask before counting an
|
|
182
|
-
* approval.
|
|
182
|
+
* approval. `base` names the commit to read at instead, as an op wave reads
|
|
183
|
+
* every rule at its `--base` (#3841).
|
|
183
184
|
*/
|
|
184
|
-
export function gateAdmission(cwd: string, gate: string): GateAdmission | null {
|
|
185
|
-
return gateAdmissionFrom(scopeSource(cwd), gate);
|
|
185
|
+
export function gateAdmission(cwd: string, gate: string, base?: string): GateAdmission | null {
|
|
186
|
+
return gateAdmissionFrom(scopeSource(cwd, base), gate);
|
|
186
187
|
}
|
|
@@ -368,11 +368,12 @@ function readAt(cwd: string, at?: string): { declaration: Declaration; rootOnDis
|
|
|
368
368
|
* The scope for a write from `cwd`: the declaration and the trust policy at
|
|
369
369
|
* the base revision (`origin/HEAD`, `main` or `master`), so a write can't
|
|
370
370
|
* widen its own scope by editing its copy of the declaration. Without a base,
|
|
371
|
-
* or without a declaration at it, the working tree's declaration.
|
|
371
|
+
* or without a declaration at it, the working tree's declaration. `at` names
|
|
372
|
+
* the base instead, as an op wave's `--base` does (#3841).
|
|
372
373
|
*/
|
|
373
|
-
export function scopeSource(cwd: string): ScopeSource {
|
|
374
|
+
export function scopeSource(cwd: string, at?: string): ScopeSource {
|
|
374
375
|
const top = gitRoot(cwd);
|
|
375
|
-
const base = top ? resolveBase(top) : null;
|
|
376
|
+
const base = top ? resolveBase(top, at) : null;
|
|
376
377
|
const policy = top && base ? policyAtBase(top, base) : emptyPolicy(null);
|
|
377
378
|
const withClasses = (read: NonNullable<ReturnType<typeof readAt>>) => {
|
|
378
379
|
const { tree, ...rest } = read;
|