@intentius/chant 0.124.6 → 0.124.8
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/lifecycle/rollback.d.ts.map +1 -1
- package/dist/op/activities/forge-pull-request.d.ts +49 -0
- package/dist/op/activities/forge-pull-request.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +7 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +6 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/pr-forge.d.ts +101 -7
- package/dist/pr-forge.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/lifecycle/rollback.ts +10 -2
- package/src/op/activities/forge-pull-request.test.ts +91 -0
- package/src/op/activities/forge-pull-request.ts +98 -0
- package/src/op/activities/lexicon-upgrade.test.ts +58 -0
- package/src/op/activities/lexicon-upgrade.ts +34 -8
- package/src/op/activities/propose-upgrade.ts +13 -0
- package/src/op/activities/reconcile.ts +10 -2
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.test.ts +212 -0
- package/src/pr-forge.ts +291 -13
package/src/pr-forge.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* What chant and the products built on it ask of a forge: keep one comment
|
|
3
3
|
* (a merge request note on GitLab) on a pull request up to date, set a commit
|
|
4
4
|
* status, keep one tracking issue in the repository, list who approved a pull
|
|
5
|
-
* request,
|
|
5
|
+
* request, find the pull request that merged a commit, and open or edit a
|
|
6
|
+
* pull request (a merge request on GitLab) for a branch.
|
|
6
7
|
*
|
|
7
8
|
* The pull-request loop (#3183) uses the note, the status and the review
|
|
8
9
|
* calls. `reconcilePr`'s `comment` and `issue` finding modes post through the
|
|
@@ -21,6 +22,10 @@
|
|
|
21
22
|
* `gitlab:<login>`, or `<forge>@<host>:<login>` off the forge's default host.
|
|
22
23
|
* The apply compares that string with the approval's `resolvedBy`.
|
|
23
24
|
*
|
|
25
|
+
* Opening a pull request goes over REST too (#3798): `gh pr` speaks GitHub's
|
|
26
|
+
* GraphQL, which Forgejo does not serve, so chant's own pull-request paths
|
|
27
|
+
* open theirs through {@link PrForge.openPullRequest} on Forgejo and GitLab.
|
|
28
|
+
*
|
|
24
29
|
* Like `./pr-loop.ts`, this bundles without the TypeScript toolchain (#3421):
|
|
25
30
|
* it imports nothing but `node:fs`, to read a CI job's event payload.
|
|
26
31
|
* `./pr-forge-bundle.test.ts` holds it to that.
|
|
@@ -41,7 +46,13 @@ export type ForgeFetch = (
|
|
|
41
46
|
export interface ForgeStatus {
|
|
42
47
|
/** The status's name, such as `chant/plan` or `terragucci/plan`. */
|
|
43
48
|
context: string;
|
|
44
|
-
|
|
49
|
+
/**
|
|
50
|
+
* GitLab's own states, plus GitHub's `failure` and `error`. Each forge gets
|
|
51
|
+
* the nearest state it has ({@link statusStateFor}): `running` is `pending`
|
|
52
|
+
* on GitHub and Forgejo, `canceled` is `error` there, and `failure` and
|
|
53
|
+
* `error` are `failed` on GitLab.
|
|
54
|
+
*/
|
|
55
|
+
state: ForgeStatusState;
|
|
45
56
|
/** Cut to what the forge keeps: 140 characters on GitHub and Forgejo, 255 on GitLab. */
|
|
46
57
|
description: string;
|
|
47
58
|
/** The pipeline run, linked from the status. */
|
|
@@ -50,6 +61,30 @@ export interface ForgeStatus {
|
|
|
50
61
|
pipelineId?: number;
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
/** A commit status's state, as a caller names it. */
|
|
65
|
+
export type ForgeStatusState = "pending" | "running" | "success" | "failed" | "failure" | "canceled" | "error";
|
|
66
|
+
|
|
67
|
+
/** Every {@link ForgeStatusState}, for a caller that reads one from its own input. */
|
|
68
|
+
export const FORGE_STATUS_STATES: readonly ForgeStatusState[] = ["pending", "running", "success", "failed", "failure", "canceled", "error"];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What each forge is sent for a {@link ForgeStatusState} (#3834). GitHub
|
|
72
|
+
* keeps `pending`, `success`, `failure` and `error`; Forgejo the same and
|
|
73
|
+
* `warning`; GitLab `pending`, `running`, `success`, `failed` and `canceled`.
|
|
74
|
+
* A state the forge lacks becomes its nearest one.
|
|
75
|
+
*/
|
|
76
|
+
const STATUS_STATE: Record<"github" | "gitlab", Record<ForgeStatusState, string>> = {
|
|
77
|
+
github: { pending: "pending", running: "pending", success: "success", failed: "failure", failure: "failure", canceled: "error", error: "error" },
|
|
78
|
+
gitlab: { pending: "pending", running: "running", success: "success", failed: "failed", failure: "failed", canceled: "canceled", error: "failed" },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The state `kind` is sent for `state`. Forgejo takes GitHub's. */
|
|
82
|
+
export function statusStateFor(kind: ForgeKind, state: ForgeStatusState): string {
|
|
83
|
+
const mapped = STATUS_STATE[kind === "gitlab" ? "gitlab" : "github"][state];
|
|
84
|
+
if (mapped === undefined) throw new Error(`a commit status state is one of ${FORGE_STATUS_STATES.join(", ")}, not ${JSON.stringify(state)}`);
|
|
85
|
+
return mapped;
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
/**
|
|
54
89
|
* How a comment or an issue is told to be the caller's own. By default the
|
|
55
90
|
* body starts with the marker, chant's recipe since #2291, and anyone may
|
|
@@ -59,12 +94,15 @@ export interface MarkerMatch {
|
|
|
59
94
|
/** `prefix` (the default): the body starts with the marker. `anywhere`: the body carries it. */
|
|
60
95
|
match?: "prefix" | "anywhere";
|
|
61
96
|
/**
|
|
62
|
-
* Only a comment or issue written by this login
|
|
63
|
-
* GitHub and Forgejo, `author.username` on
|
|
64
|
-
* A merge request's author can write a note
|
|
65
|
-
* product that reads its own notes back names
|
|
97
|
+
* Only a comment or issue written by this login, or by any of these
|
|
98
|
+
* logins, counts: `user.login` on GitHub and Forgejo, `author.username` on
|
|
99
|
+
* GitLab, compared without case. A merge request's author can write a note
|
|
100
|
+
* carrying any marker, so a product that reads its own notes back names
|
|
101
|
+
* its bot here. A list is for a bot whose login the job cannot know in
|
|
102
|
+
* advance, such as `gitea-actions` or `forgejo-actions` on Forgejo (#3834).
|
|
103
|
+
* An empty list matches nobody.
|
|
66
104
|
*/
|
|
67
|
-
author?: string;
|
|
105
|
+
author?: string | readonly string[];
|
|
68
106
|
}
|
|
69
107
|
|
|
70
108
|
/** What {@link PrForge.upsertComment} did. */
|
|
@@ -146,6 +184,76 @@ export interface IssueResult {
|
|
|
146
184
|
created?: string;
|
|
147
185
|
}
|
|
148
186
|
|
|
187
|
+
/** A pull request (merge request) for {@link PrForge.openPullRequest} to open. */
|
|
188
|
+
export interface PullRequestSpec {
|
|
189
|
+
/** The branch with the change, in the same repository. */
|
|
190
|
+
head: string;
|
|
191
|
+
/** The branch it merges into. Default: the repository's default branch, read from the forge. */
|
|
192
|
+
base?: string;
|
|
193
|
+
title: string;
|
|
194
|
+
body: string;
|
|
195
|
+
/**
|
|
196
|
+
* Open it as a draft: GitHub's `draft`, GitLab's `Draft: ` title prefix,
|
|
197
|
+
* Forgejo's `WIP: ` title prefix (it has no draft field).
|
|
198
|
+
*/
|
|
199
|
+
draft?: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A pull request (merge request) as the forge answered it. */
|
|
203
|
+
export interface PullRequest {
|
|
204
|
+
/** The pull request's number, or the merge request's iid. */
|
|
205
|
+
number: number;
|
|
206
|
+
url?: string;
|
|
207
|
+
title: string;
|
|
208
|
+
body: string;
|
|
209
|
+
/** Its head (source) branch. */
|
|
210
|
+
head: string;
|
|
211
|
+
/** Its base (target) branch. */
|
|
212
|
+
base?: string;
|
|
213
|
+
}
|
|
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
|
+
|
|
149
257
|
export interface PrForge {
|
|
150
258
|
kind: ForgeKind;
|
|
151
259
|
/** Lower-cased, such as `github.com`. */
|
|
@@ -208,6 +316,29 @@ export interface PrForge {
|
|
|
208
316
|
approvers(pr: number): Promise<string[]>;
|
|
209
317
|
/** The merged pull request that produced `sha`, or null. */
|
|
210
318
|
pullRequestFor(sha: string): Promise<number | null>;
|
|
319
|
+
/**
|
|
320
|
+
* Open a pull request (a merge request on GitLab) from `head` into `base`,
|
|
321
|
+
* or into the default branch when `base` is not given. Over REST on every
|
|
322
|
+
* forge, so it works on Forgejo, where `gh pr create` cannot (#3798).
|
|
323
|
+
*/
|
|
324
|
+
openPullRequest(spec: PullRequestSpec): Promise<PullRequest>;
|
|
325
|
+
/**
|
|
326
|
+
* The OPEN pull request whose head is branch `head` in this repository (a
|
|
327
|
+
* fork's branch of the same name is not it), or undefined. The oldest when
|
|
328
|
+
* there are several.
|
|
329
|
+
*/
|
|
330
|
+
findPullRequest(head: string): Promise<PullRequest | undefined>;
|
|
331
|
+
/** Change an open pull request's title or body. */
|
|
332
|
+
editPullRequest(pr: number, change: { title?: string; body?: string }): Promise<PullRequest | undefined>;
|
|
333
|
+
/** Add labels to a pull request by name. Forgejo needs them to exist; GitHub and GitLab make them. */
|
|
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>;
|
|
211
342
|
}
|
|
212
343
|
|
|
213
344
|
/** A GitHub or Forgejo client. */
|
|
@@ -219,7 +350,7 @@ export class ForgeApiError extends Error {
|
|
|
219
350
|
readonly method: string,
|
|
220
351
|
readonly url: string,
|
|
221
352
|
readonly status: number,
|
|
222
|
-
detail: string,
|
|
353
|
+
readonly detail: string,
|
|
223
354
|
) {
|
|
224
355
|
super(`${method} ${url} answered ${status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
|
|
225
356
|
this.name = "ForgeApiError";
|
|
@@ -301,8 +432,15 @@ interface Threads {
|
|
|
301
432
|
const carries = (body: string, marker: string, match: MarkerMatch["match"]): boolean =>
|
|
302
433
|
match === "anywhere" ? body.includes(marker) : body.startsWith(marker);
|
|
303
434
|
|
|
435
|
+
/** Whether `author` is one of `want`, without case. No `want` (or `""`) takes anyone. */
|
|
436
|
+
const authorIn = (want: MarkerMatch["author"], author: string | undefined): boolean => {
|
|
437
|
+
if (want === undefined || want === "") return true;
|
|
438
|
+
const logins = (typeof want === "string" ? [want] : want).map((l) => l.toLowerCase());
|
|
439
|
+
return author !== undefined && logins.includes(author.toLowerCase());
|
|
440
|
+
};
|
|
441
|
+
|
|
304
442
|
const ownedBy = (marker: string, opts: MarkerMatch) => (t: Thread): boolean =>
|
|
305
|
-
carries(t.body, marker, opts.match) && (
|
|
443
|
+
carries(t.body, marker, opts.match) && authorIn(opts.author, t.author);
|
|
306
444
|
|
|
307
445
|
function checkMarker(what: string, marker: string, body: string, match: MarkerMatch["match"]): void {
|
|
308
446
|
if (!marker) throw new Error(`the ${what} needs a marker to be found again`);
|
|
@@ -420,6 +558,21 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
420
558
|
return { id, body: str(v.body) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
421
559
|
};
|
|
422
560
|
const threads = (items: unknown[]): Thread[] => items.map(thread).filter((t): t is Thread => t !== undefined);
|
|
561
|
+
const pull = (v: unknown): PullRequest | undefined => {
|
|
562
|
+
if (!isObject(v)) return undefined;
|
|
563
|
+
const number = num(v.number);
|
|
564
|
+
const head = isObject(v.head) ? str(v.head.ref) : undefined;
|
|
565
|
+
if (number === undefined) return undefined;
|
|
566
|
+
const base = isObject(v.base) ? str(v.base.ref) : undefined;
|
|
567
|
+
const url = str(v.html_url);
|
|
568
|
+
return { number, title: str(v.title) ?? "", body: str(v.body) ?? "", head: head ?? "", ...(base ? { base } : {}), ...(url ? { url } : {}) };
|
|
569
|
+
};
|
|
570
|
+
/** A pull request whose head branch lives in this repository, not a fork. */
|
|
571
|
+
const fromHere = (v: unknown, head: string): boolean => {
|
|
572
|
+
if (!isObject(v) || !isObject(v.head) || v.head.ref !== head) return false;
|
|
573
|
+
const full = isObject(v.head.repo) ? str(v.head.repo.full_name) : undefined;
|
|
574
|
+
return full === undefined || full.toLowerCase() === opts.repo.toLowerCase();
|
|
575
|
+
};
|
|
423
576
|
const t: Threads = {
|
|
424
577
|
comments: async (pr) => threads(await api.list(`${repo}/issues/${pr}/comments`, pageParams, pageSize)),
|
|
425
578
|
addComment: async (pr, body) => thread(await api.call("POST", `${repo}/issues/${pr}/comments`, { body })),
|
|
@@ -445,7 +598,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
445
598
|
...threadCalls(t),
|
|
446
599
|
async setStatus(sha, status) {
|
|
447
600
|
await api.call("POST", `${repo}/statuses/${sha}`, {
|
|
448
|
-
state: status.state,
|
|
601
|
+
state: statusStateFor(kind, status.state),
|
|
449
602
|
context: status.context,
|
|
450
603
|
description: status.description.slice(0, GITHUB_DESCRIPTION),
|
|
451
604
|
...(status.url ? { target_url: status.url } : {}),
|
|
@@ -483,9 +636,87 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
483
636
|
const pick = (exact ?? merged[0]) as Record<string, unknown> | undefined;
|
|
484
637
|
return pick && typeof pick.number === "number" ? pick.number : null;
|
|
485
638
|
},
|
|
639
|
+
async openPullRequest(spec) {
|
|
640
|
+
const base = spec.base ?? (await defaultBranchOf(api, repo, "default_branch"));
|
|
641
|
+
const title = spec.draft && kind === "forgejo" ? `WIP: ${spec.title}` : spec.title;
|
|
642
|
+
const made = await api.call("POST", `${repo}/pulls`, {
|
|
643
|
+
title,
|
|
644
|
+
body: spec.body,
|
|
645
|
+
head: spec.head,
|
|
646
|
+
base,
|
|
647
|
+
...(spec.draft && kind === "github" ? { draft: true } : {}),
|
|
648
|
+
});
|
|
649
|
+
return pull(made) ?? { number: 0, title, body: spec.body, head: spec.head, base };
|
|
650
|
+
},
|
|
651
|
+
async findPullRequest(head) {
|
|
652
|
+
// GitHub filters by `owner:branch` server-side; Forgejo's list takes no
|
|
653
|
+
// head filter, so every open page is read and matched here.
|
|
654
|
+
const owner = opts.repo.split("/")[0];
|
|
655
|
+
const query = kind === "github" ? `?state=open&head=${encodeURIComponent(`${owner}:${head}`)}` : "?state=open";
|
|
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));
|
|
668
|
+
return open.map(pull).filter((p): p is PullRequest => p !== undefined).sort((a, b) => a.number - b.number)[0];
|
|
669
|
+
},
|
|
670
|
+
async editPullRequest(pr, change) {
|
|
671
|
+
return pull(await api.call("PATCH", `${repo}/pulls/${pr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { body: change.body } : {}) }));
|
|
672
|
+
},
|
|
673
|
+
async addLabels(pr, labels) {
|
|
674
|
+
if (labels.length === 0) return;
|
|
675
|
+
await api.call("POST", `${repo}/issues/${pr}/labels`, { labels: [...labels] });
|
|
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
|
+
},
|
|
486
709
|
};
|
|
487
710
|
}
|
|
488
711
|
|
|
712
|
+
/** The repository's default branch, from `GET` on the repository (`/repos/{repo}`, `/projects/{id}`). */
|
|
713
|
+
async function defaultBranchOf(api: Transport, path: string, field: string): Promise<string> {
|
|
714
|
+
const r = await api.call("GET", path);
|
|
715
|
+
const branch = isObject(r) ? str(r[field]) : undefined;
|
|
716
|
+
if (!branch) throw new Error(`${path} names no default branch to open the pull request against; pass base`);
|
|
717
|
+
return branch;
|
|
718
|
+
}
|
|
719
|
+
|
|
489
720
|
/** A GitHub client (github.com or GitHub Enterprise Server). */
|
|
490
721
|
export function githubForge(opts: GithubForgeOptions): GithubShapedForge {
|
|
491
722
|
return githubShaped("github", opts);
|
|
@@ -515,8 +746,6 @@ export interface GitlabForgeOptions {
|
|
|
515
746
|
fetch?: ForgeFetch;
|
|
516
747
|
}
|
|
517
748
|
|
|
518
|
-
const GITLAB_STATE: Record<ForgeStatus["state"], string> = { pending: "pending", success: "success", failure: "failed" };
|
|
519
|
-
|
|
520
749
|
/**
|
|
521
750
|
* A GitLab client. Writing notes needs an access token with the `api` scope
|
|
522
751
|
* on most instances: the job token reaches only the endpoints the instance's
|
|
@@ -545,6 +774,14 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
545
774
|
return { id, body: str(v.description) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
546
775
|
};
|
|
547
776
|
const notes = `${project}/merge_requests`;
|
|
777
|
+
const mergeRequest = (v: unknown): PullRequest | undefined => {
|
|
778
|
+
if (!isObject(v)) return undefined;
|
|
779
|
+
const number = num(v.iid);
|
|
780
|
+
if (number === undefined) return undefined;
|
|
781
|
+
const base = str(v.target_branch);
|
|
782
|
+
const url = str(v.web_url);
|
|
783
|
+
return { number, title: str(v.title) ?? "", body: str(v.description) ?? "", head: str(v.source_branch) ?? "", ...(base ? { base } : {}), ...(url ? { url } : {}) };
|
|
784
|
+
};
|
|
548
785
|
const t: Threads = {
|
|
549
786
|
comments: async (mr) =>
|
|
550
787
|
(await api.list(`${notes}/${mr}/notes`, pageParams, 100))
|
|
@@ -577,7 +814,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
577
814
|
async setStatus(sha, status) {
|
|
578
815
|
try {
|
|
579
816
|
await api.call("POST", `${project}/statuses/${sha}`, {
|
|
580
|
-
state:
|
|
817
|
+
state: statusStateFor("gitlab", status.state),
|
|
581
818
|
name: status.context,
|
|
582
819
|
description: status.description.slice(0, GITLAB_DESCRIPTION),
|
|
583
820
|
...(status.url ? { target_url: status.url } : {}),
|
|
@@ -606,6 +843,47 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
606
843
|
?? mrs.find((m) => isObject(m) && m.state === "merged");
|
|
607
844
|
return isObject(merged) && typeof merged.iid === "number" ? merged.iid : null;
|
|
608
845
|
},
|
|
846
|
+
async openPullRequest(spec) {
|
|
847
|
+
const base = spec.base ?? (await defaultBranchOf(api, project, "default_branch"));
|
|
848
|
+
const title = spec.draft ? `Draft: ${spec.title}` : spec.title;
|
|
849
|
+
const made = await api.call("POST", notes, { source_branch: spec.head, target_branch: base, title, description: spec.body });
|
|
850
|
+
return mergeRequest(made) ?? { number: 0, title, body: spec.body, head: spec.head, base };
|
|
851
|
+
},
|
|
852
|
+
async findPullRequest(head) {
|
|
853
|
+
const open = await api.list(`${notes}?state=opened&source_branch=${encodeURIComponent(head)}`, pageParams, 100);
|
|
854
|
+
return open
|
|
855
|
+
// A fork's merge request into this project has another source project.
|
|
856
|
+
.filter((m) => isObject(m) && m.source_branch === head && (m.source_project_id === undefined || m.source_project_id === m.project_id))
|
|
857
|
+
.map(mergeRequest)
|
|
858
|
+
.filter((m): m is PullRequest => m !== undefined)
|
|
859
|
+
.sort((a, b) => a.number - b.number)[0];
|
|
860
|
+
},
|
|
861
|
+
async editPullRequest(mr, change) {
|
|
862
|
+
return mergeRequest(await api.call("PUT", `${notes}/${mr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { description: change.body } : {}) }));
|
|
863
|
+
},
|
|
864
|
+
async addLabels(mr, labels) {
|
|
865
|
+
if (labels.length === 0) return;
|
|
866
|
+
await api.call("PUT", `${notes}/${mr}`, { add_labels: labels.join(",") });
|
|
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
|
+
},
|
|
609
887
|
};
|
|
610
888
|
}
|
|
611
889
|
|