@intentius/chant 0.121.1 → 0.123.0
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/commands/build.d.ts +12 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +16 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +91 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +99 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +198 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/build.ts +37 -18
- package/src/cli/commands/carve-emit-state.test.ts +8 -2
- package/src/cli/commands/lint.test.ts +109 -1
- package/src/cli/commands/lint.ts +165 -1
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +20 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +527 -104
- package/src/op/op-waves.test.ts +383 -3
- package/src/op/op-waves.ts +187 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +303 -0
- package/src/pr-forge.ts +502 -47
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
package/src/pr-forge.ts
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What the
|
|
3
|
-
* pull request up to date, set a commit
|
|
4
|
-
*
|
|
2
|
+
* What chant and the products built on it ask of a forge: keep one comment
|
|
3
|
+
* (a merge request note on GitLab) on a pull request up to date, set a commit
|
|
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
6
|
*
|
|
6
|
-
*
|
|
7
|
+
* The pull-request loop (#3183) uses the note, the status and the review
|
|
8
|
+
* calls. `reconcilePr`'s `comment` and `issue` finding modes post through the
|
|
9
|
+
* same clients (#3757), so a finding needs no `gh` on the runner. terragucci
|
|
10
|
+
* and SQL Yodeler post their own notes, statuses and drift issues through
|
|
11
|
+
* them too (#3759): bodies stay with each product, the finding by marker and
|
|
12
|
+
* the forge's REST shapes live here.
|
|
13
|
+
*
|
|
14
|
+
* GitHub and Forgejo speak the same REST shapes for most of this, at
|
|
7
15
|
* different base URLs (`$GITHUB_API_URL` is right on both, #2305); they part
|
|
8
|
-
* on the commit-to-pull-request lookup
|
|
9
|
-
* own API for all
|
|
10
|
-
* with a recording fake and no network.
|
|
16
|
+
* on the commit-to-pull-request lookup, on review states and on page size.
|
|
17
|
+
* GitLab has its own API for all of it. Each client takes a `fetch`, so the
|
|
18
|
+
* tests drive them with a recording fake and no network.
|
|
11
19
|
*
|
|
12
20
|
* A reviewer is named the way #3163 names a person: `github:<login>`,
|
|
13
21
|
* `gitlab:<login>`, or `<forge>@<host>:<login>` off the forge's default host.
|
|
14
22
|
* The apply compares that string with the approval's `resolvedBy`.
|
|
15
23
|
*
|
|
16
24
|
* Like `./pr-loop.ts`, this bundles without the TypeScript toolchain (#3421):
|
|
17
|
-
* it imports nothing.
|
|
25
|
+
* it imports nothing but `node:fs`, to read a CI job's event payload.
|
|
26
|
+
* `./pr-forge-bundle.test.ts` holds it to that.
|
|
18
27
|
*/
|
|
19
28
|
|
|
29
|
+
import { readFileSync } from "node:fs";
|
|
30
|
+
|
|
20
31
|
export type ForgeKind = "github" | "gitlab" | "forgejo";
|
|
21
32
|
|
|
22
33
|
export const FORGE_KINDS: readonly ForgeKind[] = ["github", "gitlab", "forgejo"];
|
|
@@ -28,12 +39,72 @@ export type ForgeFetch = (
|
|
|
28
39
|
) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
|
|
29
40
|
|
|
30
41
|
export interface ForgeStatus {
|
|
31
|
-
/** `chant/plan` or `
|
|
42
|
+
/** The status's name, such as `chant/plan` or `terragucci/plan`. */
|
|
32
43
|
context: string;
|
|
33
44
|
state: "pending" | "success" | "failure";
|
|
45
|
+
/** Cut to what the forge keeps: 140 characters on GitHub and Forgejo, 255 on GitLab. */
|
|
34
46
|
description: string;
|
|
35
47
|
/** The pipeline run, linked from the status. */
|
|
36
48
|
url?: string;
|
|
49
|
+
/** GitLab only: the pipeline the status belongs to. */
|
|
50
|
+
pipelineId?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How a comment or an issue is told to be the caller's own. By default the
|
|
55
|
+
* body starts with the marker, chant's recipe since #2291, and anyone may
|
|
56
|
+
* have written it.
|
|
57
|
+
*/
|
|
58
|
+
export interface MarkerMatch {
|
|
59
|
+
/** `prefix` (the default): the body starts with the marker. `anywhere`: the body carries it. */
|
|
60
|
+
match?: "prefix" | "anywhere";
|
|
61
|
+
/**
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
author?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What {@link PrForge.upsertComment} did. */
|
|
71
|
+
export interface CommentResult {
|
|
72
|
+
action: "created" | "updated" | "unchanged";
|
|
73
|
+
/** The comment's or note's id. */
|
|
74
|
+
id: number;
|
|
75
|
+
/** Its web URL, when the forge's answer carries one (GitHub and Forgejo do; a GitLab note does not). */
|
|
76
|
+
url?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A finding to keep in a tracking issue. `body` carries the marker. */
|
|
80
|
+
export interface IssueFinding {
|
|
81
|
+
title: string;
|
|
82
|
+
body: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface TrackIssueOptions extends MarkerMatch {
|
|
86
|
+
/**
|
|
87
|
+
* When the finding has cleared (`null`), close the open issue, after
|
|
88
|
+
* {@link clearComment} when one is given. Off by default: `reconcilePr`'s
|
|
89
|
+
* `issue` mode never closes an issue, and a closed one is never reopened,
|
|
90
|
+
* so the next finding opens a fresh issue.
|
|
91
|
+
*/
|
|
92
|
+
closeOnClear?: boolean;
|
|
93
|
+
/** Posted on the issue before it is closed. */
|
|
94
|
+
clearComment?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What {@link PrForge.trackIssue} did. */
|
|
98
|
+
export interface IssueResult {
|
|
99
|
+
/**
|
|
100
|
+
* `opened`, `updated`, or `unchanged` (title and body already current) for
|
|
101
|
+
* a finding; `closed`, `left` (open, `closeOnClear` off) or `none` (no open
|
|
102
|
+
* issue) for a cleared one.
|
|
103
|
+
*/
|
|
104
|
+
action: "opened" | "updated" | "unchanged" | "closed" | "left" | "none";
|
|
105
|
+
/** The issue's number (GitHub, Forgejo) or iid (GitLab). */
|
|
106
|
+
number?: number;
|
|
107
|
+
url?: string;
|
|
37
108
|
}
|
|
38
109
|
|
|
39
110
|
export interface PrForge {
|
|
@@ -44,15 +115,43 @@ export interface PrForge {
|
|
|
44
115
|
runUrl?: string;
|
|
45
116
|
/** How #3163 names `login` on this forge. */
|
|
46
117
|
principalOf(login: string): string;
|
|
47
|
-
/**
|
|
48
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Keep one comment on pull request `pr` (one note on merge request `pr`):
|
|
120
|
+
* the one {@link MarkerMatch} finds is updated, unless its body is already
|
|
121
|
+
* `body`, or one is added. `body` must carry the marker the same way, or
|
|
122
|
+
* the next run could not find it; that is refused before any call. Every
|
|
123
|
+
* page of comments is read, and GitLab's system notes are skipped. When
|
|
124
|
+
* several match, the oldest is kept.
|
|
125
|
+
*/
|
|
126
|
+
upsertComment(pr: number, marker: string, body: string, opts?: MarkerMatch): Promise<CommentResult>;
|
|
127
|
+
/**
|
|
128
|
+
* {@link upsertComment} by prefix, without the body check, resolving to the
|
|
129
|
+
* comment's web URL. The pull-request loop's form.
|
|
130
|
+
*/
|
|
131
|
+
upsertNote(pr: number, marker: string, body: string): Promise<string | undefined>;
|
|
49
132
|
setStatus(sha: string, status: ForgeStatus): Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* Keep one OPEN tracking issue for `marker` in line with `finding`: open
|
|
135
|
+
* it, update its title and body, or, when `finding` is `null` and
|
|
136
|
+
* `closeOnClear` is set, close it with a comment. Only open issues are
|
|
137
|
+
* read, so an issue closed by hand stays closed. Pull requests are never
|
|
138
|
+
* taken for issues. When several match, the oldest is kept.
|
|
139
|
+
*/
|
|
140
|
+
trackIssue(marker: string, finding: IssueFinding | null, opts?: TrackIssueOptions): Promise<IssueResult>;
|
|
141
|
+
/**
|
|
142
|
+
* {@link trackIssue} by prefix for a finding, without the body check,
|
|
143
|
+
* resolving to the issue's web URL. `reconcilePr`'s `issue` mode's form.
|
|
144
|
+
*/
|
|
145
|
+
upsertIssue(marker: string, title: string, body: string): Promise<string | undefined>;
|
|
50
146
|
/** Logins whose standing review approves `pr`. */
|
|
51
147
|
approvers(pr: number): Promise<string[]>;
|
|
52
148
|
/** The merged pull request that produced `sha`, or null. */
|
|
53
149
|
pullRequestFor(sha: string): Promise<number | null>;
|
|
54
150
|
}
|
|
55
151
|
|
|
152
|
+
/** A GitHub or Forgejo client. */
|
|
153
|
+
export type GithubShapedForge = PrForge & { kind: "github" | "forgejo" };
|
|
154
|
+
|
|
56
155
|
/** Thrown when a forge answers a call with an error status. */
|
|
57
156
|
export class ForgeApiError extends Error {
|
|
58
157
|
constructor(
|
|
@@ -112,6 +211,83 @@ function transport(base: string, headers: Record<string, string>, fetchImpl: For
|
|
|
112
211
|
|
|
113
212
|
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
114
213
|
const loginOf = (v: unknown): string | undefined => (isObject(v) && isObject(v.user) && typeof v.user.login === "string" ? v.user.login : undefined);
|
|
214
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
215
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" ? v : typeof v === "string" && /^\d+$/.test(v) ? Number(v) : undefined);
|
|
216
|
+
|
|
217
|
+
/** A comment, note or issue as the shared recipes read it. `id` is an issue's number or iid. */
|
|
218
|
+
interface Thread {
|
|
219
|
+
id: number;
|
|
220
|
+
body: string;
|
|
221
|
+
title?: string;
|
|
222
|
+
author?: string;
|
|
223
|
+
url?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The calls each forge makes for the shared comment and issue recipes. */
|
|
227
|
+
interface Threads {
|
|
228
|
+
comments(pr: number): Promise<Thread[]>;
|
|
229
|
+
addComment(pr: number, body: string): Promise<Thread | undefined>;
|
|
230
|
+
editComment(pr: number, id: number, body: string): Promise<Thread | undefined>;
|
|
231
|
+
/** Open issues only, pull requests left out. */
|
|
232
|
+
openIssues(marker: string): Promise<Thread[]>;
|
|
233
|
+
openIssue(title: string, body: string): Promise<Thread | undefined>;
|
|
234
|
+
editIssue(n: number, title: string, body: string): Promise<Thread | undefined>;
|
|
235
|
+
closeIssue(n: number, comment: string | undefined): Promise<void>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const carries = (body: string, marker: string, match: MarkerMatch["match"]): boolean =>
|
|
239
|
+
match === "anywhere" ? body.includes(marker) : body.startsWith(marker);
|
|
240
|
+
|
|
241
|
+
const ownedBy = (marker: string, opts: MarkerMatch) => (t: Thread): boolean =>
|
|
242
|
+
carries(t.body, marker, opts.match) && (!opts.author || t.author?.toLowerCase() === opts.author.toLowerCase());
|
|
243
|
+
|
|
244
|
+
function checkMarker(what: string, marker: string, body: string, match: MarkerMatch["match"]): void {
|
|
245
|
+
if (!marker) throw new Error(`the ${what} needs a marker to be found again`);
|
|
246
|
+
if (!carries(body, marker, match)) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`the ${what} body ${match === "anywhere" ? "does not carry" : "does not start with"} its marker ${JSON.stringify(marker)}, so the next run could not find it`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function upsertCommentWith(t: Threads, pr: number, marker: string, body: string, opts: MarkerMatch, check: boolean): Promise<CommentResult> {
|
|
254
|
+
if (check) checkMarker("comment", marker, body, opts.match);
|
|
255
|
+
const mine = (await t.comments(pr)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
|
|
256
|
+
if (!mine) {
|
|
257
|
+
const made = await t.addComment(pr, body);
|
|
258
|
+
return { action: "created", id: made?.id ?? 0, ...(made?.url ? { url: made.url } : {}) };
|
|
259
|
+
}
|
|
260
|
+
if (mine.body === body) return { action: "unchanged", id: mine.id, ...(mine.url ? { url: mine.url } : {}) };
|
|
261
|
+
const edited = await t.editComment(pr, mine.id, body);
|
|
262
|
+
const url = edited?.url ?? mine.url;
|
|
263
|
+
return { action: "updated", id: mine.id, ...(url ? { url } : {}) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function trackIssueWith(t: Threads, marker: string, finding: IssueFinding | null, opts: TrackIssueOptions, check: boolean): Promise<IssueResult> {
|
|
267
|
+
if (finding && check) checkMarker("issue", marker, finding.body, opts.match);
|
|
268
|
+
const mine = (await t.openIssues(marker)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
|
|
269
|
+
const at = (i: Thread | undefined): Omit<IssueResult, "action"> => (i ? { number: i.id, ...(i.url ? { url: i.url } : {}) } : {});
|
|
270
|
+
if (finding === null) {
|
|
271
|
+
if (!mine) return { action: "none" };
|
|
272
|
+
if (!opts.closeOnClear) return { action: "left", ...at(mine) };
|
|
273
|
+
await t.closeIssue(mine.id, opts.clearComment);
|
|
274
|
+
return { action: "closed", ...at(mine) };
|
|
275
|
+
}
|
|
276
|
+
if (!mine) return { action: "opened", ...at(await t.openIssue(finding.title, finding.body)) };
|
|
277
|
+
if (mine.body === finding.body && mine.title === finding.title) return { action: "unchanged", ...at(mine) };
|
|
278
|
+
const edited = await t.editIssue(mine.id, finding.title, finding.body);
|
|
279
|
+
return { action: "updated", ...at(mine), ...(edited?.url ? { url: edited.url } : {}) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The recipes every client shares, over its own {@link Threads}. */
|
|
283
|
+
function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "trackIssue" | "upsertIssue"> {
|
|
284
|
+
return {
|
|
285
|
+
upsertComment: (pr, marker, body, opts = {}) => upsertCommentWith(t, pr, marker, body, opts, true),
|
|
286
|
+
upsertNote: async (pr, marker, body) => (await upsertCommentWith(t, pr, marker, body, {}, false)).url,
|
|
287
|
+
trackIssue: (marker, finding, opts = {}) => trackIssueWith(t, marker, finding, opts, true),
|
|
288
|
+
upsertIssue: async (marker, title, body) => (await trackIssueWith(t, marker, { title, body }, {}, false)).url,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
115
291
|
|
|
116
292
|
export interface GithubForgeOptions {
|
|
117
293
|
/** `$GITHUB_API_URL`: `https://api.github.com`, `<host>/api/v3`, or Forgejo's `<host>/api/v1`. */
|
|
@@ -125,8 +301,13 @@ export interface GithubForgeOptions {
|
|
|
125
301
|
fetch?: ForgeFetch;
|
|
126
302
|
}
|
|
127
303
|
|
|
304
|
+
/** GitHub keeps a status description to 140 characters; Forgejo follows. */
|
|
305
|
+
const GITHUB_DESCRIPTION = 140;
|
|
306
|
+
/** GitLab keeps a status description to 255 characters. */
|
|
307
|
+
const GITLAB_DESCRIPTION = 255;
|
|
308
|
+
|
|
128
309
|
/** Which of the two GitHub-shaped forges a client is talking to. */
|
|
129
|
-
function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions):
|
|
310
|
+
function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): GithubShapedForge {
|
|
130
311
|
const host = (opts.host ?? "github.com").toLowerCase();
|
|
131
312
|
const api = transport(
|
|
132
313
|
opts.apiBase,
|
|
@@ -139,25 +320,43 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrF
|
|
|
139
320
|
// GitHub pages with per_page (up to 100); Forgejo with limit (capped at 50 by default).
|
|
140
321
|
const pageSize = kind === "github" ? 100 : 50;
|
|
141
322
|
const pageParams = (page: number) => (kind === "github" ? `per_page=100&page=${page}` : `limit=50&page=${page}`);
|
|
323
|
+
const thread = (v: unknown): Thread | undefined => {
|
|
324
|
+
if (!isObject(v)) return undefined;
|
|
325
|
+
const id = num(v.number) ?? num(v.id);
|
|
326
|
+
if (id === undefined) return undefined;
|
|
327
|
+
const author = loginOf(v);
|
|
328
|
+
const url = str(v.html_url);
|
|
329
|
+
return { id, body: str(v.body) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
330
|
+
};
|
|
331
|
+
const threads = (items: unknown[]): Thread[] => items.map(thread).filter((t): t is Thread => t !== undefined);
|
|
332
|
+
const t: Threads = {
|
|
333
|
+
comments: async (pr) => threads(await api.list(`${repo}/issues/${pr}/comments`, pageParams, pageSize)),
|
|
334
|
+
addComment: async (pr, body) => thread(await api.call("POST", `${repo}/issues/${pr}/comments`, { body })),
|
|
335
|
+
editComment: async (_pr, id, body) => thread(await api.call("PATCH", `${repo}/issues/comments/${id}`, { body })),
|
|
336
|
+
// Paged in full rather than searched: GitHub's search index lags a
|
|
337
|
+
// write, so a run straight after another would miss the issue it just
|
|
338
|
+
// opened, and Forgejo has no `/search/issues` at all. Both list pull
|
|
339
|
+
// requests among issues; one of those is never ours.
|
|
340
|
+
openIssues: async () =>
|
|
341
|
+
threads((await api.list(`${repo}/issues?state=open`, pageParams, pageSize)).filter((i) => isObject(i) && !isObject(i.pull_request))),
|
|
342
|
+
openIssue: async (title, body) => thread(await api.call("POST", `${repo}/issues`, { title, body })),
|
|
343
|
+
editIssue: async (n, title, body) => thread(await api.call("PATCH", `${repo}/issues/${n}`, { title, body })),
|
|
344
|
+
async closeIssue(n, comment) {
|
|
345
|
+
if (comment) await api.call("POST", `${repo}/issues/${n}/comments`, { body: comment });
|
|
346
|
+
await api.call("PATCH", `${repo}/issues/${n}`, { state: "closed" });
|
|
347
|
+
},
|
|
348
|
+
};
|
|
142
349
|
return {
|
|
143
350
|
kind,
|
|
144
351
|
host,
|
|
145
352
|
...(opts.runUrl ? { runUrl: opts.runUrl } : {}),
|
|
146
353
|
principalOf: (login) => forgePrincipalOf(kind, host, login),
|
|
147
|
-
|
|
148
|
-
const comments = await api.list(`${repo}/issues/${pr}/comments`, pageParams, pageSize);
|
|
149
|
-
const mine = comments.find((c) => isObject(c) && typeof c.body === "string" && c.body.startsWith(marker));
|
|
150
|
-
if (isObject(mine) && (typeof mine.id === "number" || typeof mine.id === "string")) {
|
|
151
|
-
await api.call("PATCH", `${repo}/issues/comments/${mine.id}`, { body });
|
|
152
|
-
} else {
|
|
153
|
-
await api.call("POST", `${repo}/issues/${pr}/comments`, { body });
|
|
154
|
-
}
|
|
155
|
-
},
|
|
354
|
+
...threadCalls(t),
|
|
156
355
|
async setStatus(sha, status) {
|
|
157
356
|
await api.call("POST", `${repo}/statuses/${sha}`, {
|
|
158
357
|
state: status.state,
|
|
159
358
|
context: status.context,
|
|
160
|
-
description: status.description,
|
|
359
|
+
description: status.description.slice(0, GITHUB_DESCRIPTION),
|
|
161
360
|
...(status.url ? { target_url: status.url } : {}),
|
|
162
361
|
});
|
|
163
362
|
},
|
|
@@ -197,12 +396,12 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrF
|
|
|
197
396
|
}
|
|
198
397
|
|
|
199
398
|
/** A GitHub client (github.com or GitHub Enterprise Server). */
|
|
200
|
-
export function githubForge(opts: GithubForgeOptions):
|
|
399
|
+
export function githubForge(opts: GithubForgeOptions): GithubShapedForge {
|
|
201
400
|
return githubShaped("github", opts);
|
|
202
401
|
}
|
|
203
402
|
|
|
204
403
|
/** A Forgejo (or Gitea) client. `host` is required: Forgejo has no default host. */
|
|
205
|
-
export function forgejoForge(opts: GithubForgeOptions & { host: string }):
|
|
404
|
+
export function forgejoForge(opts: GithubForgeOptions & { host: string }): GithubShapedForge {
|
|
206
405
|
return githubShaped("forgejo", opts);
|
|
207
406
|
}
|
|
208
407
|
|
|
@@ -212,6 +411,12 @@ export interface GitlabForgeOptions {
|
|
|
212
411
|
/** `$CI_PROJECT_ID`, or the URL-encoded path. */
|
|
213
412
|
project: string;
|
|
214
413
|
token: string;
|
|
414
|
+
/**
|
|
415
|
+
* The header the token goes in: `PRIVATE-TOKEN` (the default) for an
|
|
416
|
+
* access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. One in the other's header
|
|
417
|
+
* is a 401.
|
|
418
|
+
*/
|
|
419
|
+
tokenHeader?: "PRIVATE-TOKEN" | "JOB-TOKEN";
|
|
215
420
|
/** Default gitlab.com. */
|
|
216
421
|
host?: string;
|
|
217
422
|
runUrl?: string;
|
|
@@ -220,33 +425,78 @@ export interface GitlabForgeOptions {
|
|
|
220
425
|
|
|
221
426
|
const GITLAB_STATE: Record<ForgeStatus["state"], string> = { pending: "pending", success: "success", failure: "failed" };
|
|
222
427
|
|
|
223
|
-
/**
|
|
428
|
+
/**
|
|
429
|
+
* A GitLab client. Writing notes needs an access token with the `api` scope
|
|
430
|
+
* on most instances: the job token reaches only the endpoints the instance's
|
|
431
|
+
* job-token allowlist names.
|
|
432
|
+
*/
|
|
224
433
|
export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
225
434
|
const host = (opts.host ?? "gitlab.com").toLowerCase();
|
|
226
|
-
const api = transport(opts.apiBase, { "PRIVATE-TOKEN": opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
|
|
435
|
+
const api = transport(opts.apiBase, { [opts.tokenHeader ?? "PRIVATE-TOKEN"]: opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
|
|
227
436
|
const project = `/projects/${encodeURIComponent(decodeURIComponent(opts.project))}`;
|
|
228
437
|
const pageParams = (page: number) => `per_page=100&page=${page}`;
|
|
438
|
+
const authorOf = (v: Record<string, unknown>): string | undefined => (isObject(v.author) ? str(v.author.username) : undefined);
|
|
439
|
+
const note = (v: unknown): Thread | undefined => {
|
|
440
|
+
if (!isObject(v)) return undefined;
|
|
441
|
+
const id = num(v.id);
|
|
442
|
+
if (id === undefined) return undefined;
|
|
443
|
+
const author = authorOf(v);
|
|
444
|
+
return { id, body: str(v.body) ?? "", ...(author ? { author } : {}) };
|
|
445
|
+
};
|
|
446
|
+
const issue = (v: unknown): Thread | undefined => {
|
|
447
|
+
if (!isObject(v)) return undefined;
|
|
448
|
+
const id = num(v.iid);
|
|
449
|
+
if (id === undefined) return undefined;
|
|
450
|
+
const author = authorOf(v);
|
|
451
|
+
const url = str(v.web_url);
|
|
452
|
+
return { id, body: str(v.description) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
453
|
+
};
|
|
454
|
+
const notes = `${project}/merge_requests`;
|
|
455
|
+
const t: Threads = {
|
|
456
|
+
comments: async (mr) =>
|
|
457
|
+
(await api.list(`${notes}/${mr}/notes`, pageParams, 100))
|
|
458
|
+
// System notes ("added 1 commit") are the forge's own, never ours.
|
|
459
|
+
.filter((n) => !(isObject(n) && n.system === true))
|
|
460
|
+
.map(note)
|
|
461
|
+
.filter((n): n is Thread => n !== undefined),
|
|
462
|
+
addComment: async (mr, body) => note(await api.call("POST", `${notes}/${mr}/notes`, { body })),
|
|
463
|
+
editComment: async (mr, id, body) => note(await api.call("PUT", `${notes}/${mr}/notes/${id}`, { body })),
|
|
464
|
+
// `search` narrows the list server-side to issues whose description
|
|
465
|
+
// carries the marker, as `reconcilePr`'s GitLab issue does (#2292); the
|
|
466
|
+
// match itself is still decided here.
|
|
467
|
+
openIssues: async (marker) =>
|
|
468
|
+
(await api.list(`${project}/issues?state=opened&search=${encodeURIComponent(marker)}&in=description`, pageParams, 100))
|
|
469
|
+
.map(issue)
|
|
470
|
+
.filter((i): i is Thread => i !== undefined),
|
|
471
|
+
openIssue: async (title, body) => issue(await api.call("POST", `${project}/issues`, { title, description: body })),
|
|
472
|
+
editIssue: async (n, title, body) => issue(await api.call("PUT", `${project}/issues/${n}`, { title, description: body })),
|
|
473
|
+
async closeIssue(n, comment) {
|
|
474
|
+
if (comment) await api.call("POST", `${project}/issues/${n}/notes`, { body: comment });
|
|
475
|
+
await api.call("PUT", `${project}/issues/${n}`, { state_event: "close" });
|
|
476
|
+
},
|
|
477
|
+
};
|
|
229
478
|
return {
|
|
230
479
|
kind: "gitlab",
|
|
231
480
|
host,
|
|
232
481
|
...(opts.runUrl ? { runUrl: opts.runUrl } : {}),
|
|
233
482
|
principalOf: (login) => forgePrincipalOf("gitlab", host, login),
|
|
234
|
-
|
|
235
|
-
const notes = await api.list(`${project}/merge_requests/${mr}/notes`, pageParams, 100);
|
|
236
|
-
const mine = notes.find((n) => isObject(n) && typeof n.body === "string" && n.body.startsWith(marker));
|
|
237
|
-
if (isObject(mine) && typeof mine.id === "number") {
|
|
238
|
-
await api.call("PUT", `${project}/merge_requests/${mr}/notes/${mine.id}`, { body });
|
|
239
|
-
} else {
|
|
240
|
-
await api.call("POST", `${project}/merge_requests/${mr}/notes`, { body });
|
|
241
|
-
}
|
|
242
|
-
},
|
|
483
|
+
...threadCalls(t),
|
|
243
484
|
async setStatus(sha, status) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
485
|
+
try {
|
|
486
|
+
await api.call("POST", `${project}/statuses/${sha}`, {
|
|
487
|
+
state: GITLAB_STATE[status.state],
|
|
488
|
+
name: status.context,
|
|
489
|
+
description: status.description.slice(0, GITLAB_DESCRIPTION),
|
|
490
|
+
...(status.url ? { target_url: status.url } : {}),
|
|
491
|
+
...(status.pipelineId !== undefined ? { pipeline_id: status.pipelineId } : {}),
|
|
492
|
+
});
|
|
493
|
+
} catch (err) {
|
|
494
|
+
// GitLab answers 400 to a status set again to the state it already
|
|
495
|
+
// has ("Cannot transition status via :success from :success"): the
|
|
496
|
+
// status is what was asked for.
|
|
497
|
+
if (err instanceof ForgeApiError && err.status === 400 && /cannot transition status/i.test(err.message)) return;
|
|
498
|
+
throw err;
|
|
499
|
+
}
|
|
250
500
|
},
|
|
251
501
|
async approvers(mr) {
|
|
252
502
|
const approvals = await api.call("GET", `${project}/merge_requests/${mr}/approvals`);
|
|
@@ -268,8 +518,19 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
268
518
|
|
|
269
519
|
/** Thrown when the environment lacks what a forge client needs. */
|
|
270
520
|
export class ForgeEnvironmentError extends Error {
|
|
271
|
-
|
|
272
|
-
|
|
521
|
+
/**
|
|
522
|
+
* @param kind The forge, or undefined when the environment does not say which.
|
|
523
|
+
* @param missing What is missing, each naming the variable to set.
|
|
524
|
+
*/
|
|
525
|
+
constructor(
|
|
526
|
+
readonly kind: ForgeKind | undefined,
|
|
527
|
+
readonly missing: string[],
|
|
528
|
+
) {
|
|
529
|
+
super(
|
|
530
|
+
kind
|
|
531
|
+
? `--forge ${kind} needs ${missing.join(", ")} in the environment`
|
|
532
|
+
: "the forge is not known: this is not a GitHub Actions, GitLab CI or Forgejo Actions job, so name the forge",
|
|
533
|
+
);
|
|
273
534
|
this.name = "ForgeEnvironmentError";
|
|
274
535
|
}
|
|
275
536
|
}
|
|
@@ -284,10 +545,12 @@ const hostOf = (url: string | undefined): string | undefined => {
|
|
|
284
545
|
};
|
|
285
546
|
|
|
286
547
|
/**
|
|
287
|
-
* A client for `kind` from the CI job's environment
|
|
288
|
-
* `CHANT_FORGE_TOKEN` when set, else the forge's
|
|
289
|
-
* set on Forgejo Actions) or `GITLAB_TOKEN`.
|
|
290
|
-
* write notes, so a GitLab pipeline names a
|
|
548
|
+
* A client for `kind` from the CI job's environment, as the pull-request
|
|
549
|
+
* loop reads it. The token is `CHANT_FORGE_TOKEN` when set, else the forge's
|
|
550
|
+
* own: `GITHUB_TOKEN` (also set on Forgejo Actions) or `GITLAB_TOKEN`.
|
|
551
|
+
* GitLab's `CI_JOB_TOKEN` cannot write notes, so a GitLab pipeline names a
|
|
552
|
+
* project access token. {@link forgeTargetFromEnv} is the general reading,
|
|
553
|
+
* with the finding modes' token order.
|
|
291
554
|
*/
|
|
292
555
|
export function forgeFromEnv(kind: ForgeKind, env: Record<string, string | undefined> = process.env, fetchImpl?: ForgeFetch): PrForge {
|
|
293
556
|
const f = fetchImpl ? { fetch: fetchImpl } : {};
|
|
@@ -330,3 +593,195 @@ export function forgeFromEnv(kind: ForgeKind, env: Record<string, string | undef
|
|
|
330
593
|
};
|
|
331
594
|
return kind === "forgejo" ? forgejoForge({ ...opts, host: host! }) : githubForge({ ...opts, host: host ?? "github.com" });
|
|
332
595
|
}
|
|
596
|
+
|
|
597
|
+
// ── Where a CI job posts (#3759) ─────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
type Env = Record<string, string | undefined>;
|
|
600
|
+
|
|
601
|
+
const truthy = (v: string | undefined): boolean => v === "true" || v === "1";
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The forge a CI job runs on, from its environment, or undefined outside a
|
|
605
|
+
* GitHub Actions, GitLab CI or Forgejo Actions job. Forgejo is told apart by
|
|
606
|
+
* `FORGEJO_ACTIONS` or `GITEA_ACTIONS`, or by a GitHub-style job whose
|
|
607
|
+
* `GITHUB_API_URL` ends in `/api/v1`, which GitHub never serves.
|
|
608
|
+
*/
|
|
609
|
+
export function detectForge(env: Env = process.env): ForgeKind | undefined {
|
|
610
|
+
if (truthy(env.GITLAB_CI)) return "gitlab";
|
|
611
|
+
if (truthy(env.FORGEJO_ACTIONS) || truthy(env.GITEA_ACTIONS)) return "forgejo";
|
|
612
|
+
if (truthy(env.GITHUB_ACTIONS)) return /\/api\/v1\/?$/.test(env.GITHUB_API_URL ?? "") ? "forgejo" : "github";
|
|
613
|
+
return undefined;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Where a CI job posts: the forge, the repository, the token, and the pull request and commit when there are ones. */
|
|
617
|
+
export interface ForgeTarget {
|
|
618
|
+
kind: ForgeKind;
|
|
619
|
+
/** The REST base: `https://api.github.com`, `<host>/api/v3`, `<host>/api/v1`, or `<host>/api/v4`. */
|
|
620
|
+
apiBase: string;
|
|
621
|
+
/** `owner/name` on GitHub and Forgejo; the project id or path on GitLab. */
|
|
622
|
+
repo: string;
|
|
623
|
+
/** Lower-cased. */
|
|
624
|
+
host: string;
|
|
625
|
+
token: string;
|
|
626
|
+
/** The variable the token came from, for messages. Never the token itself. */
|
|
627
|
+
tokenSource: string;
|
|
628
|
+
/** GitLab: `JOB-TOKEN` when the token is `CI_JOB_TOKEN`, else `PRIVATE-TOKEN`. */
|
|
629
|
+
tokenHeader?: "PRIVATE-TOKEN" | "JOB-TOKEN";
|
|
630
|
+
/** The pull request's number, or the merge request's iid. */
|
|
631
|
+
pr?: number;
|
|
632
|
+
/** The commit a status belongs on: the pull request's head when the event names it. */
|
|
633
|
+
sha?: string;
|
|
634
|
+
/** The run, to link from a status. */
|
|
635
|
+
runUrl?: string;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export interface ForgeTargetOptions {
|
|
639
|
+
/** The forge, in place of {@link detectForge}. */
|
|
640
|
+
kind?: ForgeKind;
|
|
641
|
+
/** Read the token from this variable only, in place of the default order. */
|
|
642
|
+
tokenEnv?: string;
|
|
643
|
+
/** Refuse an environment with no pull or merge request. Default false. */
|
|
644
|
+
requirePr?: boolean;
|
|
645
|
+
/** Reads the event payload `GITHUB_EVENT_PATH` names. Default `node:fs`. */
|
|
646
|
+
readFile?: (path: string) => string;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The token order guide/ops documents for the finding modes (#2291, #2256):
|
|
651
|
+
* a chant-specific variable first, for posting to an instance other than the
|
|
652
|
+
* one the job runs on, then what the job already carries.
|
|
653
|
+
*/
|
|
654
|
+
export const FORGE_TOKEN_ORDER: Record<ForgeKind, readonly string[]> = {
|
|
655
|
+
github: ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
|
|
656
|
+
forgejo: ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
|
|
657
|
+
gitlab: ["CHANT_GITLAB_TOKEN", "GITLAB_TOKEN", "CI_JOB_TOKEN"],
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
/** The pull request and its head from a GitHub-style event payload, or from the ref. */
|
|
661
|
+
function prFromEvent(payload: unknown, ref: string | undefined): { pr?: number; sha?: string } {
|
|
662
|
+
const out: { pr?: number; sha?: string } = {};
|
|
663
|
+
if (isObject(payload)) {
|
|
664
|
+
const pr = isObject(payload.pull_request) ? payload.pull_request : undefined;
|
|
665
|
+
const issue = isObject(payload.issue) ? payload.issue : undefined;
|
|
666
|
+
const n = num(pr?.number) ?? (issue && isObject(issue.pull_request) ? num(issue.number) : undefined) ?? (pr ? num(payload.number) : undefined);
|
|
667
|
+
if (n !== undefined && n > 0) out.pr = n;
|
|
668
|
+
const head = pr && isObject(pr.head) ? str(pr.head.sha) : undefined;
|
|
669
|
+
if (head) out.sha = head;
|
|
670
|
+
}
|
|
671
|
+
if (out.pr === undefined) {
|
|
672
|
+
const m = /^refs\/pull\/(\d+)\/(?:merge|head)$/.exec(ref ?? "");
|
|
673
|
+
if (m) out.pr = Number(m[1]);
|
|
674
|
+
}
|
|
675
|
+
return out;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Where a CI job posts, read from its environment: the forge
|
|
680
|
+
* ({@link detectForge} unless `kind` is given), the repository, the REST
|
|
681
|
+
* base, the host, the token, and the pull request and its head commit when
|
|
682
|
+
* the run has them. Throws {@link ForgeEnvironmentError} naming every
|
|
683
|
+
* missing piece.
|
|
684
|
+
*
|
|
685
|
+
* - GitHub Actions: `GITHUB_REPOSITORY`, `GITHUB_API_URL` (default
|
|
686
|
+
* `https://api.github.com`), `GITHUB_SERVER_URL`; the pull request from
|
|
687
|
+
* the event payload at `GITHUB_EVENT_PATH` or `refs/pull/<n>/` in
|
|
688
|
+
* `GITHUB_REF`, its head from the payload, else `GITHUB_SHA`.
|
|
689
|
+
* - Forgejo Actions: the same, with `FORGEJO_*` read before `GITHUB_*`,
|
|
690
|
+
* and the REST base `<server>/api/v1` when no API URL is set.
|
|
691
|
+
* - GitLab CI: `CI_API_V4_URL` (or `CI_SERVER_URL/api/v4`), the merge
|
|
692
|
+
* request's project before the pipeline's (a fork's merge request runs in
|
|
693
|
+
* the fork), `CI_MERGE_REQUEST_IID`, `CI_COMMIT_SHA`, `CI_PIPELINE_URL`.
|
|
694
|
+
*
|
|
695
|
+
* The token is read in {@link FORGE_TOKEN_ORDER}, or from `tokenEnv` alone.
|
|
696
|
+
*/
|
|
697
|
+
export function forgeTargetFromEnv(env: Env = process.env, opts: ForgeTargetOptions = {}): ForgeTarget {
|
|
698
|
+
const kind = opts.kind ?? detectForge(env);
|
|
699
|
+
if (!kind) throw new ForgeEnvironmentError(undefined, []);
|
|
700
|
+
const pick = (...names: string[]): string | undefined => names.map((n) => env[n]?.trim()).find((v) => v !== undefined && v !== "");
|
|
701
|
+
const names = opts.tokenEnv ? [opts.tokenEnv] : FORGE_TOKEN_ORDER[kind];
|
|
702
|
+
const tokenSource = names.find((n) => env[n]?.trim());
|
|
703
|
+
const token = tokenSource ? env[tokenSource]!.trim() : undefined;
|
|
704
|
+
const missing: string[] = [];
|
|
705
|
+
const tokenMissing = `a token (${names.join(", ")})`;
|
|
706
|
+
|
|
707
|
+
if (kind === "gitlab") {
|
|
708
|
+
const server = pick("CI_SERVER_URL")?.replace(/\/+$/, "");
|
|
709
|
+
const apiBase = pick("CI_API_V4_URL")?.replace(/\/+$/, "") ?? (server ? `${server}/api/v4` : undefined);
|
|
710
|
+
const repo = pick("CI_MERGE_REQUEST_PROJECT_ID", "CI_PROJECT_ID", "CI_MERGE_REQUEST_PROJECT_PATH", "CI_PROJECT_PATH");
|
|
711
|
+
const iid = num(pick("CI_MERGE_REQUEST_IID"));
|
|
712
|
+
if (!apiBase) missing.push("CI_API_V4_URL");
|
|
713
|
+
if (!repo) missing.push("CI_PROJECT_ID");
|
|
714
|
+
if (opts.requirePr && !iid) missing.push("CI_MERGE_REQUEST_IID (a merge request pipeline)");
|
|
715
|
+
if (!token) missing.push(tokenMissing);
|
|
716
|
+
if (missing.length > 0) throw new ForgeEnvironmentError(kind, missing);
|
|
717
|
+
const sha = pick("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "CI_COMMIT_SHA");
|
|
718
|
+
const runUrl = pick("CI_PIPELINE_URL");
|
|
719
|
+
return {
|
|
720
|
+
kind,
|
|
721
|
+
apiBase: apiBase!,
|
|
722
|
+
repo: repo!,
|
|
723
|
+
host: pick("CI_SERVER_HOST")?.toLowerCase() ?? hostOf(server) ?? hostOf(apiBase) ?? "gitlab.com",
|
|
724
|
+
token: token!,
|
|
725
|
+
tokenSource: tokenSource!,
|
|
726
|
+
tokenHeader: tokenSource === "CI_JOB_TOKEN" ? "JOB-TOKEN" : "PRIVATE-TOKEN",
|
|
727
|
+
...(iid ? { pr: iid } : {}),
|
|
728
|
+
...(sha ? { sha } : {}),
|
|
729
|
+
...(runUrl ? { runUrl } : {}),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const fj = kind === "forgejo";
|
|
734
|
+
const read = (name: string): string | undefined => (fj ? pick(`FORGEJO_${name}`, `GITHUB_${name}`) : pick(`GITHUB_${name}`));
|
|
735
|
+
const repo = read("REPOSITORY");
|
|
736
|
+
const server = read("SERVER_URL")?.replace(/\/+$/, "");
|
|
737
|
+
const apiBase = read("API_URL")?.replace(/\/+$/, "") ?? (fj ? (server ? `${server}/api/v1` : undefined) : "https://api.github.com");
|
|
738
|
+
let event: unknown;
|
|
739
|
+
const eventPath = read("EVENT_PATH");
|
|
740
|
+
if (eventPath) {
|
|
741
|
+
try {
|
|
742
|
+
event = JSON.parse((opts.readFile ?? ((p: string) => readFileSync(p, "utf8")))(eventPath));
|
|
743
|
+
} catch {
|
|
744
|
+
// An unreadable payload is not fatal: the ref may still name the pull request.
|
|
745
|
+
event = undefined;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const { pr, sha: head } = prFromEvent(event, read("REF"));
|
|
749
|
+
if (!repo) missing.push(fj ? "FORGEJO_REPOSITORY or GITHUB_REPOSITORY" : "GITHUB_REPOSITORY");
|
|
750
|
+
if (!apiBase) missing.push("GITHUB_SERVER_URL or GITHUB_API_URL");
|
|
751
|
+
if (opts.requirePr && !pr) missing.push("a pull request (a pull_request event, or refs/pull/<n>/ in GITHUB_REF)");
|
|
752
|
+
if (!token) missing.push(tokenMissing);
|
|
753
|
+
if (missing.length > 0) throw new ForgeEnvironmentError(kind, missing);
|
|
754
|
+
const sha = head ?? read("SHA");
|
|
755
|
+
const runId = read("RUN_ID");
|
|
756
|
+
const runUrl = server && runId ? `${server}/${repo}/actions/runs/${runId}` : undefined;
|
|
757
|
+
return {
|
|
758
|
+
kind,
|
|
759
|
+
apiBase: apiBase!,
|
|
760
|
+
repo: repo!,
|
|
761
|
+
host: hostOf(server) ?? (fj ? hostOf(apiBase)! : "github.com"),
|
|
762
|
+
token: token!,
|
|
763
|
+
tokenSource: tokenSource!,
|
|
764
|
+
...(pr ? { pr } : {}),
|
|
765
|
+
...(sha ? { sha } : {}),
|
|
766
|
+
...(runUrl ? { runUrl } : {}),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** The client for a {@link ForgeTarget}, from {@link forgeTargetFromEnv} or built by hand. */
|
|
771
|
+
export function forgeClient(target: ForgeTarget, fetchImpl?: ForgeFetch): PrForge {
|
|
772
|
+
const f = fetchImpl ? { fetch: fetchImpl } : {};
|
|
773
|
+
const run = target.runUrl ? { runUrl: target.runUrl } : {};
|
|
774
|
+
if (target.kind === "gitlab") {
|
|
775
|
+
return gitlabForge({
|
|
776
|
+
apiBase: target.apiBase,
|
|
777
|
+
project: target.repo,
|
|
778
|
+
token: target.token,
|
|
779
|
+
host: target.host,
|
|
780
|
+
...(target.tokenHeader ? { tokenHeader: target.tokenHeader } : {}),
|
|
781
|
+
...run,
|
|
782
|
+
...f,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
const opts = { apiBase: target.apiBase, repo: target.repo, token: target.token, host: target.host, ...run, ...f };
|
|
786
|
+
return target.kind === "forgejo" ? forgejoForge(opts) : githubForge(opts);
|
|
787
|
+
}
|