@zeroroot-ai/zerocool 0.4.0 → 0.6.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/dispatch.d.ts +64 -1
- package/dist/dispatch.js +174 -4
- package/dist/fix.d.ts +207 -0
- package/dist/fix.js +255 -0
- package/dist/gitlab.d.ts +118 -0
- package/dist/gitlab.js +176 -0
- package/dist/watch.d.ts +219 -0
- package/dist/watch.js +346 -0
- package/package.json +1 -1
package/dist/fix.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Fix — what the always-on agent does with a Finding (zerocool-plugins#89).
|
|
3
|
+
*
|
|
4
|
+
* After a Scan mission lands, the agent works the Application's open Findings
|
|
5
|
+
* in priority order. For each one it can act on it rewrites the repository,
|
|
6
|
+
* runs the repository's own tests, and only then opens a merge request set to
|
|
7
|
+
* merge itself when the pipeline succeeds. It records the scan verdict on the
|
|
8
|
+
* commit as `gibson/scan` and posts one note saying what it fixed, what it did
|
|
9
|
+
* not, and why.
|
|
10
|
+
*
|
|
11
|
+
* THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
|
|
12
|
+
* Finding: it stops every other fix behind it. So the change is applied to a
|
|
13
|
+
* workspace, the tests run there, and a failure resets the workspace and leaves
|
|
14
|
+
* the Finding `open` with its reason recorded. Nothing unproven reaches GitLab.
|
|
15
|
+
*
|
|
16
|
+
* THE FIX NEVER MARKS A FINDING `verified`. A merge is not evidence that a
|
|
17
|
+
* rescan did not see the weakness again — only the rescan is, and absence is
|
|
18
|
+
* not an observation, so it is reconciled once at a scan's completion
|
|
19
|
+
* (gibson#1686). The Fix moves a Finding `open` → `fixing` when its merge
|
|
20
|
+
* request opens and `fixing` → `fixed` when GitLab reports that request merged.
|
|
21
|
+
*
|
|
22
|
+
* THE TOKEN NEVER APPEARS IN ANYTHING THIS WRITES. It reaches GitLab only as a
|
|
23
|
+
* `PRIVATE-TOKEN` header inside {@link gitlabRestWriter}. No branch name, merge
|
|
24
|
+
* request title, description, note or status description is built from it, and
|
|
25
|
+
* `fix.test.ts` proves that with a sentinel that would show up in every
|
|
26
|
+
* recorded output if it ever did.
|
|
27
|
+
*
|
|
28
|
+
* TWO SEAMS ARE DELIBERATELY UNIMPLEMENTED HERE. Reading an Application's
|
|
29
|
+
* Findings and writing a Finding's status both exist on the Go SDK as of
|
|
30
|
+
* v0.174.0, and on neither the TypeScript SDK nor this package: `@zeroroot-ai/sdk`
|
|
31
|
+
* carries no `applicationFindings` and its `observe` admits only host, domain,
|
|
32
|
+
* subdomain, credential, account and memory. That is sdk-ts#51. Until it lands,
|
|
33
|
+
* {@link FindingSource} and {@link FindingStatusWriter} have no platform
|
|
34
|
+
* implementation, and the Fix is driven by whatever supplies them. This is a
|
|
35
|
+
* seam, not a second code path: when the wire arrives there is one
|
|
36
|
+
* implementation to add and nothing here changes.
|
|
37
|
+
*/
|
|
38
|
+
/** Severities, worst first. Used for the note's counts and for a stable order. */
|
|
39
|
+
export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
40
|
+
/** Default ceiling on merge requests opened by one pass. */
|
|
41
|
+
export const DEFAULT_MAX_MERGE_REQUESTS = 10;
|
|
42
|
+
/**
|
|
43
|
+
* Run one Fix pass over an Application.
|
|
44
|
+
*
|
|
45
|
+
* Reconcile first, then fix. A Finding already `fixing` whose merge request has
|
|
46
|
+
* merged becomes `fixed` before anything new is opened, so a pass never opens a
|
|
47
|
+
* second merge request for a Finding the previous pass already resolved.
|
|
48
|
+
*/
|
|
49
|
+
export async function runFix(opts) {
|
|
50
|
+
const emit = (e) => opts.onEvent?.(e);
|
|
51
|
+
const cap = opts.maxMergeRequests ?? DEFAULT_MAX_MERGE_REQUESTS;
|
|
52
|
+
const outcomes = [];
|
|
53
|
+
const all = await opts.findings.findings(opts.application, ["open", "fixing"]);
|
|
54
|
+
const fixing = all.filter((f) => f.status === "fixing");
|
|
55
|
+
const open = all.filter((f) => f.status === "open");
|
|
56
|
+
emit({ type: "fix.start", application: opts.application, commit: opts.commit, open: open.length, fixing: fixing.length });
|
|
57
|
+
// Reconcile: a merge request that merged moves its Finding to `fixed`.
|
|
58
|
+
for (const finding of fixing) {
|
|
59
|
+
if (finding.mergeRequestIid === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
const mr = await opts.gitlab.mergeRequestState(finding.mergeRequestIid);
|
|
63
|
+
if (mr.state !== "merged")
|
|
64
|
+
continue;
|
|
65
|
+
await opts.status.markFixed(finding.id);
|
|
66
|
+
emit({ type: "fix.merged", findingId: finding.id, iid: mr.iid });
|
|
67
|
+
outcomes.push({ finding, result: "merged", reason: "", mergeRequest: mr });
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
emit({ type: "fix.error", stage: "status", message: message(e) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
let opened = 0;
|
|
74
|
+
for (const finding of open) {
|
|
75
|
+
if (opened >= cap) {
|
|
76
|
+
outcomes.push({ finding, result: "unfixed", reason: `merge request cap of ${cap} reached in this pass` });
|
|
77
|
+
emit({ type: "fix.skip", findingId: finding.id, reason: "cap reached" });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let plan;
|
|
81
|
+
try {
|
|
82
|
+
plan = await opts.planner.plan(finding, opts.workspace);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
await safeReset(opts.workspace);
|
|
86
|
+
outcomes.push({ finding, result: "unfixed", reason: `planning failed: ${message(e)}` });
|
|
87
|
+
emit({ type: "fix.error", stage: "plan", message: message(e) });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!plan) {
|
|
91
|
+
await safeReset(opts.workspace);
|
|
92
|
+
outcomes.push({ finding, result: "unfixed", reason: "no fix strategy for this finding" });
|
|
93
|
+
emit({ type: "fix.skip", findingId: finding.id, reason: "no fix strategy" });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const changes = opts.workspace.staged();
|
|
97
|
+
if (changes.length === 0) {
|
|
98
|
+
await safeReset(opts.workspace);
|
|
99
|
+
outcomes.push({ finding, result: "unfixed", reason: "the planner changed nothing" });
|
|
100
|
+
emit({ type: "fix.skip", findingId: finding.id, reason: "no change staged" });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
emit({ type: "fix.plan", findingId: finding.id, kind: plan.kind, summary: plan.summary });
|
|
104
|
+
const tests = await opts.workspace.test();
|
|
105
|
+
emit({ type: "fix.tests", findingId: finding.id, ok: tests.ok });
|
|
106
|
+
if (!tests.ok) {
|
|
107
|
+
await safeReset(opts.workspace);
|
|
108
|
+
outcomes.push({ finding, result: "unfixed", plan, reason: `the repository's tests failed: ${firstLines(tests.output)}` });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const branch = fixBranch(finding);
|
|
113
|
+
await opts.gitlab.commitToBranch(branch, opts.targetRef, `${plan.summary}\n\n${plan.detail}`, changes);
|
|
114
|
+
const mr = await opts.gitlab.openMergeRequest(branch, opts.targetRef, plan.summary, mergeRequestBody(finding, plan));
|
|
115
|
+
emit({ type: "fix.mr", findingId: finding.id, iid: mr.iid, webUrl: mr.webUrl });
|
|
116
|
+
opened++;
|
|
117
|
+
await opts.status.markFixing(finding.id, mr);
|
|
118
|
+
outcomes.push({ finding, result: "fixing", reason: "", mergeRequest: mr, plan });
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
outcomes.push({ finding, result: "unfixed", plan, reason: `GitLab refused the change: ${message(e)}` });
|
|
122
|
+
emit({ type: "fix.error", stage: "status", message: message(e) });
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await safeReset(opts.workspace);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const summary = {
|
|
129
|
+
application: opts.application,
|
|
130
|
+
commit: opts.commit,
|
|
131
|
+
outcomes,
|
|
132
|
+
clean: outcomes.every((o) => o.result !== "unfixed"),
|
|
133
|
+
};
|
|
134
|
+
// The verdict lands on the commit that was scanned, whatever happened above.
|
|
135
|
+
// A pass that could not reach GitLab for the status still reports its work
|
|
136
|
+
// through the return value, so the caller is never told the pass did nothing.
|
|
137
|
+
try {
|
|
138
|
+
await opts.gitlab.setCommitStatus(opts.commit, summary.clean ? "success" : "failed", statusDescription(summary), opts.pipelineUrl);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
emit({ type: "fix.error", stage: "note", message: message(e) });
|
|
142
|
+
}
|
|
143
|
+
for (const outcome of outcomes) {
|
|
144
|
+
if (outcome.result !== "fixing" || !outcome.mergeRequest)
|
|
145
|
+
continue;
|
|
146
|
+
try {
|
|
147
|
+
await opts.gitlab.comment(outcome.mergeRequest.iid, noteBody(summary));
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
emit({ type: "fix.error", stage: "note", message: message(e) });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return summary;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The branch one Finding's fix lands on. Stable, so a retry reuses it rather
|
|
157
|
+
* than opening a second merge request for the same Finding.
|
|
158
|
+
*
|
|
159
|
+
* The slug keeps only `[a-z0-9-]`. Dots are dropped rather than preserved:
|
|
160
|
+
* git refuses a ref containing `..`, and a vulnerability id is attacker-
|
|
161
|
+
* adjacent text that ends up interpolated into a URL path, so a dot run is
|
|
162
|
+
* exactly the shape not to carry through. The Finding id is appended so two
|
|
163
|
+
* occurrences of one CVE get two branches.
|
|
164
|
+
*/
|
|
165
|
+
export function fixBranch(finding) {
|
|
166
|
+
const slug = `${finding.vulnerabilityId || finding.id}`
|
|
167
|
+
.toLowerCase()
|
|
168
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
169
|
+
.replace(/^-+|-+$/g, "");
|
|
170
|
+
return `gibson/fix-${slug || "unclassified"}-${finding.id.slice(0, 8)}`;
|
|
171
|
+
}
|
|
172
|
+
/** Counts by severity, worst first, for the note and the status line. */
|
|
173
|
+
export function countsBySeverity(findings) {
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const s of SEVERITIES) {
|
|
176
|
+
const n = findings.filter((f) => f.severity.toLowerCase() === s).length;
|
|
177
|
+
if (n > 0)
|
|
178
|
+
out[s] = n;
|
|
179
|
+
}
|
|
180
|
+
const known = new Set(SEVERITIES);
|
|
181
|
+
const other = findings.filter((f) => !known.has(f.severity.toLowerCase())).length;
|
|
182
|
+
if (other > 0)
|
|
183
|
+
out.other = other;
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/** The one-line verdict GitLab shows beside the commit. */
|
|
187
|
+
export function statusDescription(summary) {
|
|
188
|
+
const fixing = summary.outcomes.filter((o) => o.result === "fixing").length;
|
|
189
|
+
const merged = summary.outcomes.filter((o) => o.result === "merged").length;
|
|
190
|
+
const unfixed = summary.outcomes.filter((o) => o.result === "unfixed").length;
|
|
191
|
+
if (summary.outcomes.length === 0)
|
|
192
|
+
return "no open findings";
|
|
193
|
+
return `${fixing} merge request(s) opened, ${merged} merged, ${unfixed} left for a human`;
|
|
194
|
+
}
|
|
195
|
+
/** The merge request description. Names the Finding it closes and how. */
|
|
196
|
+
export function mergeRequestBody(finding, plan) {
|
|
197
|
+
return [
|
|
198
|
+
plan.detail,
|
|
199
|
+
"",
|
|
200
|
+
`Finding: \`${finding.id}\``,
|
|
201
|
+
`Vulnerability: \`${finding.vulnerabilityId || "unclassified"}\``,
|
|
202
|
+
`Where: ${finding.placeLabel} \`${finding.placeKey}\``,
|
|
203
|
+
`Severity: ${finding.severity}${finding.priority ? ` · priority ${finding.priority}` : ""}`,
|
|
204
|
+
"",
|
|
205
|
+
"Opened by the Gibson always-on agent. It merges itself when the pipeline",
|
|
206
|
+
"succeeds. The finding is marked fixed on merge and verified only by a",
|
|
207
|
+
"later rescan.",
|
|
208
|
+
].join("\n");
|
|
209
|
+
}
|
|
210
|
+
/** The note posted on each merge request: what this pass did, in full. */
|
|
211
|
+
export function noteBody(summary) {
|
|
212
|
+
const lines = [
|
|
213
|
+
`### Gibson scan of \`${summary.commit.slice(0, 12)}\` on ${summary.application}`,
|
|
214
|
+
"",
|
|
215
|
+
];
|
|
216
|
+
const counts = countsBySeverity(summary.outcomes.map((o) => o.finding));
|
|
217
|
+
const rendered = Object.entries(counts)
|
|
218
|
+
.map(([k, v]) => `${v} ${k}`)
|
|
219
|
+
.join(", ");
|
|
220
|
+
lines.push(rendered ? `Findings this pass: ${rendered}.` : "No open findings this pass.");
|
|
221
|
+
lines.push("");
|
|
222
|
+
const fixed = summary.outcomes.filter((o) => o.result === "fixing" || o.result === "merged");
|
|
223
|
+
if (fixed.length > 0) {
|
|
224
|
+
lines.push("**Fixed**");
|
|
225
|
+
for (const o of fixed) {
|
|
226
|
+
const where = `${o.finding.placeLabel} \`${o.finding.placeKey}\``;
|
|
227
|
+
const link = o.mergeRequest?.webUrl ? ` — ${o.mergeRequest.webUrl}` : "";
|
|
228
|
+
lines.push(`- ${o.finding.vulnerabilityId || o.finding.id} in ${where}${link}`);
|
|
229
|
+
}
|
|
230
|
+
lines.push("");
|
|
231
|
+
}
|
|
232
|
+
const unfixed = summary.outcomes.filter((o) => o.result === "unfixed");
|
|
233
|
+
if (unfixed.length > 0) {
|
|
234
|
+
lines.push("**Left for a human**");
|
|
235
|
+
for (const o of unfixed) {
|
|
236
|
+
const where = `${o.finding.placeLabel} \`${o.finding.placeKey}\``;
|
|
237
|
+
lines.push(`- ${o.finding.vulnerabilityId || o.finding.id} in ${where} — ${o.reason}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
|
242
|
+
async function safeReset(ws) {
|
|
243
|
+
try {
|
|
244
|
+
await ws.reset();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// A reset that fails is reported by the next planner call changing nothing.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function firstLines(output, n = 3) {
|
|
251
|
+
return output.split("\n").slice(0, n).join(" ").trim();
|
|
252
|
+
}
|
|
253
|
+
function message(e) {
|
|
254
|
+
return e instanceof Error ? e.message : String(e);
|
|
255
|
+
}
|
package/dist/gitlab.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab — the trigger the always-on agent watches (zerocool-plugins#88).
|
|
3
|
+
*
|
|
4
|
+
* The agent polls one project's pipelines on one branch. It never receives a
|
|
5
|
+
* webhook and GitLab holds no Gibson credential: the poll runs outbound from
|
|
6
|
+
* the sandbox on the tenant's own project access token, resolved through the
|
|
7
|
+
* harness `GetCredential` under the dispatch grant. That direction is the point
|
|
8
|
+
* — a CI job cannot originate a Gibson mission (ADR-0063 admits a component
|
|
9
|
+
* only from inside a mission it was dispatched to), so the agent watches
|
|
10
|
+
* GitLab rather than GitLab calling Gibson.
|
|
11
|
+
*
|
|
12
|
+
* FINISHED MEANS `success`. A pipeline that failed published no image and left
|
|
13
|
+
* `main` unchanged, so there is nothing new to scan; treating it as a trigger
|
|
14
|
+
* would run a Scan mission against the image the previous pipeline built and
|
|
15
|
+
* report its findings against the new commit. `canceled` and `skipped` are the
|
|
16
|
+
* same case. The status this polls for is therefore `success`, named once in
|
|
17
|
+
* {@link TRIGGER_STATUS} rather than spelled at each call site.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is one `fetch` away from pure: {@link gitlabRest} takes the
|
|
20
|
+
* fetch it uses, so `watch.test.ts` drives a whole loop against a stub with no
|
|
21
|
+
* network and no token.
|
|
22
|
+
*/
|
|
23
|
+
/** The pipeline status that triggers a Scan mission. See the module note. */
|
|
24
|
+
export declare const TRIGGER_STATUS = "success";
|
|
25
|
+
/** GitLab's public host, when a tenant names no other instance. */
|
|
26
|
+
export declare const GITLAB_DEFAULT_URL = "https://gitlab.com";
|
|
27
|
+
/** One pipeline, reduced to what a trigger decision and a Scan mission need. */
|
|
28
|
+
export interface Pipeline {
|
|
29
|
+
/** GitLab's own pipeline id. Monotonic per project, and the checkpoint key. */
|
|
30
|
+
id: number;
|
|
31
|
+
/** The commit the pipeline ran on — the Scan mission's `repository.commit`. */
|
|
32
|
+
sha: string;
|
|
33
|
+
/** `success` for anything this module returns; carried for the console line. */
|
|
34
|
+
status: string;
|
|
35
|
+
/** The branch, echoed back so a caller can log what it matched. */
|
|
36
|
+
ref: string;
|
|
37
|
+
/** GitLab's page for the pipeline, for the console line and the MR note. */
|
|
38
|
+
webUrl: string;
|
|
39
|
+
}
|
|
40
|
+
/** The GitLab reads the watch loop makes. One method, so a stub is three lines. */
|
|
41
|
+
export interface GitLabClient {
|
|
42
|
+
/**
|
|
43
|
+
* The most recent successful pipeline on `ref`, or `undefined` when the
|
|
44
|
+
* project has none. Rejects when GitLab refuses or is unreachable — the loop
|
|
45
|
+
* reports that and keeps polling.
|
|
46
|
+
*/
|
|
47
|
+
latestFinishedPipeline(ref: string): Promise<Pipeline | undefined>;
|
|
48
|
+
}
|
|
49
|
+
/** What {@link gitlabRest} needs to reach one project. */
|
|
50
|
+
export interface GitLabRestOptions {
|
|
51
|
+
/** `group/project`, unencoded. Encoded once here, at the call. */
|
|
52
|
+
projectPath: string;
|
|
53
|
+
/** The project access token, resolved from the tenant secret store. */
|
|
54
|
+
token: string;
|
|
55
|
+
/** The instance. Defaults to {@link GITLAB_DEFAULT_URL}. */
|
|
56
|
+
baseUrl?: string;
|
|
57
|
+
/** Injectable transport. Defaults to the global `fetch`. */
|
|
58
|
+
fetch?: typeof globalThis.fetch;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read one pipeline out of GitLab's JSON. A row without an id or a sha is
|
|
62
|
+
* dropped rather than guessed at: a Scan mission keyed on a missing commit
|
|
63
|
+
* would scan whatever the workspace happened to hold.
|
|
64
|
+
*/
|
|
65
|
+
export declare function parsePipeline(raw: unknown): Pipeline | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* A {@link GitLabClient} over GitLab's REST API.
|
|
68
|
+
*
|
|
69
|
+
* The token travels in `PRIVATE-TOKEN`, which is what a project access token
|
|
70
|
+
* authenticates with. It is never logged and never placed in a URL, so it
|
|
71
|
+
* cannot reach the console stream through an error message that quotes the
|
|
72
|
+
* request.
|
|
73
|
+
*/
|
|
74
|
+
export declare function gitlabRest(opts: GitLabRestOptions): GitLabClient;
|
|
75
|
+
/** The `gibson/scan` commit status states GitLab accepts, narrowed to what we set. */
|
|
76
|
+
export type CommitStatusState = "success" | "failed" | "running";
|
|
77
|
+
/** The context name the scan status is reported under, named once. */
|
|
78
|
+
export declare const SCAN_STATUS_CONTEXT = "gibson/scan";
|
|
79
|
+
/** A merge request the Fix opened, reduced to what the Fix and a human need. */
|
|
80
|
+
export interface MergeRequest {
|
|
81
|
+
/** The project-scoped iid, which is what every later GitLab call takes. */
|
|
82
|
+
iid: number;
|
|
83
|
+
/** The page a human opens. Also what the Finding records. */
|
|
84
|
+
webUrl: string;
|
|
85
|
+
/** GitLab's own state: `opened`, `merged`, `closed`, `locked`. */
|
|
86
|
+
state: string;
|
|
87
|
+
}
|
|
88
|
+
/** One file the Fix rewrote, as the commit API wants it. */
|
|
89
|
+
export interface FileChange {
|
|
90
|
+
path: string;
|
|
91
|
+
content: string;
|
|
92
|
+
}
|
|
93
|
+
/** The GitLab writes the Fix makes. Kept behind an interface so a test needs no network. */
|
|
94
|
+
export interface GitLabWriter {
|
|
95
|
+
/**
|
|
96
|
+
* Commit `changes` onto a new branch off `startRef`. Returns the commit sha.
|
|
97
|
+
* The branch is created by the same call, so a half-made branch with no
|
|
98
|
+
* commit is not a state this can leave behind.
|
|
99
|
+
*/
|
|
100
|
+
commitToBranch(branch: string, startRef: string, message: string, changes: FileChange[]): Promise<string>;
|
|
101
|
+
/** Open a merge request set to merge itself when its pipeline succeeds. */
|
|
102
|
+
openMergeRequest(branch: string, targetRef: string, title: string, description: string): Promise<MergeRequest>;
|
|
103
|
+
/** The current state of a merge request, so a later pass can see it merged. */
|
|
104
|
+
mergeRequestState(iid: number): Promise<MergeRequest>;
|
|
105
|
+
/** Post a note on a merge request. */
|
|
106
|
+
comment(iid: number, body: string): Promise<void>;
|
|
107
|
+
/** Set the `gibson/scan` status on a commit. */
|
|
108
|
+
setCommitStatus(sha: string, state: CommitStatusState, description: string, targetUrl?: string): Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A {@link GitLabWriter} over GitLab's REST API.
|
|
112
|
+
*
|
|
113
|
+
* Every request carries the token in `PRIVATE-TOKEN` and never in a URL or a
|
|
114
|
+
* body, so a failure message that quotes the request cannot carry it into the
|
|
115
|
+
* console stream. The error text names the project, the branch and the status
|
|
116
|
+
* code — never a header.
|
|
117
|
+
*/
|
|
118
|
+
export declare function gitlabRestWriter(opts: GitLabRestOptions): GitLabWriter;
|
package/dist/gitlab.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab — the trigger the always-on agent watches (zerocool-plugins#88).
|
|
3
|
+
*
|
|
4
|
+
* The agent polls one project's pipelines on one branch. It never receives a
|
|
5
|
+
* webhook and GitLab holds no Gibson credential: the poll runs outbound from
|
|
6
|
+
* the sandbox on the tenant's own project access token, resolved through the
|
|
7
|
+
* harness `GetCredential` under the dispatch grant. That direction is the point
|
|
8
|
+
* — a CI job cannot originate a Gibson mission (ADR-0063 admits a component
|
|
9
|
+
* only from inside a mission it was dispatched to), so the agent watches
|
|
10
|
+
* GitLab rather than GitLab calling Gibson.
|
|
11
|
+
*
|
|
12
|
+
* FINISHED MEANS `success`. A pipeline that failed published no image and left
|
|
13
|
+
* `main` unchanged, so there is nothing new to scan; treating it as a trigger
|
|
14
|
+
* would run a Scan mission against the image the previous pipeline built and
|
|
15
|
+
* report its findings against the new commit. `canceled` and `skipped` are the
|
|
16
|
+
* same case. The status this polls for is therefore `success`, named once in
|
|
17
|
+
* {@link TRIGGER_STATUS} rather than spelled at each call site.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is one `fetch` away from pure: {@link gitlabRest} takes the
|
|
20
|
+
* fetch it uses, so `watch.test.ts` drives a whole loop against a stub with no
|
|
21
|
+
* network and no token.
|
|
22
|
+
*/
|
|
23
|
+
/** The pipeline status that triggers a Scan mission. See the module note. */
|
|
24
|
+
export const TRIGGER_STATUS = "success";
|
|
25
|
+
/** GitLab's public host, when a tenant names no other instance. */
|
|
26
|
+
export const GITLAB_DEFAULT_URL = "https://gitlab.com";
|
|
27
|
+
/**
|
|
28
|
+
* Read one pipeline out of GitLab's JSON. A row without an id or a sha is
|
|
29
|
+
* dropped rather than guessed at: a Scan mission keyed on a missing commit
|
|
30
|
+
* would scan whatever the workspace happened to hold.
|
|
31
|
+
*/
|
|
32
|
+
export function parsePipeline(raw) {
|
|
33
|
+
if (!raw || typeof raw !== "object")
|
|
34
|
+
return undefined;
|
|
35
|
+
const p = raw;
|
|
36
|
+
const id = typeof p.id === "number" ? p.id : Number.NaN;
|
|
37
|
+
const sha = typeof p.sha === "string" ? p.sha : "";
|
|
38
|
+
if (!Number.isInteger(id) || id <= 0 || !sha)
|
|
39
|
+
return undefined;
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
sha,
|
|
43
|
+
status: typeof p.status === "string" ? p.status : "",
|
|
44
|
+
ref: typeof p.ref === "string" ? p.ref : "",
|
|
45
|
+
webUrl: typeof p.web_url === "string" ? p.web_url : "",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A {@link GitLabClient} over GitLab's REST API.
|
|
50
|
+
*
|
|
51
|
+
* The token travels in `PRIVATE-TOKEN`, which is what a project access token
|
|
52
|
+
* authenticates with. It is never logged and never placed in a URL, so it
|
|
53
|
+
* cannot reach the console stream through an error message that quotes the
|
|
54
|
+
* request.
|
|
55
|
+
*/
|
|
56
|
+
export function gitlabRest(opts) {
|
|
57
|
+
const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
|
|
58
|
+
const project = encodeURIComponent(opts.projectPath);
|
|
59
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
60
|
+
return {
|
|
61
|
+
async latestFinishedPipeline(ref) {
|
|
62
|
+
const url = `${base}/api/v4/projects/${project}/pipelines` +
|
|
63
|
+
`?ref=${encodeURIComponent(ref)}&status=${TRIGGER_STATUS}&order_by=id&sort=desc&per_page=1`;
|
|
64
|
+
const res = await doFetch(url, {
|
|
65
|
+
headers: { "PRIVATE-TOKEN": opts.token, Accept: "application/json" },
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${res.status} ${res.statusText}`);
|
|
69
|
+
}
|
|
70
|
+
const body = await res.json();
|
|
71
|
+
if (!Array.isArray(body)) {
|
|
72
|
+
throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${typeof body}, expected an array`);
|
|
73
|
+
}
|
|
74
|
+
for (const row of body) {
|
|
75
|
+
const p = parsePipeline(row);
|
|
76
|
+
if (p)
|
|
77
|
+
return p;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** The context name the scan status is reported under, named once. */
|
|
84
|
+
export const SCAN_STATUS_CONTEXT = "gibson/scan";
|
|
85
|
+
/**
|
|
86
|
+
* A {@link GitLabWriter} over GitLab's REST API.
|
|
87
|
+
*
|
|
88
|
+
* Every request carries the token in `PRIVATE-TOKEN` and never in a URL or a
|
|
89
|
+
* body, so a failure message that quotes the request cannot carry it into the
|
|
90
|
+
* console stream. The error text names the project, the branch and the status
|
|
91
|
+
* code — never a header.
|
|
92
|
+
*/
|
|
93
|
+
export function gitlabRestWriter(opts) {
|
|
94
|
+
const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
|
|
95
|
+
const project = encodeURIComponent(opts.projectPath);
|
|
96
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
97
|
+
const api = `${base}/api/v4/projects/${project}`;
|
|
98
|
+
async function call(method, path, body) {
|
|
99
|
+
const res = await doFetch(`${api}${path}`, {
|
|
100
|
+
method,
|
|
101
|
+
headers: {
|
|
102
|
+
"PRIVATE-TOKEN": opts.token,
|
|
103
|
+
Accept: "application/json",
|
|
104
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
105
|
+
},
|
|
106
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
throw new Error(`GitLab ${method} ${path} on ${opts.projectPath} returned ${res.status} ${res.statusText}`);
|
|
110
|
+
}
|
|
111
|
+
return res.json();
|
|
112
|
+
}
|
|
113
|
+
const asMergeRequest = (raw, branch) => {
|
|
114
|
+
const mr = (raw ?? {});
|
|
115
|
+
const iid = typeof mr.iid === "number" ? mr.iid : Number.NaN;
|
|
116
|
+
if (!Number.isInteger(iid) || iid <= 0) {
|
|
117
|
+
throw new Error(`GitLab returned a merge request for ${branch} with no iid`);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
iid,
|
|
121
|
+
webUrl: typeof mr.web_url === "string" ? mr.web_url : "",
|
|
122
|
+
state: typeof mr.state === "string" ? mr.state : "",
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
async commitToBranch(branch, startRef, message, changes) {
|
|
127
|
+
if (changes.length === 0) {
|
|
128
|
+
throw new Error(`refusing to commit an empty change set to ${branch}`);
|
|
129
|
+
}
|
|
130
|
+
const raw = await call("POST", "/repository/commits", {
|
|
131
|
+
branch,
|
|
132
|
+
start_branch: startRef,
|
|
133
|
+
commit_message: message,
|
|
134
|
+
actions: changes.map((c) => ({ action: "update", file_path: c.path, content: c.content })),
|
|
135
|
+
});
|
|
136
|
+
const sha = raw.id;
|
|
137
|
+
if (typeof sha !== "string" || !sha) {
|
|
138
|
+
throw new Error(`GitLab accepted the commit on ${branch} but returned no sha`);
|
|
139
|
+
}
|
|
140
|
+
return sha;
|
|
141
|
+
},
|
|
142
|
+
async openMergeRequest(branch, targetRef, title, description) {
|
|
143
|
+
const created = asMergeRequest(await call("POST", "/merge_requests", {
|
|
144
|
+
source_branch: branch,
|
|
145
|
+
target_branch: targetRef,
|
|
146
|
+
title,
|
|
147
|
+
description,
|
|
148
|
+
remove_source_branch: true,
|
|
149
|
+
}), branch);
|
|
150
|
+
// Auto-merge is a separate call, and a refusal is not fatal: the merge
|
|
151
|
+
// request is open and correct either way, and a human can merge it. A
|
|
152
|
+
// thrown error here would lose a good merge request over a race with the
|
|
153
|
+
// pipeline that has not started yet.
|
|
154
|
+
try {
|
|
155
|
+
await call("PUT", `/merge_requests/${created.iid}/merge`, { merge_when_pipeline_succeeds: true });
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Reported by the caller through the note, not swallowed silently.
|
|
159
|
+
}
|
|
160
|
+
return created;
|
|
161
|
+
},
|
|
162
|
+
mergeRequestState: async (iid) => asMergeRequest(await call("GET", `/merge_requests/${iid}`), `!${iid}`),
|
|
163
|
+
comment: async (iid, body) => {
|
|
164
|
+
await call("POST", `/merge_requests/${iid}/notes`, { body });
|
|
165
|
+
},
|
|
166
|
+
setCommitStatus: async (sha, state, description, targetUrl) => {
|
|
167
|
+
await call("POST", `/statuses/${encodeURIComponent(sha)}`, {
|
|
168
|
+
state,
|
|
169
|
+
name: SCAN_STATUS_CONTEXT,
|
|
170
|
+
context: SCAN_STATUS_CONTEXT,
|
|
171
|
+
description,
|
|
172
|
+
...(targetUrl ? { target_url: targetUrl } : {}),
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|