@intentius/chant 0.59.0 → 0.60.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/build-params.d.ts +2 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
- package/dist/config.d.ts +4 -4
- package/dist/lexicon.d.ts +48 -5
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -4
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +79 -7
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -4
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/params.d.ts +1 -1
- package/dist/project-root.d.ts +2 -2
- package/package.json +1 -1
- package/src/build-params.ts +2 -2
- package/src/cli/commands/build.ts +8 -8
- package/src/cli/commands/lint.test.ts +151 -0
- package/src/cli/commands/lint.ts +37 -4
- package/src/cli/handlers/graph.test.ts +4 -4
- package/src/cli/handlers/graph.ts +11 -11
- package/src/cli/handlers/lint.test.ts +107 -0
- package/src/cli/handlers/lint.ts +30 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +2 -2
- package/src/config.ts +4 -4
- package/src/discovery/fold-import.test.ts +1 -1
- package/src/discovery/fold-import.ts +3 -3
- package/src/lexicon.ts +49 -5
- package/src/lifecycle/observe.test.ts +2 -2
- package/src/lifecycle/observe.ts +8 -8
- package/src/lifecycle/release-ledger.test.ts +2 -2
- package/src/op/activities/index.ts +8 -1
- package/src/op/activities/reconcile.test.ts +238 -0
- package/src/op/activities/reconcile.ts +267 -13
- package/src/op/gate-summary.test.ts +62 -0
- package/src/op/gate-summary.ts +17 -5
- package/src/params.ts +1 -1
- package/src/project-root.ts +2 -2
|
@@ -204,8 +204,8 @@ describe("observeResources", () => {
|
|
|
204
204
|
const stack = (opts as { stack?: string }).stack;
|
|
205
205
|
calls.push(stack);
|
|
206
206
|
// Different resources per stack — the multi-stack, per-component case
|
|
207
|
-
// (#57
|
|
208
|
-
//
|
|
207
|
+
// (#57). Bare-string stacks keep BARE ids (no `src` scope), so the
|
|
208
|
+
// union is `db-a`+`db-b`, not stack-qualified: per-component ids are
|
|
209
209
|
// already unique and behold reads them bare. Qualification is a scoped
|
|
210
210
|
// (`src`) feature — see the per-stack src test below.
|
|
211
211
|
const resources: Record<string, ResourceMetadata> =
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -71,10 +71,10 @@ function qualifyObservation(obs: NormalizedObservation, stackName: string): Norm
|
|
|
71
71
|
* (`read-failed`, #1089) rather than dropped, so a failed read is visibly a
|
|
72
72
|
* hole instead of a silent absence.
|
|
73
73
|
*
|
|
74
|
-
* `stacks` (#57) is for a multi-stack, per-component project
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* `stacks` (#57) is for a multi-stack, per-component project, where there is no
|
|
75
|
+
* single stack named after the environment — AWS's single-stack convention
|
|
76
|
+
* (`lexicons/aws/src/plugin.ts`'s `describeResources`, absent an explicit
|
|
77
|
+
* `stack`) queries a stack that simply doesn't exist there,
|
|
78
78
|
* so the single-call path always observes zero nodes. When `stacks` is
|
|
79
79
|
* present and non-empty, each observing plugin's `describeResources` is
|
|
80
80
|
* called once per stack and the returned observations are merged. A stack entry
|
|
@@ -197,10 +197,10 @@ export async function observeResources(
|
|
|
197
197
|
// Qualify ids by stack ONLY for a scoped (`src`) stack (#1162): that
|
|
198
198
|
// is the multi-region case where the SAME bare LogicalResourceId
|
|
199
199
|
// (e.g. `vpc`) exists in every stack, so a bare union would collide.
|
|
200
|
-
// A bare-string stack (#57
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
200
|
+
// A bare-string stack (#57) has unique per-component ids and is asked
|
|
201
|
+
// the whole-project entity set, so it keeps the bare-id tri-state
|
|
202
|
+
// merge (present > not-observed > absent) that behold and other
|
|
203
|
+
// consumers read.
|
|
204
204
|
parts.push(stack.src ? qualifyObservation(norm, stack.name) : norm);
|
|
205
205
|
}
|
|
206
206
|
observed = mergeObservations(parts);
|
|
@@ -256,10 +256,10 @@ describe("release-ledger", () => {
|
|
|
256
256
|
test("a Forgejo instance resolves through the same env contract to its own host", () => {
|
|
257
257
|
const env = {
|
|
258
258
|
GITHUB_RUN_ID: "42",
|
|
259
|
-
GITHUB_REPOSITORY: "
|
|
259
|
+
GITHUB_REPOSITORY: "acme/widgets",
|
|
260
260
|
GITHUB_SERVER_URL: "https://forge.example.dev",
|
|
261
261
|
};
|
|
262
|
-
expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/
|
|
262
|
+
expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/acme/widgets/actions/runs/42");
|
|
263
263
|
});
|
|
264
264
|
|
|
265
265
|
test("GitLab CI env records the project path and takes CI_PIPELINE_URL verbatim", () => {
|
|
@@ -43,7 +43,14 @@ export type { EnvTeardownArgs, EnvTeardownResult, EnvTeardownDeps } from "./env-
|
|
|
43
43
|
// the registry imports this module statically, so a project that installs
|
|
44
44
|
// nothing but chant still resolves every step below.
|
|
45
45
|
|
|
46
|
-
export {
|
|
46
|
+
export {
|
|
47
|
+
reconcilePr,
|
|
48
|
+
commentMarker,
|
|
49
|
+
pullRequestContextFrom,
|
|
50
|
+
resolvePullRequestContext,
|
|
51
|
+
mergeRequestContextFrom,
|
|
52
|
+
gitlabNoteTokenFrom,
|
|
53
|
+
} from "./reconcile";
|
|
47
54
|
export type { ReconcilePrArgs, ReconcileResult, ReconcileMode, ReconcileEntry, PullRequestContext } from "./reconcile";
|
|
48
55
|
|
|
49
56
|
export { nativeApply, compensateApply, hasNativeRollback } from "./apply";
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
entriesFromPlan,
|
|
7
7
|
commentMarker,
|
|
8
8
|
pullRequestContextFrom,
|
|
9
|
+
mergeRequestContextFrom,
|
|
10
|
+
gitlabNoteTokenFrom,
|
|
9
11
|
} from "./reconcile";
|
|
10
12
|
|
|
11
13
|
const entries = [
|
|
@@ -159,3 +161,239 @@ describe("reconcilePr comment mode refuses a run with no pull request (#2231)",
|
|
|
159
161
|
}
|
|
160
162
|
});
|
|
161
163
|
});
|
|
164
|
+
|
|
165
|
+
// ── The GitLab merge-request note (#2256) ───────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe("mergeRequestContextFrom (#2256)", () => {
|
|
168
|
+
const gitlabEnv = {
|
|
169
|
+
CI_API_V4_URL: "https://gitlab.com/api/v4",
|
|
170
|
+
CI_PROJECT_ID: "42",
|
|
171
|
+
CI_PROJECT_PATH: "acme/infra",
|
|
172
|
+
CI_PROJECT_URL: "https://gitlab.com/acme/infra",
|
|
173
|
+
CI_MERGE_REQUEST_IID: "7",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
test("reads the merge request off a merge_request_event pipeline", () => {
|
|
177
|
+
expect(mergeRequestContextFrom(gitlabEnv)).toEqual({
|
|
178
|
+
api: "https://gitlab.com/api/v4",
|
|
179
|
+
project: "42",
|
|
180
|
+
iid: 7,
|
|
181
|
+
path: "acme/infra",
|
|
182
|
+
webUrl: "https://gitlab.com/acme/infra",
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("prefers the merge request's own project over the pipeline's", () => {
|
|
187
|
+
// A merge request from a fork runs its pipeline in the fork's project,
|
|
188
|
+
// and the note belongs on the target project's merge request.
|
|
189
|
+
expect(
|
|
190
|
+
mergeRequestContextFrom({ ...gitlabEnv, CI_MERGE_REQUEST_PROJECT_ID: "9" })?.project,
|
|
191
|
+
).toBe("9");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("derives the API base from CI_SERVER_URL when CI_API_V4_URL is unset", () => {
|
|
195
|
+
const { CI_API_V4_URL: _drop, ...rest } = gitlabEnv;
|
|
196
|
+
expect(mergeRequestContextFrom({ ...rest, CI_SERVER_URL: "https://gl.example.com" })?.api).toBe(
|
|
197
|
+
"https://gl.example.com/api/v4",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a push or scheduled GitLab pipeline has no merge request", () => {
|
|
202
|
+
const { CI_MERGE_REQUEST_IID: _drop, ...rest } = gitlabEnv;
|
|
203
|
+
expect(mergeRequestContextFrom(rest)).toBeUndefined();
|
|
204
|
+
expect(mergeRequestContextFrom({ ...rest, CI_MERGE_REQUEST_IID: "" })).toBeUndefined();
|
|
205
|
+
expect(mergeRequestContextFrom({ ...rest, CI_MERGE_REQUEST_IID: "not-a-number" })).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a GitHub Actions run is not mistaken for a GitLab one", () => {
|
|
209
|
+
expect(mergeRequestContextFrom({ GITHUB_REPOSITORY: "INTENTIUS/chant", GITHUB_REF: "refs/pull/1/merge" })).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("gitlabNoteTokenFrom (#2256)", () => {
|
|
214
|
+
test("a project or personal access token is sent as PRIVATE-TOKEN", () => {
|
|
215
|
+
expect(gitlabNoteTokenFrom({ GITLAB_TOKEN: "glpat-x" })).toEqual({
|
|
216
|
+
header: "PRIVATE-TOKEN",
|
|
217
|
+
value: "glpat-x",
|
|
218
|
+
source: "GITLAB_TOKEN",
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("CHANT_GITLAB_TOKEN wins over GITLAB_TOKEN, which wins over the job token", () => {
|
|
223
|
+
expect(
|
|
224
|
+
gitlabNoteTokenFrom({ CHANT_GITLAB_TOKEN: "a", GITLAB_TOKEN: "b", CI_JOB_TOKEN: "c" })?.source,
|
|
225
|
+
).toBe("CHANT_GITLAB_TOKEN");
|
|
226
|
+
expect(gitlabNoteTokenFrom({ GITLAB_TOKEN: "b", CI_JOB_TOKEN: "c" })?.source).toBe("GITLAB_TOKEN");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("the job token is sent as JOB-TOKEN, which is a different header", () => {
|
|
230
|
+
expect(gitlabNoteTokenFrom({ CI_JOB_TOKEN: "c" })).toEqual({
|
|
231
|
+
header: "JOB-TOKEN",
|
|
232
|
+
value: "c",
|
|
233
|
+
source: "CI_JOB_TOKEN",
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("no token at all is undefined rather than an empty header", () => {
|
|
238
|
+
expect(gitlabNoteTokenFrom({})).toBeUndefined();
|
|
239
|
+
expect(gitlabNoteTokenFrom({ GITLAB_TOKEN: "", CI_JOB_TOKEN: "" })).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
/** One stubbed GitLab REST response. */
|
|
244
|
+
function gitlabResponse(body: unknown, headers: Record<string, string> = {}): Response {
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
status: 200,
|
|
248
|
+
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
|
|
249
|
+
json: async () => body,
|
|
250
|
+
text: async () => JSON.stringify(body),
|
|
251
|
+
} as unknown as Response;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
describe("reconcilePr comment mode on GitLab posts one merge-request note (#2256)", () => {
|
|
255
|
+
const gitlabEnv: Record<string, string> = {
|
|
256
|
+
CI_API_V4_URL: "https://gitlab.com/api/v4",
|
|
257
|
+
CI_PROJECT_ID: "42",
|
|
258
|
+
CI_PROJECT_PATH: "acme/infra",
|
|
259
|
+
CI_PROJECT_URL: "https://gitlab.com/acme/infra",
|
|
260
|
+
CI_MERGE_REQUEST_IID: "7",
|
|
261
|
+
GITLAB_TOKEN: "glpat-x",
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
function stubGitlabEnv(): void {
|
|
265
|
+
for (const [k, v] of Object.entries(gitlabEnv)) vi.stubEnv(k, v);
|
|
266
|
+
// A GitLab job carries none of GitHub's variables; make that explicit so
|
|
267
|
+
// the GitHub path can never be the one under test here.
|
|
268
|
+
for (const k of ["GITHUB_REPOSITORY", "GITHUB_REF", "GITHUB_EVENT_PATH"]) vi.stubEnv(k, "");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
test("POSTs a new note carrying the marker as its first line", async () => {
|
|
272
|
+
stubGitlabEnv();
|
|
273
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
274
|
+
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
|
275
|
+
calls.push({ url, init });
|
|
276
|
+
if (!init?.method || init.method === "GET") return gitlabResponse([]);
|
|
277
|
+
return gitlabResponse({ id: 555 });
|
|
278
|
+
});
|
|
279
|
+
try {
|
|
280
|
+
const result = await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
|
|
281
|
+
expect(result.commentUrl).toBe("https://gitlab.com/acme/infra/-/merge_requests/7#note_555");
|
|
282
|
+
expect(result.mergeRequest).toBe("acme/infra!7");
|
|
283
|
+
const post = calls[calls.length - 1];
|
|
284
|
+
expect(post.url).toBe("https://gitlab.com/api/v4/projects/42/merge_requests/7/notes");
|
|
285
|
+
expect(post.init?.method).toBe("POST");
|
|
286
|
+
expect((post.init?.headers as Record<string, string>)["PRIVATE-TOKEN"]).toBe("glpat-x");
|
|
287
|
+
expect(JSON.parse(String(post.init?.body)).body).toBe(
|
|
288
|
+
"<!-- chant-reconcile:app -->\n\nthe plan",
|
|
289
|
+
);
|
|
290
|
+
} finally {
|
|
291
|
+
vi.unstubAllEnvs();
|
|
292
|
+
vi.unstubAllGlobals();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("PUTs the note it already owns instead of stacking a second one", async () => {
|
|
297
|
+
stubGitlabEnv();
|
|
298
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
299
|
+
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
|
300
|
+
calls.push({ url, init });
|
|
301
|
+
if (!init?.method || init.method === "GET") {
|
|
302
|
+
return gitlabResponse([
|
|
303
|
+
{ id: 1, system: true, body: "changed the description" },
|
|
304
|
+
{ id: 2, system: false, body: "unrelated human note" },
|
|
305
|
+
{ id: 3, system: false, body: "<!-- chant-reconcile:app -->\n\nan older plan" },
|
|
306
|
+
]);
|
|
307
|
+
}
|
|
308
|
+
return gitlabResponse({ id: 3 });
|
|
309
|
+
});
|
|
310
|
+
try {
|
|
311
|
+
const result = await reconcilePr({ env: "app", mode: "comment", body: "a newer plan" });
|
|
312
|
+
const write = calls[calls.length - 1];
|
|
313
|
+
expect(write.init?.method).toBe("PUT");
|
|
314
|
+
expect(write.url).toBe("https://gitlab.com/api/v4/projects/42/merge_requests/7/notes/3");
|
|
315
|
+
expect(result.commentUrl).toBe("https://gitlab.com/acme/infra/-/merge_requests/7#note_3");
|
|
316
|
+
} finally {
|
|
317
|
+
vi.unstubAllEnvs();
|
|
318
|
+
vi.unstubAllGlobals();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("follows GitLab's own pagination rather than reading page one alone", async () => {
|
|
323
|
+
stubGitlabEnv();
|
|
324
|
+
const seen: string[] = [];
|
|
325
|
+
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
|
326
|
+
if (!init?.method || init.method === "GET") {
|
|
327
|
+
seen.push(url);
|
|
328
|
+
if (url.endsWith("page=1")) return gitlabResponse([{ id: 1, body: "nope" }], { "x-next-page": "2" });
|
|
329
|
+
return gitlabResponse([{ id: 9, body: "<!-- chant-reconcile:app -->\n\nold" }], { "x-next-page": "" });
|
|
330
|
+
}
|
|
331
|
+
return gitlabResponse({ id: 9 });
|
|
332
|
+
});
|
|
333
|
+
try {
|
|
334
|
+
await reconcilePr({ env: "app", mode: "comment", body: "new" });
|
|
335
|
+
expect(seen).toHaveLength(2);
|
|
336
|
+
expect(seen[1]).toContain("page=2");
|
|
337
|
+
} finally {
|
|
338
|
+
vi.unstubAllEnvs();
|
|
339
|
+
vi.unstubAllGlobals();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("a rejected write fails the step by name, carrying GitLab's own status", async () => {
|
|
344
|
+
stubGitlabEnv();
|
|
345
|
+
vi.stubGlobal("fetch", async (_url: string, init?: RequestInit) => {
|
|
346
|
+
if (!init?.method || init.method === "GET") return gitlabResponse([]);
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
status: 403,
|
|
350
|
+
headers: { get: () => null },
|
|
351
|
+
json: async () => ({}),
|
|
352
|
+
text: async () => '{"message":"403 Forbidden"}',
|
|
353
|
+
} as unknown as Response;
|
|
354
|
+
});
|
|
355
|
+
try {
|
|
356
|
+
await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
|
|
357
|
+
/merge_requests\/7\/notes.*403.*403 Forbidden/s,
|
|
358
|
+
);
|
|
359
|
+
} finally {
|
|
360
|
+
vi.unstubAllEnvs();
|
|
361
|
+
vi.unstubAllGlobals();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("a merge-request pipeline with no token says which variable to set", async () => {
|
|
366
|
+
for (const [k, v] of Object.entries(gitlabEnv)) vi.stubEnv(k, k === "GITLAB_TOKEN" ? "" : v);
|
|
367
|
+
vi.stubEnv("CI_JOB_TOKEN", "");
|
|
368
|
+
vi.stubEnv("CHANT_GITLAB_TOKEN", "");
|
|
369
|
+
try {
|
|
370
|
+
await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
|
|
371
|
+
/GITLAB_TOKEN.*CI_JOB_TOKEN/s,
|
|
372
|
+
);
|
|
373
|
+
} finally {
|
|
374
|
+
vi.unstubAllEnvs();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("the no-context refusal names both forges' variables (#2256)", () => {
|
|
380
|
+
test("a run on neither forge is told what GitLab would have set too", async () => {
|
|
381
|
+
for (const k of [
|
|
382
|
+
"GITHUB_REPOSITORY",
|
|
383
|
+
"GITHUB_REF",
|
|
384
|
+
"GITHUB_EVENT_PATH",
|
|
385
|
+
"CI_MERGE_REQUEST_IID",
|
|
386
|
+
"CI_PROJECT_ID",
|
|
387
|
+
"CI_API_V4_URL",
|
|
388
|
+
]) {
|
|
389
|
+
vi.stubEnv(k, "");
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
|
|
393
|
+
/CI_MERGE_REQUEST_IID/,
|
|
394
|
+
);
|
|
395
|
+
} finally {
|
|
396
|
+
vi.unstubAllEnvs();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
});
|
|
@@ -12,6 +12,13 @@ const execAsync = promisify(exec);
|
|
|
12
12
|
* re-run (chant #2231). It therefore needs a pull-request trigger, and
|
|
13
13
|
* {@link resolvePullRequestContext} fails the step by name when the run has
|
|
14
14
|
* none.
|
|
15
|
+
*
|
|
16
|
+
* On GitLab the same mode writes a merge-request note (chant #2256): the same
|
|
17
|
+
* marker, the same edit-in-place, a different API. Which forge a run is on is
|
|
18
|
+
* read off the run's own CI variables rather than configured — a
|
|
19
|
+
* `merge_request_event` pipeline sets `CI_MERGE_REQUEST_IID`, a GitHub
|
|
20
|
+
* `pull_request` event sets `GITHUB_REPOSITORY`, and no run sets both. See
|
|
21
|
+
* {@link mergeRequestContextFrom}.
|
|
15
22
|
*/
|
|
16
23
|
export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
|
|
17
24
|
|
|
@@ -78,10 +85,12 @@ export interface ReconcileResult {
|
|
|
78
85
|
prUrl?: string;
|
|
79
86
|
/** Opened issue URL (issue mode). */
|
|
80
87
|
issueUrl?: string;
|
|
81
|
-
/** The posted or updated PR comment
|
|
88
|
+
/** The posted or updated PR comment / MR note URL (comment mode). */
|
|
82
89
|
commentUrl?: string;
|
|
83
|
-
/** The pull request the comment landed on, `owner/repo#number` (comment mode). */
|
|
90
|
+
/** The pull request the comment landed on, `owner/repo#number` (comment mode, GitHub). */
|
|
84
91
|
pullRequest?: string;
|
|
92
|
+
/** The merge request the note landed on, `group/project!iid` (comment mode, GitLab — #2256). */
|
|
93
|
+
mergeRequest?: string;
|
|
85
94
|
/** The markdown summary used as the PR/issue body. */
|
|
86
95
|
summary: string;
|
|
87
96
|
/** The entries that triggered the reconcile. */
|
|
@@ -142,14 +151,17 @@ export function commentMarker(env: string): string {
|
|
|
142
151
|
return `<!-- chant-reconcile:${env.replace(/[^a-zA-Z0-9._-]+/g, "-")} -->`;
|
|
143
152
|
}
|
|
144
153
|
|
|
145
|
-
/** What a `comment`-mode step says when the run it is in has no pull request. */
|
|
154
|
+
/** What a `comment`-mode step says when the run it is in has no pull request and no merge request. */
|
|
146
155
|
export function noPullRequestContextMessage(): string {
|
|
147
156
|
return (
|
|
148
|
-
'reconcilePr mode "comment" posts the finding on the pull request that triggered the
|
|
149
|
-
"has none.
|
|
150
|
-
"GITHUB_EVENT_PATH (`.number` / `.pull_request.number`) or from
|
|
151
|
-
"
|
|
152
|
-
|
|
157
|
+
'reconcilePr mode "comment" posts the finding on the pull request or merge request that triggered the ' +
|
|
158
|
+
"run, and this run has none. On GitHub Actions it needs GITHUB_REPOSITORY plus a pull request number, " +
|
|
159
|
+
"read from the event payload at GITHUB_EVENT_PATH (`.number` / `.pull_request.number`) or from " +
|
|
160
|
+
"GITHUB_REF (`refs/pull/<n>/merge`), which a pull_request event sets and nothing else does. On GitLab " +
|
|
161
|
+
"CI it needs CI_MERGE_REQUEST_IID plus the project (CI_MERGE_REQUEST_PROJECT_ID or CI_PROJECT_ID) and " +
|
|
162
|
+
"the API base (CI_API_V4_URL, or CI_SERVER_URL to derive it), which a merge_request_event pipeline " +
|
|
163
|
+
"sets and nothing else does. Trigger this Op from a pull_request or merge_request pipeline, or give " +
|
|
164
|
+
'it findingMode "issue" or "report".'
|
|
153
165
|
);
|
|
154
166
|
}
|
|
155
167
|
|
|
@@ -252,6 +264,226 @@ async function postOrUpdateComment(
|
|
|
252
264
|
return stdout.trim();
|
|
253
265
|
}
|
|
254
266
|
|
|
267
|
+
// ── The GitLab merge-request note (#2256) ───────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The merge request a `comment`-mode run posts its note onto (#2256), as a
|
|
271
|
+
* GitLab CI job knows it. The GitLab counterpart of {@link
|
|
272
|
+
* PullRequestContext}.
|
|
273
|
+
*/
|
|
274
|
+
export interface MergeRequestContext {
|
|
275
|
+
/** REST v4 base, from `CI_API_V4_URL` or derived from `CI_SERVER_URL`. */
|
|
276
|
+
api: string;
|
|
277
|
+
/** The project holding the merge request — its numeric id, or a `group/project` path. */
|
|
278
|
+
project: string;
|
|
279
|
+
/** The merge request's `iid` (its per-project number, which is what the API path takes). */
|
|
280
|
+
iid: number;
|
|
281
|
+
/** `group/project`, for the human-readable `group/project!iid` on the result. */
|
|
282
|
+
path?: string;
|
|
283
|
+
/** The project's web URL, used to build the note's own URL. */
|
|
284
|
+
webUrl?: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Derive the triggering merge request from a GitLab job's CI variables. Pure
|
|
289
|
+
* — exported for testing, and the whole forge detection: nothing but a
|
|
290
|
+
* `merge_request_event` pipeline sets `CI_MERGE_REQUEST_IID`, so a run that
|
|
291
|
+
* has it is on GitLab and has a merge request, and a run that does not is
|
|
292
|
+
* neither.
|
|
293
|
+
*
|
|
294
|
+
* The project is the merge request's own (`CI_MERGE_REQUEST_PROJECT_ID`) in
|
|
295
|
+
* preference to the pipeline's (`CI_PROJECT_ID`): a merge request opened from
|
|
296
|
+
* a fork runs its pipeline in the fork, and the note belongs on the target
|
|
297
|
+
* project's merge request rather than on an iid that means something else in
|
|
298
|
+
* the fork.
|
|
299
|
+
*
|
|
300
|
+
* Returns undefined rather than throwing, so the caller owns the message.
|
|
301
|
+
*/
|
|
302
|
+
export function mergeRequestContextFrom(
|
|
303
|
+
env: Record<string, string | undefined>,
|
|
304
|
+
): MergeRequestContext | undefined {
|
|
305
|
+
const rawIid = env.CI_MERGE_REQUEST_IID?.trim();
|
|
306
|
+
if (!rawIid) return undefined;
|
|
307
|
+
const iid = Number(rawIid);
|
|
308
|
+
if (!Number.isInteger(iid) || iid <= 0) return undefined;
|
|
309
|
+
|
|
310
|
+
const server = env.CI_SERVER_URL?.trim().replace(/\/+$/, "");
|
|
311
|
+
const api = env.CI_API_V4_URL?.trim().replace(/\/+$/, "") || (server ? `${server}/api/v4` : "");
|
|
312
|
+
if (!api) return undefined;
|
|
313
|
+
|
|
314
|
+
const project =
|
|
315
|
+
env.CI_MERGE_REQUEST_PROJECT_ID?.trim() ||
|
|
316
|
+
env.CI_PROJECT_ID?.trim() ||
|
|
317
|
+
env.CI_MERGE_REQUEST_PROJECT_PATH?.trim() ||
|
|
318
|
+
env.CI_PROJECT_PATH?.trim() ||
|
|
319
|
+
"";
|
|
320
|
+
if (!project) return undefined;
|
|
321
|
+
|
|
322
|
+
const path = env.CI_MERGE_REQUEST_PROJECT_PATH?.trim() || env.CI_PROJECT_PATH?.trim();
|
|
323
|
+
const webUrl = env.CI_MERGE_REQUEST_PROJECT_URL?.trim() || env.CI_PROJECT_URL?.trim();
|
|
324
|
+
return {
|
|
325
|
+
api,
|
|
326
|
+
project,
|
|
327
|
+
iid,
|
|
328
|
+
...(path ? { path } : {}),
|
|
329
|
+
...(webUrl ? { webUrl } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The credential a merge-request note is written with, and the header GitLab reads it from. */
|
|
334
|
+
export interface GitlabNoteToken {
|
|
335
|
+
/** `PRIVATE-TOKEN` for a personal/project/group access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. */
|
|
336
|
+
header: "PRIVATE-TOKEN" | "JOB-TOKEN";
|
|
337
|
+
value: string;
|
|
338
|
+
/** The variable it came from, so a refusal or a log line can name it. */
|
|
339
|
+
source: string;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Resolve the token a merge-request note is written with, most specific
|
|
344
|
+
* first. Pure — exported for testing.
|
|
345
|
+
*
|
|
346
|
+
* Two headers, not one, because GitLab reads two different credentials from
|
|
347
|
+
* two different headers: an access token goes in `PRIVATE-TOKEN`, and the
|
|
348
|
+
* pipeline's own ephemeral `CI_JOB_TOKEN` goes in `JOB-TOKEN`. Sending one in
|
|
349
|
+
* the other's header is a 401, not a fallback.
|
|
350
|
+
*
|
|
351
|
+
* The access token is preferred because it is the one that reliably works:
|
|
352
|
+
* `CI_JOB_TOKEN` reaches only the endpoints GitLab's job-token allowlist
|
|
353
|
+
* names, and the notes API is not among them on current GitLab, so a project
|
|
354
|
+
* that has not widened that allowlist needs a token with `api` scope. It is
|
|
355
|
+
* still accepted last rather than refused, so a project on an instance whose
|
|
356
|
+
* allowlist does cover notes needs no long-lived credential at all.
|
|
357
|
+
*/
|
|
358
|
+
export function gitlabNoteTokenFrom(
|
|
359
|
+
env: Record<string, string | undefined>,
|
|
360
|
+
): GitlabNoteToken | undefined {
|
|
361
|
+
for (const source of ["CHANT_GITLAB_TOKEN", "GITLAB_TOKEN"]) {
|
|
362
|
+
const value = env[source]?.trim();
|
|
363
|
+
if (value) return { header: "PRIVATE-TOKEN", value, source };
|
|
364
|
+
}
|
|
365
|
+
const jobToken = env.CI_JOB_TOKEN?.trim();
|
|
366
|
+
if (jobToken) return { header: "JOB-TOKEN", value: jobToken, source: "CI_JOB_TOKEN" };
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** What a `comment`-mode step says on a merge request it has no credential for. */
|
|
371
|
+
export function noGitlabNoteTokenMessage(iid: number): string {
|
|
372
|
+
return (
|
|
373
|
+
`reconcilePr mode "comment" has merge request !${iid} to post its finding on and no token to post it ` +
|
|
374
|
+
"with. Set a GITLAB_TOKEN CI/CD variable (masked, scope: api) on the project — a project access token " +
|
|
375
|
+
"is enough — or, on an instance whose job-token allowlist covers the notes API, make CI_JOB_TOKEN " +
|
|
376
|
+
"available to the job. CHANT_GITLAB_TOKEN is read first where the two must differ."
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** One page of merge-request notes, as much of each as this activity reads. */
|
|
381
|
+
interface GitlabNote {
|
|
382
|
+
id: number;
|
|
383
|
+
body?: string;
|
|
384
|
+
/** GitLab's own generated notes ("changed the description"), never ours. */
|
|
385
|
+
system?: boolean;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** GitLab's REST paths take a URL-encoded project id or `group%2Fproject` path. */
|
|
389
|
+
function notesEndpoint(ctx: MergeRequestContext): string {
|
|
390
|
+
return `${ctx.api}/projects/${encodeURIComponent(ctx.project)}/merge_requests/${ctx.iid}/notes`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** One GitLab REST call, with the failure spelled out rather than swallowed into a parse error. */
|
|
394
|
+
async function gitlabRequest(
|
|
395
|
+
url: string,
|
|
396
|
+
token: GitlabNoteToken,
|
|
397
|
+
init: RequestInit,
|
|
398
|
+
signal?: AbortSignal,
|
|
399
|
+
): Promise<Response> {
|
|
400
|
+
const res = await fetch(url, {
|
|
401
|
+
...init,
|
|
402
|
+
...(signal ? { signal } : {}),
|
|
403
|
+
headers: { [token.header]: token.value, "content-type": "application/json" },
|
|
404
|
+
});
|
|
405
|
+
if (!res.ok) {
|
|
406
|
+
const detail = (await res.text().catch(() => "")).slice(0, 500);
|
|
407
|
+
throw new Error(
|
|
408
|
+
`GitLab API ${init.method ?? "GET"} ${url} answered ${res.status}${detail ? `: ${detail}` : ""} ` +
|
|
409
|
+
`(token from ${token.source}, sent as ${token.header}).`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return res;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Find the note this Op already owns on `ctx`'s merge request, by the same
|
|
417
|
+
* hidden marker `postOrUpdateComment` looks a GitHub comment up by: the
|
|
418
|
+
* marker is the body's first line and the match is a prefix.
|
|
419
|
+
*
|
|
420
|
+
* Pages the way GitLab pages, following the `x-next-page` response header
|
|
421
|
+
* rather than guessing a page count — an active merge request runs past one
|
|
422
|
+
* page of notes routinely, and a lookup that read page one alone would post
|
|
423
|
+
* a second comment instead of editing the first.
|
|
424
|
+
*
|
|
425
|
+
* GitLab's own system notes are skipped: they are the activity feed
|
|
426
|
+
* ("changed the description"), they are never ours, and they are the bulk of
|
|
427
|
+
* what fills those pages.
|
|
428
|
+
*/
|
|
429
|
+
async function findOwnedNote(
|
|
430
|
+
ctx: MergeRequestContext,
|
|
431
|
+
token: GitlabNoteToken,
|
|
432
|
+
marker: string,
|
|
433
|
+
signal?: AbortSignal,
|
|
434
|
+
): Promise<number | undefined> {
|
|
435
|
+
const endpoint = notesEndpoint(ctx);
|
|
436
|
+
// A merge request with more notes than this has something other than a
|
|
437
|
+
// stale plan comment wrong with it; the bound is what stops a broken
|
|
438
|
+
// `x-next-page` header from looping forever.
|
|
439
|
+
const MAX_PAGES = 50;
|
|
440
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
441
|
+
const res = await gitlabRequest(`${endpoint}?per_page=100&page=${page}`, token, { method: "GET" }, signal);
|
|
442
|
+
const notes = (await res.json()) as GitlabNote[];
|
|
443
|
+
const owned = notes.find((note) => !note.system && (note.body ?? "").startsWith(marker));
|
|
444
|
+
if (owned) return owned.id;
|
|
445
|
+
const next = res.headers.get("x-next-page")?.trim();
|
|
446
|
+
if (!next) return undefined;
|
|
447
|
+
}
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Post `body` as one note on `ctx`'s merge request, or edit the note this Op
|
|
453
|
+
* already owns there — {@link postOrUpdateComment}'s GitLab half, and the
|
|
454
|
+
* same recipe: find by marker, PUT when there is one, POST when there is not,
|
|
455
|
+
* so a merge request pushed to five times carries one note holding the
|
|
456
|
+
* current finding rather than five stale ones.
|
|
457
|
+
*
|
|
458
|
+
* Over `fetch` rather than a CLI. `gh` is on GitHub's hosted runners and is
|
|
459
|
+
* already this activity's dependency for the issue and pull-request modes;
|
|
460
|
+
* `glab` is on no GitLab runner by default, and a job whose finding step
|
|
461
|
+
* depended on it would fail on the ordinary `node:22-slim` image the
|
|
462
|
+
* generator emits.
|
|
463
|
+
*/
|
|
464
|
+
async function postOrUpdateNote(
|
|
465
|
+
ctx: MergeRequestContext,
|
|
466
|
+
token: GitlabNoteToken,
|
|
467
|
+
marker: string,
|
|
468
|
+
body: string,
|
|
469
|
+
signal?: AbortSignal,
|
|
470
|
+
): Promise<string> {
|
|
471
|
+
const endpoint = notesEndpoint(ctx);
|
|
472
|
+
const existing = await findOwnedNote(ctx, token, marker, signal);
|
|
473
|
+
const payload = JSON.stringify({ body: `${marker}\n\n${body}` });
|
|
474
|
+
const res = existing
|
|
475
|
+
? await gitlabRequest(`${endpoint}/${existing}`, token, { method: "PUT", body: payload }, signal)
|
|
476
|
+
: await gitlabRequest(endpoint, token, { method: "POST", body: payload }, signal);
|
|
477
|
+
const note = (await res.json()) as GitlabNote;
|
|
478
|
+
// GitLab's note payload carries no web URL, unlike GitHub's comment. The
|
|
479
|
+
// anchor is how the UI itself addresses a note, so it is built rather than
|
|
480
|
+
// read; with no project web URL to build it from, the API path is at least
|
|
481
|
+
// a resolvable address for the thing that was written.
|
|
482
|
+
return ctx.webUrl
|
|
483
|
+
? `${ctx.webUrl}/-/merge_requests/${ctx.iid}#note_${note.id}`
|
|
484
|
+
: `${endpoint}/${note.id}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
255
487
|
/**
|
|
256
488
|
* Map a `chant lifecycle plan --json` ChangeSet to reconcile entries, dropping
|
|
257
489
|
* `noop` entries (nothing to reconcile). Pure — exported for testing.
|
|
@@ -286,10 +518,13 @@ async function derivePlanEntries(
|
|
|
286
518
|
* - `issue` — open a GitHub issue describing the drift (no code change).
|
|
287
519
|
* - `comment` — post the body as one comment on the pull request that
|
|
288
520
|
* triggered the run, editing that same comment on every re-run rather than
|
|
289
|
-
* stacking a new one (#2231)
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
521
|
+
* stacking a new one (#2231), or, on a GitLab `merge_request_event`
|
|
522
|
+
* pipeline, as one note on that merge request by the same recipe (#2256).
|
|
523
|
+
* Needs a pull-request- or merge-request-triggered run; fails by name when
|
|
524
|
+
* there is none. No code change, and the `pull-requests: write` the
|
|
525
|
+
* generated workflow already grants on that trigger is the whole scope it
|
|
526
|
+
* spends on GitHub; on GitLab the scope is whatever the token it is given
|
|
527
|
+
* carries.
|
|
293
528
|
* - `pull-request` — create a branch, regenerate source via
|
|
294
529
|
* `chant import --from <env>`, commit, push, and open a PR whose diff is the
|
|
295
530
|
* regenerated TypeScript. Never commits to the main branch.
|
|
@@ -325,8 +560,27 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
|
|
|
325
560
|
// The trigger context is read here rather than passed in: a step's args
|
|
326
561
|
// are serialized at build time, and the pull request is not known until
|
|
327
562
|
// the run. Missing context is fatal — see `noPullRequestContextMessage`.
|
|
328
|
-
const ctx = await resolvePullRequestContext();
|
|
329
563
|
const marker = args.marker ?? commentMarker(args.env);
|
|
564
|
+
|
|
565
|
+
// GitLab first, because its check is the narrow one: only a
|
|
566
|
+
// `merge_request_event` pipeline sets `CI_MERGE_REQUEST_IID` (#2256), so
|
|
567
|
+
// a run that has it is unambiguously the GitLab case, and a GitHub run
|
|
568
|
+
// never reaches this branch.
|
|
569
|
+
const mr = mergeRequestContextFrom(process.env);
|
|
570
|
+
if (mr) {
|
|
571
|
+
const token = gitlabNoteTokenFrom(process.env);
|
|
572
|
+
if (!token) throw new Error(noGitlabNoteTokenMessage(mr.iid));
|
|
573
|
+
const commentUrl = await postOrUpdateNote(mr, token, marker, summary, signal);
|
|
574
|
+
return {
|
|
575
|
+
mode,
|
|
576
|
+
summary,
|
|
577
|
+
entries,
|
|
578
|
+
commentUrl,
|
|
579
|
+
mergeRequest: `${mr.path ?? mr.project}!${mr.iid}`,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const ctx = await resolvePullRequestContext();
|
|
330
584
|
const commentUrl = await postOrUpdateComment(ctx, marker, summary, signal);
|
|
331
585
|
return { mode, summary, entries, commentUrl, pullRequest: `${ctx.repo}#${ctx.number}` };
|
|
332
586
|
}
|