@intentius/chant 0.122.0 → 0.124.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/check-lexicon.d.ts.map +1 -1
- 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/config-import.d.ts +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/policy-import.d.ts +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-verify.js +76 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +307 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
- 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 +218 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +113 -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 +225 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
- package/dist/workspace/conformance/broker.d.ts +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 +15 -2
- package/src/cli/commands/build.ts +1 -1
- package/src/cli/commands/check-lexicon.ts +3 -1
- package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
- package/src/cli/commands/lint.test.ts +4 -109
- package/src/cli/commands/lint.ts +5 -2
- package/src/cli/discovery-skip.e2e.test.ts +18 -3
- 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/config-import.ts +1 -1
- package/src/discovery/sandbox/run.test.ts +1 -1
- 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/policy-import.ts +1 -1
- 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-plain-node.e2e.test.ts +68 -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/__fixtures__/decide-stub-backend.ts +1 -1
- 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 +36 -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 +908 -123
- package/src/op/op-waves.test.ts +543 -3
- package/src/op/op-waves.ts +215 -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 +378 -0
- package/src/pr-forge.ts +546 -47
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
- package/src/workspace/conformance/broker.ts +1 -1
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/records-write.e2e.test.ts +1 -1
- 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/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
- /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
- /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
- /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
- /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
- /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
- /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
- /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
- /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
- /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
- /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
- /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
- /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
- /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
- /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
- /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
- /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
- /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
- /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
- /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
- /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
- /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
- /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
- /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -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,90 @@ 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 comment or note {@link PrForge.findComment} read back. */
|
|
80
|
+
export interface FoundComment {
|
|
81
|
+
/** The comment's or note's id. */
|
|
82
|
+
id: number;
|
|
83
|
+
body: string;
|
|
84
|
+
/** Its web URL, when the forge's answer carries one (GitHub and Forgejo do; a GitLab note does not). */
|
|
85
|
+
url?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A finding to keep in a tracking issue. `body` carries the marker. */
|
|
89
|
+
export interface IssueFinding {
|
|
90
|
+
title: string;
|
|
91
|
+
body: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface TrackIssueOptions extends MarkerMatch {
|
|
95
|
+
/**
|
|
96
|
+
* When the finding has cleared (`null`), close the open issue, after
|
|
97
|
+
* {@link clearComment} when one is given. Off by default: `reconcilePr`'s
|
|
98
|
+
* `issue` mode never closes an issue, and a closed one is never reopened,
|
|
99
|
+
* so the next finding opens a fresh issue.
|
|
100
|
+
*/
|
|
101
|
+
closeOnClear?: boolean;
|
|
102
|
+
/** Posted on the issue before it is closed. */
|
|
103
|
+
clearComment?: string;
|
|
104
|
+
/**
|
|
105
|
+
* When the open issue counts as already current, in place of an equal
|
|
106
|
+
* title and body, for a body that names something that changes on every
|
|
107
|
+
* run (the run's URL, a timestamp). A string: unchanged when the titles
|
|
108
|
+
* are equal and the open issue's body carries it, such as the finding's
|
|
109
|
+
* fingerprint. A function: unchanged when it returns true for the open
|
|
110
|
+
* issue and the finding. An unchanged issue is not edited.
|
|
111
|
+
*/
|
|
112
|
+
sameWhen?: string | ((open: IssueFinding, finding: IssueFinding) => boolean);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What {@link PrForge.trackIssue} did. */
|
|
116
|
+
export interface IssueResult {
|
|
117
|
+
/**
|
|
118
|
+
* `opened`, `updated`, or `unchanged` (title and body already current) for
|
|
119
|
+
* a finding; `closed`, `left` (open, `closeOnClear` off) or `none` (no open
|
|
120
|
+
* issue) for a cleared one.
|
|
121
|
+
*/
|
|
122
|
+
action: "opened" | "updated" | "unchanged" | "closed" | "left" | "none";
|
|
123
|
+
/** The issue's number (GitHub, Forgejo) or iid (GitLab). */
|
|
124
|
+
number?: number;
|
|
125
|
+
url?: string;
|
|
37
126
|
}
|
|
38
127
|
|
|
39
128
|
export interface PrForge {
|
|
@@ -44,15 +133,51 @@ export interface PrForge {
|
|
|
44
133
|
runUrl?: string;
|
|
45
134
|
/** How #3163 names `login` on this forge. */
|
|
46
135
|
principalOf(login: string): string;
|
|
47
|
-
/**
|
|
48
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Keep one comment on pull request `pr` (one note on merge request `pr`):
|
|
138
|
+
* the one {@link MarkerMatch} finds is updated, unless its body is already
|
|
139
|
+
* `body`, or one is added. `body` must carry the marker the same way, or
|
|
140
|
+
* the next run could not find it; that is refused before any call. Every
|
|
141
|
+
* page of comments is read, and GitLab's system notes are skipped. When
|
|
142
|
+
* several match, the oldest is kept.
|
|
143
|
+
*/
|
|
144
|
+
upsertComment(pr: number, marker: string, body: string, opts?: MarkerMatch): Promise<CommentResult>;
|
|
145
|
+
/**
|
|
146
|
+
* {@link upsertComment} by prefix, without the body check, resolving to the
|
|
147
|
+
* comment's web URL. The pull-request loop's form.
|
|
148
|
+
*/
|
|
149
|
+
upsertNote(pr: number, marker: string, body: string): Promise<string | undefined>;
|
|
150
|
+
/**
|
|
151
|
+
* The comment on pull request `pr` (the note on merge request `pr`) that
|
|
152
|
+
* {@link upsertComment} would update for `marker`: the oldest one that
|
|
153
|
+
* {@link MarkerMatch} finds, `author` honoured, so a pull request's author
|
|
154
|
+
* cannot plant one. Undefined when there is none. Every page is read, and
|
|
155
|
+
* GitLab's system notes are skipped.
|
|
156
|
+
*/
|
|
157
|
+
findComment(pr: number, marker: string, opts?: MarkerMatch): Promise<FoundComment | undefined>;
|
|
49
158
|
setStatus(sha: string, status: ForgeStatus): Promise<void>;
|
|
159
|
+
/**
|
|
160
|
+
* Keep one OPEN tracking issue for `marker` in line with `finding`: open
|
|
161
|
+
* it, update its title and body, or, when `finding` is `null` and
|
|
162
|
+
* `closeOnClear` is set, close it with a comment. Only open issues are
|
|
163
|
+
* read, so an issue closed by hand stays closed. Pull requests are never
|
|
164
|
+
* taken for issues. When several match, the oldest is kept.
|
|
165
|
+
*/
|
|
166
|
+
trackIssue(marker: string, finding: IssueFinding | null, opts?: TrackIssueOptions): Promise<IssueResult>;
|
|
167
|
+
/**
|
|
168
|
+
* {@link trackIssue} by prefix for a finding, without the body check,
|
|
169
|
+
* resolving to the issue's web URL. `reconcilePr`'s `issue` mode's form.
|
|
170
|
+
*/
|
|
171
|
+
upsertIssue(marker: string, title: string, body: string): Promise<string | undefined>;
|
|
50
172
|
/** Logins whose standing review approves `pr`. */
|
|
51
173
|
approvers(pr: number): Promise<string[]>;
|
|
52
174
|
/** The merged pull request that produced `sha`, or null. */
|
|
53
175
|
pullRequestFor(sha: string): Promise<number | null>;
|
|
54
176
|
}
|
|
55
177
|
|
|
178
|
+
/** A GitHub or Forgejo client. */
|
|
179
|
+
export type GithubShapedForge = PrForge & { kind: "github" | "forgejo" };
|
|
180
|
+
|
|
56
181
|
/** Thrown when a forge answers a call with an error status. */
|
|
57
182
|
export class ForgeApiError extends Error {
|
|
58
183
|
constructor(
|
|
@@ -112,6 +237,99 @@ function transport(base: string, headers: Record<string, string>, fetchImpl: For
|
|
|
112
237
|
|
|
113
238
|
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
114
239
|
const loginOf = (v: unknown): string | undefined => (isObject(v) && isObject(v.user) && typeof v.user.login === "string" ? v.user.login : undefined);
|
|
240
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
241
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" ? v : typeof v === "string" && /^\d+$/.test(v) ? Number(v) : undefined);
|
|
242
|
+
|
|
243
|
+
/** A comment, note or issue as the shared recipes read it. `id` is an issue's number or iid. */
|
|
244
|
+
interface Thread {
|
|
245
|
+
id: number;
|
|
246
|
+
body: string;
|
|
247
|
+
title?: string;
|
|
248
|
+
author?: string;
|
|
249
|
+
url?: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The calls each forge makes for the shared comment and issue recipes. */
|
|
253
|
+
interface Threads {
|
|
254
|
+
comments(pr: number): Promise<Thread[]>;
|
|
255
|
+
addComment(pr: number, body: string): Promise<Thread | undefined>;
|
|
256
|
+
editComment(pr: number, id: number, body: string): Promise<Thread | undefined>;
|
|
257
|
+
/** Open issues only, pull requests left out. */
|
|
258
|
+
openIssues(marker: string): Promise<Thread[]>;
|
|
259
|
+
openIssue(title: string, body: string): Promise<Thread | undefined>;
|
|
260
|
+
editIssue(n: number, title: string, body: string): Promise<Thread | undefined>;
|
|
261
|
+
closeIssue(n: number, comment: string | undefined): Promise<void>;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const carries = (body: string, marker: string, match: MarkerMatch["match"]): boolean =>
|
|
265
|
+
match === "anywhere" ? body.includes(marker) : body.startsWith(marker);
|
|
266
|
+
|
|
267
|
+
const ownedBy = (marker: string, opts: MarkerMatch) => (t: Thread): boolean =>
|
|
268
|
+
carries(t.body, marker, opts.match) && (!opts.author || t.author?.toLowerCase() === opts.author.toLowerCase());
|
|
269
|
+
|
|
270
|
+
function checkMarker(what: string, marker: string, body: string, match: MarkerMatch["match"]): void {
|
|
271
|
+
if (!marker) throw new Error(`the ${what} needs a marker to be found again`);
|
|
272
|
+
if (!carries(body, marker, match)) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`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`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The oldest comment on `pr` that `opts` takes for the caller's own. */
|
|
280
|
+
async function ownComment(t: Threads, pr: number, marker: string, opts: MarkerMatch): Promise<Thread | undefined> {
|
|
281
|
+
if (!marker) throw new Error("the comment needs a marker to be found");
|
|
282
|
+
return (await t.comments(pr)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function upsertCommentWith(t: Threads, pr: number, marker: string, body: string, opts: MarkerMatch, check: boolean): Promise<CommentResult> {
|
|
286
|
+
if (check) checkMarker("comment", marker, body, opts.match);
|
|
287
|
+
const mine = await ownComment(t, pr, marker, opts);
|
|
288
|
+
if (!mine) {
|
|
289
|
+
const made = await t.addComment(pr, body);
|
|
290
|
+
return { action: "created", id: made?.id ?? 0, ...(made?.url ? { url: made.url } : {}) };
|
|
291
|
+
}
|
|
292
|
+
if (mine.body === body) return { action: "unchanged", id: mine.id, ...(mine.url ? { url: mine.url } : {}) };
|
|
293
|
+
const edited = await t.editComment(pr, mine.id, body);
|
|
294
|
+
const url = edited?.url ?? mine.url;
|
|
295
|
+
return { action: "updated", id: mine.id, ...(url ? { url } : {}) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function trackIssueWith(t: Threads, marker: string, finding: IssueFinding | null, opts: TrackIssueOptions, check: boolean): Promise<IssueResult> {
|
|
299
|
+
if (finding && check) checkMarker("issue", marker, finding.body, opts.match);
|
|
300
|
+
const mine = (await t.openIssues(marker)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
|
|
301
|
+
const at = (i: Thread | undefined): Omit<IssueResult, "action"> => (i ? { number: i.id, ...(i.url ? { url: i.url } : {}) } : {});
|
|
302
|
+
if (finding === null) {
|
|
303
|
+
if (!mine) return { action: "none" };
|
|
304
|
+
if (!opts.closeOnClear) return { action: "left", ...at(mine) };
|
|
305
|
+
await t.closeIssue(mine.id, opts.clearComment);
|
|
306
|
+
return { action: "closed", ...at(mine) };
|
|
307
|
+
}
|
|
308
|
+
if (!mine) return { action: "opened", ...at(await t.openIssue(finding.title, finding.body)) };
|
|
309
|
+
if (sameIssue({ title: mine.title ?? "", body: mine.body }, finding, opts.sameWhen)) return { action: "unchanged", ...at(mine) };
|
|
310
|
+
const edited = await t.editIssue(mine.id, finding.title, finding.body);
|
|
311
|
+
return { action: "updated", ...at(mine), ...(edited?.url ? { url: edited.url } : {}) };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function sameIssue(open: IssueFinding, finding: IssueFinding, sameWhen: TrackIssueOptions["sameWhen"]): boolean {
|
|
315
|
+
if (typeof sameWhen === "function") return sameWhen(open, finding);
|
|
316
|
+
if (open.title !== finding.title) return false;
|
|
317
|
+
return typeof sameWhen === "string" && sameWhen !== "" ? open.body.includes(sameWhen) : open.body === finding.body;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The recipes every client shares, over its own {@link Threads}. */
|
|
321
|
+
function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "findComment" | "trackIssue" | "upsertIssue"> {
|
|
322
|
+
return {
|
|
323
|
+
async findComment(pr, marker, opts = {}) {
|
|
324
|
+
const c = await ownComment(t, pr, marker, opts);
|
|
325
|
+
return c ? { id: c.id, body: c.body, ...(c.url ? { url: c.url } : {}) } : undefined;
|
|
326
|
+
},
|
|
327
|
+
upsertComment: (pr, marker, body, opts = {}) => upsertCommentWith(t, pr, marker, body, opts, true),
|
|
328
|
+
upsertNote: async (pr, marker, body) => (await upsertCommentWith(t, pr, marker, body, {}, false)).url,
|
|
329
|
+
trackIssue: (marker, finding, opts = {}) => trackIssueWith(t, marker, finding, opts, true),
|
|
330
|
+
upsertIssue: async (marker, title, body) => (await trackIssueWith(t, marker, { title, body }, {}, false)).url,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
115
333
|
|
|
116
334
|
export interface GithubForgeOptions {
|
|
117
335
|
/** `$GITHUB_API_URL`: `https://api.github.com`, `<host>/api/v3`, or Forgejo's `<host>/api/v1`. */
|
|
@@ -125,8 +343,13 @@ export interface GithubForgeOptions {
|
|
|
125
343
|
fetch?: ForgeFetch;
|
|
126
344
|
}
|
|
127
345
|
|
|
346
|
+
/** GitHub keeps a status description to 140 characters; Forgejo follows. */
|
|
347
|
+
const GITHUB_DESCRIPTION = 140;
|
|
348
|
+
/** GitLab keeps a status description to 255 characters. */
|
|
349
|
+
const GITLAB_DESCRIPTION = 255;
|
|
350
|
+
|
|
128
351
|
/** Which of the two GitHub-shaped forges a client is talking to. */
|
|
129
|
-
function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions):
|
|
352
|
+
function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): GithubShapedForge {
|
|
130
353
|
const host = (opts.host ?? "github.com").toLowerCase();
|
|
131
354
|
const api = transport(
|
|
132
355
|
opts.apiBase,
|
|
@@ -139,25 +362,43 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrF
|
|
|
139
362
|
// GitHub pages with per_page (up to 100); Forgejo with limit (capped at 50 by default).
|
|
140
363
|
const pageSize = kind === "github" ? 100 : 50;
|
|
141
364
|
const pageParams = (page: number) => (kind === "github" ? `per_page=100&page=${page}` : `limit=50&page=${page}`);
|
|
365
|
+
const thread = (v: unknown): Thread | undefined => {
|
|
366
|
+
if (!isObject(v)) return undefined;
|
|
367
|
+
const id = num(v.number) ?? num(v.id);
|
|
368
|
+
if (id === undefined) return undefined;
|
|
369
|
+
const author = loginOf(v);
|
|
370
|
+
const url = str(v.html_url);
|
|
371
|
+
return { id, body: str(v.body) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
372
|
+
};
|
|
373
|
+
const threads = (items: unknown[]): Thread[] => items.map(thread).filter((t): t is Thread => t !== undefined);
|
|
374
|
+
const t: Threads = {
|
|
375
|
+
comments: async (pr) => threads(await api.list(`${repo}/issues/${pr}/comments`, pageParams, pageSize)),
|
|
376
|
+
addComment: async (pr, body) => thread(await api.call("POST", `${repo}/issues/${pr}/comments`, { body })),
|
|
377
|
+
editComment: async (_pr, id, body) => thread(await api.call("PATCH", `${repo}/issues/comments/${id}`, { body })),
|
|
378
|
+
// Paged in full rather than searched: GitHub's search index lags a
|
|
379
|
+
// write, so a run straight after another would miss the issue it just
|
|
380
|
+
// opened, and Forgejo has no `/search/issues` at all. Both list pull
|
|
381
|
+
// requests among issues; one of those is never ours.
|
|
382
|
+
openIssues: async () =>
|
|
383
|
+
threads((await api.list(`${repo}/issues?state=open`, pageParams, pageSize)).filter((i) => isObject(i) && !isObject(i.pull_request))),
|
|
384
|
+
openIssue: async (title, body) => thread(await api.call("POST", `${repo}/issues`, { title, body })),
|
|
385
|
+
editIssue: async (n, title, body) => thread(await api.call("PATCH", `${repo}/issues/${n}`, { title, body })),
|
|
386
|
+
async closeIssue(n, comment) {
|
|
387
|
+
if (comment) await api.call("POST", `${repo}/issues/${n}/comments`, { body: comment });
|
|
388
|
+
await api.call("PATCH", `${repo}/issues/${n}`, { state: "closed" });
|
|
389
|
+
},
|
|
390
|
+
};
|
|
142
391
|
return {
|
|
143
392
|
kind,
|
|
144
393
|
host,
|
|
145
394
|
...(opts.runUrl ? { runUrl: opts.runUrl } : {}),
|
|
146
395
|
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
|
-
},
|
|
396
|
+
...threadCalls(t),
|
|
156
397
|
async setStatus(sha, status) {
|
|
157
398
|
await api.call("POST", `${repo}/statuses/${sha}`, {
|
|
158
399
|
state: status.state,
|
|
159
400
|
context: status.context,
|
|
160
|
-
description: status.description,
|
|
401
|
+
description: status.description.slice(0, GITHUB_DESCRIPTION),
|
|
161
402
|
...(status.url ? { target_url: status.url } : {}),
|
|
162
403
|
});
|
|
163
404
|
},
|
|
@@ -197,12 +438,12 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrF
|
|
|
197
438
|
}
|
|
198
439
|
|
|
199
440
|
/** A GitHub client (github.com or GitHub Enterprise Server). */
|
|
200
|
-
export function githubForge(opts: GithubForgeOptions):
|
|
441
|
+
export function githubForge(opts: GithubForgeOptions): GithubShapedForge {
|
|
201
442
|
return githubShaped("github", opts);
|
|
202
443
|
}
|
|
203
444
|
|
|
204
445
|
/** A Forgejo (or Gitea) client. `host` is required: Forgejo has no default host. */
|
|
205
|
-
export function forgejoForge(opts: GithubForgeOptions & { host: string }):
|
|
446
|
+
export function forgejoForge(opts: GithubForgeOptions & { host: string }): GithubShapedForge {
|
|
206
447
|
return githubShaped("forgejo", opts);
|
|
207
448
|
}
|
|
208
449
|
|
|
@@ -212,6 +453,12 @@ export interface GitlabForgeOptions {
|
|
|
212
453
|
/** `$CI_PROJECT_ID`, or the URL-encoded path. */
|
|
213
454
|
project: string;
|
|
214
455
|
token: string;
|
|
456
|
+
/**
|
|
457
|
+
* The header the token goes in: `PRIVATE-TOKEN` (the default) for an
|
|
458
|
+
* access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. One in the other's header
|
|
459
|
+
* is a 401.
|
|
460
|
+
*/
|
|
461
|
+
tokenHeader?: "PRIVATE-TOKEN" | "JOB-TOKEN";
|
|
215
462
|
/** Default gitlab.com. */
|
|
216
463
|
host?: string;
|
|
217
464
|
runUrl?: string;
|
|
@@ -220,33 +467,78 @@ export interface GitlabForgeOptions {
|
|
|
220
467
|
|
|
221
468
|
const GITLAB_STATE: Record<ForgeStatus["state"], string> = { pending: "pending", success: "success", failure: "failed" };
|
|
222
469
|
|
|
223
|
-
/**
|
|
470
|
+
/**
|
|
471
|
+
* A GitLab client. Writing notes needs an access token with the `api` scope
|
|
472
|
+
* on most instances: the job token reaches only the endpoints the instance's
|
|
473
|
+
* job-token allowlist names.
|
|
474
|
+
*/
|
|
224
475
|
export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
225
476
|
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));
|
|
477
|
+
const api = transport(opts.apiBase, { [opts.tokenHeader ?? "PRIVATE-TOKEN"]: opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
|
|
227
478
|
const project = `/projects/${encodeURIComponent(decodeURIComponent(opts.project))}`;
|
|
228
479
|
const pageParams = (page: number) => `per_page=100&page=${page}`;
|
|
480
|
+
const authorOf = (v: Record<string, unknown>): string | undefined => (isObject(v.author) ? str(v.author.username) : undefined);
|
|
481
|
+
const note = (v: unknown): Thread | undefined => {
|
|
482
|
+
if (!isObject(v)) return undefined;
|
|
483
|
+
const id = num(v.id);
|
|
484
|
+
if (id === undefined) return undefined;
|
|
485
|
+
const author = authorOf(v);
|
|
486
|
+
return { id, body: str(v.body) ?? "", ...(author ? { author } : {}) };
|
|
487
|
+
};
|
|
488
|
+
const issue = (v: unknown): Thread | undefined => {
|
|
489
|
+
if (!isObject(v)) return undefined;
|
|
490
|
+
const id = num(v.iid);
|
|
491
|
+
if (id === undefined) return undefined;
|
|
492
|
+
const author = authorOf(v);
|
|
493
|
+
const url = str(v.web_url);
|
|
494
|
+
return { id, body: str(v.description) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
495
|
+
};
|
|
496
|
+
const notes = `${project}/merge_requests`;
|
|
497
|
+
const t: Threads = {
|
|
498
|
+
comments: async (mr) =>
|
|
499
|
+
(await api.list(`${notes}/${mr}/notes`, pageParams, 100))
|
|
500
|
+
// System notes ("added 1 commit") are the forge's own, never ours.
|
|
501
|
+
.filter((n) => !(isObject(n) && n.system === true))
|
|
502
|
+
.map(note)
|
|
503
|
+
.filter((n): n is Thread => n !== undefined),
|
|
504
|
+
addComment: async (mr, body) => note(await api.call("POST", `${notes}/${mr}/notes`, { body })),
|
|
505
|
+
editComment: async (mr, id, body) => note(await api.call("PUT", `${notes}/${mr}/notes/${id}`, { body })),
|
|
506
|
+
// `search` narrows the list server-side to issues whose description
|
|
507
|
+
// carries the marker, as `reconcilePr`'s GitLab issue does (#2292); the
|
|
508
|
+
// match itself is still decided here.
|
|
509
|
+
openIssues: async (marker) =>
|
|
510
|
+
(await api.list(`${project}/issues?state=opened&search=${encodeURIComponent(marker)}&in=description`, pageParams, 100))
|
|
511
|
+
.map(issue)
|
|
512
|
+
.filter((i): i is Thread => i !== undefined),
|
|
513
|
+
openIssue: async (title, body) => issue(await api.call("POST", `${project}/issues`, { title, description: body })),
|
|
514
|
+
editIssue: async (n, title, body) => issue(await api.call("PUT", `${project}/issues/${n}`, { title, description: body })),
|
|
515
|
+
async closeIssue(n, comment) {
|
|
516
|
+
if (comment) await api.call("POST", `${project}/issues/${n}/notes`, { body: comment });
|
|
517
|
+
await api.call("PUT", `${project}/issues/${n}`, { state_event: "close" });
|
|
518
|
+
},
|
|
519
|
+
};
|
|
229
520
|
return {
|
|
230
521
|
kind: "gitlab",
|
|
231
522
|
host,
|
|
232
523
|
...(opts.runUrl ? { runUrl: opts.runUrl } : {}),
|
|
233
524
|
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
|
-
},
|
|
525
|
+
...threadCalls(t),
|
|
243
526
|
async setStatus(sha, status) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
527
|
+
try {
|
|
528
|
+
await api.call("POST", `${project}/statuses/${sha}`, {
|
|
529
|
+
state: GITLAB_STATE[status.state],
|
|
530
|
+
name: status.context,
|
|
531
|
+
description: status.description.slice(0, GITLAB_DESCRIPTION),
|
|
532
|
+
...(status.url ? { target_url: status.url } : {}),
|
|
533
|
+
...(status.pipelineId !== undefined ? { pipeline_id: status.pipelineId } : {}),
|
|
534
|
+
});
|
|
535
|
+
} catch (err) {
|
|
536
|
+
// GitLab answers 400 to a status set again to the state it already
|
|
537
|
+
// has ("Cannot transition status via :success from :success"): the
|
|
538
|
+
// status is what was asked for.
|
|
539
|
+
if (err instanceof ForgeApiError && err.status === 400 && /cannot transition status/i.test(err.message)) return;
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
250
542
|
},
|
|
251
543
|
async approvers(mr) {
|
|
252
544
|
const approvals = await api.call("GET", `${project}/merge_requests/${mr}/approvals`);
|
|
@@ -268,8 +560,19 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
268
560
|
|
|
269
561
|
/** Thrown when the environment lacks what a forge client needs. */
|
|
270
562
|
export class ForgeEnvironmentError extends Error {
|
|
271
|
-
|
|
272
|
-
|
|
563
|
+
/**
|
|
564
|
+
* @param kind The forge, or undefined when the environment does not say which.
|
|
565
|
+
* @param missing What is missing, each naming the variable to set.
|
|
566
|
+
*/
|
|
567
|
+
constructor(
|
|
568
|
+
readonly kind: ForgeKind | undefined,
|
|
569
|
+
readonly missing: string[],
|
|
570
|
+
) {
|
|
571
|
+
super(
|
|
572
|
+
kind
|
|
573
|
+
? `--forge ${kind} needs ${missing.join(", ")} in the environment`
|
|
574
|
+
: "the forge is not known: this is not a GitHub Actions, GitLab CI or Forgejo Actions job, so name the forge",
|
|
575
|
+
);
|
|
273
576
|
this.name = "ForgeEnvironmentError";
|
|
274
577
|
}
|
|
275
578
|
}
|
|
@@ -284,10 +587,12 @@ const hostOf = (url: string | undefined): string | undefined => {
|
|
|
284
587
|
};
|
|
285
588
|
|
|
286
589
|
/**
|
|
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
|
|
590
|
+
* A client for `kind` from the CI job's environment, as the pull-request
|
|
591
|
+
* loop reads it. The token is `CHANT_FORGE_TOKEN` when set, else the forge's
|
|
592
|
+
* own: `GITHUB_TOKEN` (also set on Forgejo Actions) or `GITLAB_TOKEN`.
|
|
593
|
+
* GitLab's `CI_JOB_TOKEN` cannot write notes, so a GitLab pipeline names a
|
|
594
|
+
* project access token. {@link forgeTargetFromEnv} is the general reading,
|
|
595
|
+
* with the finding modes' token order.
|
|
291
596
|
*/
|
|
292
597
|
export function forgeFromEnv(kind: ForgeKind, env: Record<string, string | undefined> = process.env, fetchImpl?: ForgeFetch): PrForge {
|
|
293
598
|
const f = fetchImpl ? { fetch: fetchImpl } : {};
|
|
@@ -330,3 +635,197 @@ export function forgeFromEnv(kind: ForgeKind, env: Record<string, string | undef
|
|
|
330
635
|
};
|
|
331
636
|
return kind === "forgejo" ? forgejoForge({ ...opts, host: host! }) : githubForge({ ...opts, host: host ?? "github.com" });
|
|
332
637
|
}
|
|
638
|
+
|
|
639
|
+
// ── Where a CI job posts (#3759) ─────────────────────────────────────────
|
|
640
|
+
|
|
641
|
+
type Env = Record<string, string | undefined>;
|
|
642
|
+
|
|
643
|
+
const truthy = (v: string | undefined): boolean => v === "true" || v === "1";
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* The forge a CI job runs on, from its environment, or undefined outside a
|
|
647
|
+
* GitHub Actions, GitLab CI or Forgejo Actions job. Forgejo is told apart by
|
|
648
|
+
* `FORGEJO_ACTIONS` or `GITEA_ACTIONS`, or by a GitHub-style job whose
|
|
649
|
+
* `GITHUB_API_URL` ends in `/api/v1`, which GitHub never serves.
|
|
650
|
+
*/
|
|
651
|
+
export function detectForge(env: Env = process.env): ForgeKind | undefined {
|
|
652
|
+
if (truthy(env.GITLAB_CI)) return "gitlab";
|
|
653
|
+
if (truthy(env.FORGEJO_ACTIONS) || truthy(env.GITEA_ACTIONS)) return "forgejo";
|
|
654
|
+
if (truthy(env.GITHUB_ACTIONS)) return /\/api\/v1\/?$/.test(env.GITHUB_API_URL ?? "") ? "forgejo" : "github";
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Where a CI job posts: the forge, the repository, the token, and the pull request and commit when there are ones. */
|
|
659
|
+
export interface ForgeTarget {
|
|
660
|
+
kind: ForgeKind;
|
|
661
|
+
/** The REST base: `https://api.github.com`, `<host>/api/v3`, `<host>/api/v1`, or `<host>/api/v4`. */
|
|
662
|
+
apiBase: string;
|
|
663
|
+
/** `owner/name` on GitHub and Forgejo; the project id or path on GitLab. */
|
|
664
|
+
repo: string;
|
|
665
|
+
/** Lower-cased. */
|
|
666
|
+
host: string;
|
|
667
|
+
token: string;
|
|
668
|
+
/** The variable the token came from, for messages. Never the token itself. */
|
|
669
|
+
tokenSource: string;
|
|
670
|
+
/** GitLab: `JOB-TOKEN` when the token is `CI_JOB_TOKEN`, else `PRIVATE-TOKEN`. */
|
|
671
|
+
tokenHeader?: "PRIVATE-TOKEN" | "JOB-TOKEN";
|
|
672
|
+
/** The pull request's number, or the merge request's iid. */
|
|
673
|
+
pr?: number;
|
|
674
|
+
/** The commit a status belongs on: the pull request's head when the event names it. */
|
|
675
|
+
sha?: string;
|
|
676
|
+
/** The run, to link from a status. */
|
|
677
|
+
runUrl?: string;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export interface ForgeTargetOptions {
|
|
681
|
+
/** The forge, in place of {@link detectForge}. */
|
|
682
|
+
kind?: ForgeKind;
|
|
683
|
+
/** Read the token from this variable only, in place of the default order. */
|
|
684
|
+
tokenEnv?: string;
|
|
685
|
+
/** Refuse an environment with no pull or merge request. Default false. */
|
|
686
|
+
requirePr?: boolean;
|
|
687
|
+
/** Reads the event payload `GITHUB_EVENT_PATH` names. Default `node:fs`. */
|
|
688
|
+
readFile?: (path: string) => string;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The token order {@link forgeTargetFromEnv} reads, as guide/ops documents
|
|
693
|
+
* it: a chant-specific variable first, for posting to an instance other than
|
|
694
|
+
* the one the job runs on, then what the job already carries (#3802). A
|
|
695
|
+
* Forgejo job reads `FORGEJO_TOKEN` before the `GITHUB_*` names, and the
|
|
696
|
+
* GitHub row has its own chant variable rather than the Forgejo one.
|
|
697
|
+
*/
|
|
698
|
+
export const FORGE_TOKEN_ORDER: Record<ForgeKind, readonly string[]> = {
|
|
699
|
+
github: ["CHANT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
|
|
700
|
+
forgejo: ["CHANT_FORGEJO_TOKEN", "FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
|
|
701
|
+
gitlab: ["CHANT_GITLAB_TOKEN", "GITLAB_TOKEN", "CI_JOB_TOKEN"],
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
/** The pull request and its head from a GitHub-style event payload, or from the ref. */
|
|
705
|
+
function prFromEvent(payload: unknown, ref: string | undefined): { pr?: number; sha?: string } {
|
|
706
|
+
const out: { pr?: number; sha?: string } = {};
|
|
707
|
+
if (isObject(payload)) {
|
|
708
|
+
const pr = isObject(payload.pull_request) ? payload.pull_request : undefined;
|
|
709
|
+
const issue = isObject(payload.issue) ? payload.issue : undefined;
|
|
710
|
+
const n = num(pr?.number) ?? (issue && isObject(issue.pull_request) ? num(issue.number) : undefined) ?? (pr ? num(payload.number) : undefined);
|
|
711
|
+
if (n !== undefined && n > 0) out.pr = n;
|
|
712
|
+
const head = pr && isObject(pr.head) ? str(pr.head.sha) : undefined;
|
|
713
|
+
if (head) out.sha = head;
|
|
714
|
+
}
|
|
715
|
+
if (out.pr === undefined) {
|
|
716
|
+
const m = /^refs\/pull\/(\d+)\/(?:merge|head)$/.exec(ref ?? "");
|
|
717
|
+
if (m) out.pr = Number(m[1]);
|
|
718
|
+
}
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Where a CI job posts, read from its environment: the forge
|
|
724
|
+
* ({@link detectForge} unless `kind` is given), the repository, the REST
|
|
725
|
+
* base, the host, the token, and the pull request and its head commit when
|
|
726
|
+
* the run has them. Throws {@link ForgeEnvironmentError} naming every
|
|
727
|
+
* missing piece.
|
|
728
|
+
*
|
|
729
|
+
* - GitHub Actions: `GITHUB_REPOSITORY`, `GITHUB_API_URL` (default
|
|
730
|
+
* `https://api.github.com`), `GITHUB_SERVER_URL`; the pull request from
|
|
731
|
+
* the event payload at `GITHUB_EVENT_PATH` or `refs/pull/<n>/` in
|
|
732
|
+
* `GITHUB_REF`, its head from the payload, else `GITHUB_SHA`.
|
|
733
|
+
* - Forgejo Actions: the same, with `FORGEJO_*` read before `GITHUB_*`,
|
|
734
|
+
* and the REST base `<server>/api/v1` when no API URL is set.
|
|
735
|
+
* - GitLab CI: `CI_API_V4_URL` (or `CI_SERVER_URL/api/v4`), the merge
|
|
736
|
+
* request's project before the pipeline's (a fork's merge request runs in
|
|
737
|
+
* the fork), `CI_MERGE_REQUEST_IID`, `CI_COMMIT_SHA`, `CI_PIPELINE_URL`.
|
|
738
|
+
*
|
|
739
|
+
* The token is read in {@link FORGE_TOKEN_ORDER}, or from `tokenEnv` alone.
|
|
740
|
+
*/
|
|
741
|
+
export function forgeTargetFromEnv(env: Env = process.env, opts: ForgeTargetOptions = {}): ForgeTarget {
|
|
742
|
+
const kind = opts.kind ?? detectForge(env);
|
|
743
|
+
if (!kind) throw new ForgeEnvironmentError(undefined, []);
|
|
744
|
+
const pick = (...names: string[]): string | undefined => names.map((n) => env[n]?.trim()).find((v) => v !== undefined && v !== "");
|
|
745
|
+
const names = opts.tokenEnv ? [opts.tokenEnv] : FORGE_TOKEN_ORDER[kind];
|
|
746
|
+
const tokenSource = names.find((n) => env[n]?.trim());
|
|
747
|
+
const token = tokenSource ? env[tokenSource]!.trim() : undefined;
|
|
748
|
+
const missing: string[] = [];
|
|
749
|
+
const tokenMissing = `a token (${names.join(", ")})`;
|
|
750
|
+
|
|
751
|
+
if (kind === "gitlab") {
|
|
752
|
+
const server = pick("CI_SERVER_URL")?.replace(/\/+$/, "");
|
|
753
|
+
const apiBase = pick("CI_API_V4_URL")?.replace(/\/+$/, "") ?? (server ? `${server}/api/v4` : undefined);
|
|
754
|
+
const repo = pick("CI_MERGE_REQUEST_PROJECT_ID", "CI_PROJECT_ID", "CI_MERGE_REQUEST_PROJECT_PATH", "CI_PROJECT_PATH");
|
|
755
|
+
const iid = num(pick("CI_MERGE_REQUEST_IID"));
|
|
756
|
+
if (!apiBase) missing.push("CI_API_V4_URL");
|
|
757
|
+
if (!repo) missing.push("CI_PROJECT_ID");
|
|
758
|
+
if (opts.requirePr && !iid) missing.push("CI_MERGE_REQUEST_IID (a merge request pipeline)");
|
|
759
|
+
if (!token) missing.push(tokenMissing);
|
|
760
|
+
if (missing.length > 0) throw new ForgeEnvironmentError(kind, missing);
|
|
761
|
+
const sha = pick("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "CI_COMMIT_SHA");
|
|
762
|
+
const runUrl = pick("CI_PIPELINE_URL");
|
|
763
|
+
return {
|
|
764
|
+
kind,
|
|
765
|
+
apiBase: apiBase!,
|
|
766
|
+
repo: repo!,
|
|
767
|
+
host: pick("CI_SERVER_HOST")?.toLowerCase() ?? hostOf(server) ?? hostOf(apiBase) ?? "gitlab.com",
|
|
768
|
+
token: token!,
|
|
769
|
+
tokenSource: tokenSource!,
|
|
770
|
+
tokenHeader: tokenSource === "CI_JOB_TOKEN" ? "JOB-TOKEN" : "PRIVATE-TOKEN",
|
|
771
|
+
...(iid ? { pr: iid } : {}),
|
|
772
|
+
...(sha ? { sha } : {}),
|
|
773
|
+
...(runUrl ? { runUrl } : {}),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const fj = kind === "forgejo";
|
|
778
|
+
const read = (name: string): string | undefined => (fj ? pick(`FORGEJO_${name}`, `GITHUB_${name}`) : pick(`GITHUB_${name}`));
|
|
779
|
+
const repo = read("REPOSITORY");
|
|
780
|
+
const server = read("SERVER_URL")?.replace(/\/+$/, "");
|
|
781
|
+
const apiBase = read("API_URL")?.replace(/\/+$/, "") ?? (fj ? (server ? `${server}/api/v1` : undefined) : "https://api.github.com");
|
|
782
|
+
let event: unknown;
|
|
783
|
+
const eventPath = read("EVENT_PATH");
|
|
784
|
+
if (eventPath) {
|
|
785
|
+
try {
|
|
786
|
+
event = JSON.parse((opts.readFile ?? ((p: string) => readFileSync(p, "utf8")))(eventPath));
|
|
787
|
+
} catch {
|
|
788
|
+
// An unreadable payload is not fatal: the ref may still name the pull request.
|
|
789
|
+
event = undefined;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const { pr, sha: head } = prFromEvent(event, read("REF"));
|
|
793
|
+
if (!repo) missing.push(fj ? "FORGEJO_REPOSITORY or GITHUB_REPOSITORY" : "GITHUB_REPOSITORY");
|
|
794
|
+
if (!apiBase) missing.push("GITHUB_SERVER_URL or GITHUB_API_URL");
|
|
795
|
+
if (opts.requirePr && !pr) missing.push("a pull request (a pull_request event, or refs/pull/<n>/ in GITHUB_REF)");
|
|
796
|
+
if (!token) missing.push(tokenMissing);
|
|
797
|
+
if (missing.length > 0) throw new ForgeEnvironmentError(kind, missing);
|
|
798
|
+
const sha = head ?? read("SHA");
|
|
799
|
+
const runId = read("RUN_ID");
|
|
800
|
+
const runUrl = server && runId ? `${server}/${repo}/actions/runs/${runId}` : undefined;
|
|
801
|
+
return {
|
|
802
|
+
kind,
|
|
803
|
+
apiBase: apiBase!,
|
|
804
|
+
repo: repo!,
|
|
805
|
+
host: hostOf(server) ?? (fj ? hostOf(apiBase)! : "github.com"),
|
|
806
|
+
token: token!,
|
|
807
|
+
tokenSource: tokenSource!,
|
|
808
|
+
...(pr ? { pr } : {}),
|
|
809
|
+
...(sha ? { sha } : {}),
|
|
810
|
+
...(runUrl ? { runUrl } : {}),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** The client for a {@link ForgeTarget}, from {@link forgeTargetFromEnv} or built by hand. */
|
|
815
|
+
export function forgeClient(target: ForgeTarget, fetchImpl?: ForgeFetch): PrForge {
|
|
816
|
+
const f = fetchImpl ? { fetch: fetchImpl } : {};
|
|
817
|
+
const run = target.runUrl ? { runUrl: target.runUrl } : {};
|
|
818
|
+
if (target.kind === "gitlab") {
|
|
819
|
+
return gitlabForge({
|
|
820
|
+
apiBase: target.apiBase,
|
|
821
|
+
project: target.repo,
|
|
822
|
+
token: target.token,
|
|
823
|
+
host: target.host,
|
|
824
|
+
...(target.tokenHeader ? { tokenHeader: target.tokenHeader } : {}),
|
|
825
|
+
...run,
|
|
826
|
+
...f,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
const opts = { apiBase: target.apiBase, repo: target.repo, token: target.token, host: target.host, ...run, ...f };
|
|
830
|
+
return target.kind === "forgejo" ? forgejoForge(opts) : githubForge(opts);
|
|
831
|
+
}
|