@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/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, and find the pull request that merged a commit.
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
- state: "pending" | "success" | "failure";
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 counts: `user.login` on
63
- * GitHub and Forgejo, `author.username` on GitLab, compared without case.
64
- * A merge request's author can write a note carrying any marker, so a
65
- * product that reads its own notes back names its bot here.
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) && (!opts.author || t.author?.toLowerCase() === opts.author.toLowerCase());
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: GITLAB_STATE[status.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