@brainervirus/workit-core 2.2.2 → 2.4.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.
@@ -0,0 +1,455 @@
1
+ // GitLab adapter: `glab api` REST only (design §2.0 GitLab endpoints): the MR
2
+ // (`detailed_merge_status`, `has_conflicts`, `head_pipeline`), pipeline jobs,
3
+ // job traces, discussions, and pipeline/job retry.
4
+ import { apiJson, apiText, FORGE_TIMEOUTS, type ForgeRunner } from "./exec";
5
+ import { logTail, redactText, shortBody } from "./redact";
6
+ import {
7
+ failure,
8
+ success,
9
+ type Forge,
10
+ type ForgeCheck,
11
+ type ForgePrStatus,
12
+ type ForgeResult,
13
+ type ForgeThread,
14
+ type PrState,
15
+ } from "./types";
16
+
17
+ /** Page cap for jobs and discussions (100 per page). */
18
+ export const GITLAB_MAX_PAGES = 10;
19
+ const PER_PAGE = 100;
20
+
21
+ type GlUser = { username?: string; bot?: boolean } | null;
22
+
23
+ type GlMr = {
24
+ iid: number;
25
+ web_url: string;
26
+ state: string;
27
+ draft?: boolean;
28
+ work_in_progress?: boolean;
29
+ detailed_merge_status?: string;
30
+ has_conflicts?: boolean;
31
+ target_branch: string;
32
+ source_branch: string;
33
+ sha: string;
34
+ diff_refs?: { base_sha?: string | null } | null;
35
+ source_project_id?: number;
36
+ head_pipeline?: {
37
+ id: number;
38
+ sha: string;
39
+ /** Merged-results / merge-train pipelines run on a merge ref; this is the MR head. */
40
+ source_sha?: string | null;
41
+ /** `refs/merge-requests/<iid>/merge` (merged results) or `/train` (merge train). */
42
+ ref?: string;
43
+ project_id: number;
44
+ status: string;
45
+ web_url?: string;
46
+ } | null;
47
+ };
48
+
49
+ type GlBridge = {
50
+ id: number;
51
+ name: string;
52
+ stage: string;
53
+ status: string;
54
+ allow_failure?: boolean;
55
+ web_url?: string;
56
+ downstream_pipeline?: {
57
+ id: number;
58
+ status: string;
59
+ project_id?: number;
60
+ web_url?: string;
61
+ } | null;
62
+ };
63
+
64
+ type GlJob = {
65
+ id: number;
66
+ name: string;
67
+ stage: string;
68
+ status: string;
69
+ allow_failure?: boolean;
70
+ web_url?: string;
71
+ };
72
+
73
+ type GlNote = {
74
+ id: number;
75
+ body: string;
76
+ author: GlUser;
77
+ system?: boolean;
78
+ resolvable?: boolean;
79
+ resolved?: boolean;
80
+ position?: {
81
+ new_path?: string | null;
82
+ new_line?: number | null;
83
+ old_path?: string | null;
84
+ } | null;
85
+ };
86
+
87
+ type GlDiscussion = { id: string; notes: GlNote[] };
88
+
89
+ const mrState = (value: string): PrState =>
90
+ value === "merged" ? "merged" : value === "opened" ? "open" : "closed";
91
+
92
+ const PENDING = new Set([
93
+ "created",
94
+ "pending",
95
+ "running",
96
+ "waiting_for_resource",
97
+ "waiting_for_callback",
98
+ "preparing",
99
+ "scheduled",
100
+ ]);
101
+
102
+ const stateOf = (status: string, allowFailure: boolean): ForgeCheck["state"] => {
103
+ const failed = status === "failed" || status === "canceled";
104
+ return PENDING.has(status)
105
+ ? "pending"
106
+ : failed
107
+ ? allowFailure
108
+ ? "passing"
109
+ : "failing"
110
+ : status === "success"
111
+ ? "passing"
112
+ : "skipped";
113
+ };
114
+
115
+ /** A trigger/bridge job: its state is the downstream pipeline's. */
116
+ export function checkFromBridge(bridge: GlBridge, pipelineId: number, scope: string): ForgeCheck {
117
+ const status = bridge.downstream_pipeline?.status ?? bridge.status;
118
+ const allow = bridge.allow_failure === true;
119
+ const failed = status === "failed" || status === "canceled";
120
+ return {
121
+ name: `${bridge.stage} / ${bridge.name}`,
122
+ context: bridge.name,
123
+ state: stateOf(status, allow),
124
+ conclusion: failed && allow ? "allowed_failure" : `downstream_${status}`,
125
+ url: safeUrl(bridge.downstream_pipeline?.web_url ?? bridge.web_url ?? null),
126
+ runId: pipelineId,
127
+ // Not a log-bearing job; rerun it on the downstream pipeline.
128
+ jobId: null,
129
+ scope,
130
+ required: !allow,
131
+ };
132
+ }
133
+
134
+ export function checkFromJob(job: GlJob, pipelineId: number, scope: string): ForgeCheck {
135
+ const failed = job.status === "failed" || job.status === "canceled";
136
+ return {
137
+ name: `${job.stage} / ${job.name}`,
138
+ context: job.name,
139
+ state: stateOf(job.status, job.allow_failure === true),
140
+ conclusion: failed && job.allow_failure ? "allowed_failure" : job.status,
141
+ url: safeUrl(job.web_url ?? null),
142
+ runId: pipelineId,
143
+ jobId: job.id,
144
+ scope,
145
+ // Every job that may not fail gates the pipeline, which gates the MR.
146
+ required: job.allow_failure !== true,
147
+ };
148
+ }
149
+
150
+ /** A pipeline-level check when no job explains the pipeline's state. */
151
+ const pipelineCheck = (
152
+ state: ForgeCheck["state"],
153
+ conclusion: string,
154
+ pipeline: { id: number; project_id: number; web_url?: string } | null,
155
+ ): ForgeCheck => ({
156
+ name: "pipeline",
157
+ context: "pipeline",
158
+ state,
159
+ conclusion,
160
+ url: safeUrl(pipeline?.web_url ?? null),
161
+ runId: pipeline?.id ?? null,
162
+ jobId: null,
163
+ scope: pipeline ? String(pipeline.project_id) : null,
164
+ required: true,
165
+ });
166
+
167
+ const safeUrl = (value: string | null): string | null => (value ? redactText(value) : null);
168
+
169
+ const isBot = (author: GlUser): boolean =>
170
+ author?.bot === true || /(?:^project_\d+_bot|bot$|\[bot\]$)/iu.test(author?.username ?? "");
171
+
172
+ // Statuses where GitLab has not finished computing mergeability.
173
+ const UNSETTLED = new Set(["checking", "unchecked", "preparing", "approvals_syncing"]);
174
+
175
+ export function createGitLabForge(options: {
176
+ apiHost: string;
177
+ repo: string;
178
+ runner: ForgeRunner;
179
+ maxPages?: number;
180
+ }): Forge {
181
+ const { apiHost, repo, runner } = options;
182
+ const maxPages = options.maxPages ?? GITLAB_MAX_PAGES;
183
+ const project = `projects/${encodeURIComponent(repo)}`;
184
+
185
+ const api = (endpoint: string): string[] => ["api", endpoint];
186
+
187
+ /** Every page of a list endpoint, up to the cap. */
188
+ const pages = (
189
+ endpoint: string,
190
+ what: string,
191
+ ): ForgeResult<{ items: unknown[]; truncated: boolean }> => {
192
+ const items: unknown[] = [];
193
+ const sep = endpoint.includes("?") ? "&" : "?";
194
+ for (let page = 1; page <= maxPages; page += 1) {
195
+ const chunk = apiJson<unknown>(
196
+ runner,
197
+ "glab",
198
+ apiHost,
199
+ api(`${endpoint}${sep}per_page=${PER_PAGE}&page=${page}`),
200
+ what,
201
+ );
202
+ if (!chunk.ok) return chunk;
203
+ if (!Array.isArray(chunk.data)) return failure("failed", `${what}: expected a list`);
204
+ items.push(...chunk.data);
205
+ if (chunk.data.length < PER_PAGE) return success({ items, truncated: false });
206
+ }
207
+ return success({ items, truncated: true });
208
+ };
209
+
210
+ /** True when merge commit `sha` (in project `scope`) has `head` as a parent. */
211
+ const mergeCommitTests = (scope: number, sha: string, head: string): boolean => {
212
+ const commit = apiJson<{ parent_ids?: string[] }>(
213
+ runner,
214
+ "glab",
215
+ apiHost,
216
+ api(`projects/${scope}/repository/commits/${sha}`),
217
+ `commit ${sha.slice(0, 12)}`,
218
+ );
219
+ return (
220
+ commit.ok && Array.isArray(commit.data.parent_ids) && commit.data.parent_ids.includes(head)
221
+ );
222
+ };
223
+
224
+ /** The current tip of the target branch (diff_refs.base_sha is the merge base). */
225
+ const baseTip = (branch: string): string | null => {
226
+ const tip = apiJson<{ commit?: { id?: string } }>(
227
+ runner,
228
+ "glab",
229
+ apiHost,
230
+ api(`${project}/repository/branches/${encodeURIComponent(branch)}`),
231
+ `branch ${branch}`,
232
+ );
233
+ return tip.ok && typeof tip.data.commit?.id === "string" ? tip.data.commit.id : null;
234
+ };
235
+
236
+ return {
237
+ kind: "gitlab",
238
+ apiHost,
239
+ repo,
240
+
241
+ identity(expected) {
242
+ const user = apiJson<{ username?: unknown }>(runner, "glab", apiHost, api("user"), "user");
243
+ // CI job tokens cannot read /user; reads still work, so skip the check.
244
+ if (!user.ok && user.code === "failed" && /\b403\b|forbidden/iu.test(user.error))
245
+ return success({
246
+ login: null,
247
+ expected,
248
+ matches: null,
249
+ note: "the credential cannot read /user (job or project token); identity not checked",
250
+ });
251
+ if (!user.ok) return user;
252
+ const login = typeof user.data.username === "string" ? user.data.username : "";
253
+ if (!login) return failure("failed", "glab api user returned no username");
254
+ return success({
255
+ login,
256
+ expected,
257
+ matches: expected ? login.toLowerCase() === expected.toLowerCase() : null,
258
+ });
259
+ },
260
+
261
+ repoInfo(target) {
262
+ const info = apiJson<{ id?: number; forked_from_project?: { path_with_namespace?: string } }>(
263
+ runner,
264
+ "glab",
265
+ apiHost,
266
+ api(`projects/${encodeURIComponent(target)}`),
267
+ `project ${target}`,
268
+ );
269
+ if (!info.ok) return info;
270
+ return success({
271
+ id: typeof info.data.id === "number" ? info.data.id : null,
272
+ parent: info.data.forked_from_project?.path_with_namespace ?? null,
273
+ });
274
+ },
275
+
276
+ findPr(head, match) {
277
+ const list = apiJson<GlMr[]>(
278
+ runner,
279
+ "glab",
280
+ apiHost,
281
+ api(
282
+ `${project}/merge_requests?source_branch=${encodeURIComponent(head)}&order_by=created_at&sort=desc&per_page=20`,
283
+ ),
284
+ `merge requests for ${head}`,
285
+ );
286
+ if (!list.ok) return list;
287
+ if (!Array.isArray(list.data)) return failure("failed", "merge request list is not a list");
288
+ // Same-named branches in other forks are not ours: match the source
289
+ // project or the exact head sha.
290
+ const matches = list.data.filter(
291
+ (mr) =>
292
+ mr.source_branch === head &&
293
+ ((match.projectId !== null && mr.source_project_id === match.projectId) ||
294
+ (match.sha !== null && mr.sha === match.sha)),
295
+ );
296
+ const pick = matches.find((mr) => mr.state === "opened") ?? matches[0];
297
+ return success(
298
+ pick
299
+ ? {
300
+ number: pick.iid,
301
+ url: pick.web_url,
302
+ state: mrState(pick.state),
303
+ headBranch: pick.source_branch,
304
+ headSha: pick.sha ?? null,
305
+ }
306
+ : null,
307
+ );
308
+ },
309
+
310
+ prStatus(iid) {
311
+ const mr = apiJson<GlMr>(
312
+ runner,
313
+ "glab",
314
+ apiHost,
315
+ api(`${project}/merge_requests/${iid}`),
316
+ `merge request !${iid}`,
317
+ );
318
+ if (!mr.ok) return mr;
319
+ const value = mr.data;
320
+ let truncated = false;
321
+ let checks: ForgeCheck[] = [];
322
+ const pipeline = value.head_pipeline ?? null;
323
+ const open = value.state === "opened";
324
+ // Merged-results and merge-train pipelines run on a merge ref whose sha
325
+ // is a merge commit, not the MR head. They test the current head when
326
+ // source_sha says so or, when the response has no source_sha, when the
327
+ // merge commit's parents include the head (GET …/repository/commits/:sha).
328
+ const current =
329
+ pipeline !== null &&
330
+ (pipeline.sha === value.sha ||
331
+ pipeline.source_sha === value.sha ||
332
+ (!pipeline.source_sha &&
333
+ new RegExp(`^refs/merge-requests/${value.iid}/(?:merge|train)$`, "u").test(
334
+ pipeline.ref ?? "",
335
+ ) &&
336
+ mergeCommitTests(pipeline.project_id, pipeline.sha, value.sha)));
337
+ if (pipeline && current) {
338
+ const scope = String(pipeline.project_id);
339
+ const jobs = pages(
340
+ `projects/${scope}/pipelines/${pipeline.id}/jobs`,
341
+ `pipeline ${pipeline.id} jobs`,
342
+ );
343
+ if (!jobs.ok) return jobs;
344
+ const bridges = pages(
345
+ `projects/${scope}/pipelines/${pipeline.id}/bridges`,
346
+ `pipeline ${pipeline.id} bridges`,
347
+ );
348
+ if (!bridges.ok) return bridges;
349
+ truncated ||= jobs.data.truncated || bridges.data.truncated;
350
+ checks = [
351
+ ...(jobs.data.items as GlJob[]).map((job) => checkFromJob(job, pipeline.id, scope)),
352
+ ...(bridges.data.items as GlBridge[]).map((bridge) =>
353
+ checkFromBridge(bridge, pipeline.id, scope),
354
+ ),
355
+ ];
356
+ // The pipeline status is a floor: a failed pipeline is never green,
357
+ // and a running one is never done, whatever the visible jobs say.
358
+ const pipelineState = stateOf(pipeline.status, false);
359
+ if (pipelineState === "failing" && !checks.some((check) => check.state === "failing"))
360
+ checks.push(pipelineCheck("failing", pipeline.status, pipeline));
361
+ if (pipelineState === "pending" && !checks.some((check) => check.state === "pending"))
362
+ checks.push(pipelineCheck("pending", pipeline.status, pipeline));
363
+ } else if (open && pipeline) {
364
+ // The head pipeline belongs to an older head: CI for this head has
365
+ // not reported yet. Pending, never "no checks".
366
+ checks = [pipelineCheck("pending", "awaiting_pipeline_for_head", null)];
367
+ }
368
+ const discussions = pages(
369
+ `${project}/merge_requests/${iid}/discussions`,
370
+ `merge request !${iid} discussions`,
371
+ );
372
+ if (!discussions.ok) return discussions;
373
+ truncated ||= discussions.data.truncated;
374
+ const threads: ForgeThread[] = (discussions.data.items as GlDiscussion[])
375
+ .filter((discussion) =>
376
+ discussion.notes.some((note) => note.resolvable === true && note.resolved !== true),
377
+ )
378
+ .map((discussion) => {
379
+ const note = discussion.notes[0];
380
+ return {
381
+ id: discussion.id,
382
+ path: note.position?.new_path ?? note.position?.old_path ?? null,
383
+ line: note.position?.new_line ?? null,
384
+ author: note.author?.username ?? null,
385
+ isBot: isBot(note.author),
386
+ outdated: false,
387
+ url: safeUrl(`${value.web_url}#note_${note.id}`),
388
+ body: shortBody(note.body ?? ""),
389
+ };
390
+ });
391
+ const detailed = value.detailed_merge_status ?? "unchecked";
392
+ const conflicts = value.has_conflicts === true || detailed === "conflict";
393
+ const status: ForgePrStatus = {
394
+ number: value.iid,
395
+ url: value.web_url,
396
+ state: mrState(value.state),
397
+ draft: value.draft ?? value.work_in_progress ?? false,
398
+ base: value.target_branch,
399
+ baseSha: open ? baseTip(value.target_branch) : null,
400
+ head: { branch: value.source_branch, sha: value.sha },
401
+ mergeable: conflicts ? "no" : UNSETTLED.has(detailed) ? "unknown" : "yes",
402
+ conflicts,
403
+ rebaseRequired: detailed === "need_rebase",
404
+ reviewDecision:
405
+ detailed === "not_approved"
406
+ ? "review_required"
407
+ : detailed === "requested_changes"
408
+ ? "changes_requested"
409
+ : null,
410
+ mergeState: detailed,
411
+ inMergeQueue: false,
412
+ checks,
413
+ // GitLab gates on the whole pipeline (jobs that may not fail).
414
+ requiredContexts: [],
415
+ threads,
416
+ truncated,
417
+ };
418
+ return success(status);
419
+ },
420
+
421
+ jobLogTail(job, lines) {
422
+ const scope = job.scope ?? encodeURIComponent(repo);
423
+ const text = apiText(
424
+ runner,
425
+ "glab",
426
+ apiHost,
427
+ ["api", `projects/${scope}/jobs/${job.jobId}/trace`],
428
+ `job ${job.jobId} trace`,
429
+ FORGE_TIMEOUTS.log,
430
+ );
431
+ return text.ok ? success(logTail(text.data, lines)) : text;
432
+ },
433
+
434
+ rerun(target) {
435
+ const scope = target.scope ?? encodeURIComponent(repo);
436
+ const endpoint =
437
+ target.kind === "failed_in_run"
438
+ ? `projects/${scope}/pipelines/${target.runId}/retry`
439
+ : `projects/${scope}/jobs/${target.jobId}/retry`;
440
+ const what =
441
+ target.kind === "failed_in_run"
442
+ ? `retry of pipeline ${target.runId}`
443
+ : `retry of job ${target.jobId}`;
444
+ const run = apiText(
445
+ runner,
446
+ "glab",
447
+ apiHost,
448
+ ["api", "-X", "POST", endpoint],
449
+ what,
450
+ FORGE_TIMEOUTS.api,
451
+ );
452
+ return run.ok ? success(undefined) : run;
453
+ },
454
+ };
455
+ }
@@ -0,0 +1,191 @@
1
+ // Redaction and bounding for text that comes back from a forge: CI logs,
2
+ // review comments, CLI stderr. All of it is untrusted data that an agent will
3
+ // read, so secrets are masked and the size is capped before it leaves core.
4
+
5
+ const R = "[REDACTED]";
6
+
7
+ // Token formats that are secrets wherever they appear.
8
+ const TOKEN_PATTERNS: readonly RegExp[] = [
9
+ // GitHub tokens (classic, fine-grained, app, OAuth, refresh).
10
+ /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/gu,
11
+ // GitLab tokens: glpat-, glrt-, gldt-, glcbt-, glptt-, glft-, gloas-, …
12
+ /\bgl[a-z]{1,8}-[A-Za-z0-9_-]{16,}/gu,
13
+ /\bnpm_[A-Za-z0-9]{30,}/gu,
14
+ /\bxox[abposr]-[A-Za-z0-9-]{10,}/gu,
15
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu,
16
+ /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/gu,
17
+ /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/gu,
18
+ /\bAIza[0-9A-Za-z_-]{35}/gu,
19
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/gu,
20
+ ];
21
+
22
+ // Slack/Discord webhooks: the path is the credential.
23
+ const WEBHOOK =
24
+ /\b(https?:\/\/(?:hooks\.slack\.com\/(?:services|workflows|triggers)|discord(?:app)?\.com\/api\/webhooks))\/[^\s"'<>]+/giu;
25
+
26
+ // Userinfo in any URL (`https://user:pw@`, `postgres://a:b:c@`, `https://tok@`).
27
+ const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/giu;
28
+
29
+ // `curl -u user:pass`, `--user user:pass`.
30
+ const CURL_USER = /(\s(?:-u|--user)(?:\s+|=))(["']?)[^\s"':]+:[^\s"']+\2/gu;
31
+
32
+ // Secret-ish keys in env dumps, YAML, JSON, headers and connection strings:
33
+ // DB_PASSWORD=…, "access_token": "…", X-Api-Key: …, PRIVATE-TOKEN: …,
34
+ // AccountKey=…; an optional auth scheme (Basic/Bearer) is kept.
35
+ const KEY = String.raw`[A-Za-z0-9_.-]*?(?:token|secret|password|passwd|pwd|api[_-]?key|access[_-]?key|account[_-]?key|private[_-]?key|client[_-]?secret|credentials?|authorization|cookie|signature)[A-Za-z0-9_.-]*`;
36
+ const ASSIGNMENT = new RegExp(
37
+ String.raw`(?<![A-Za-z0-9])(["']?${KEY}["']?\s*[:=]\s*["']?)((?:bearer|basic|token|digest)\s+)?([^\s"',;&]+)`,
38
+ "giu",
39
+ );
40
+
41
+ const BEARER = /\b(bearer\s+)[A-Za-z0-9._~+/-]{12,}=*/giu;
42
+
43
+ // A quoted value may contain spaces: password: "correct horse battery".
44
+ const QUOTED_ASSIGNMENT = new RegExp(
45
+ String.raw`(?<![A-Za-z0-9])(["']?${KEY}["']?\s*[:=]\s*)(["'])([^"'\n]*[^\s"'][^"'\n]*)\2`,
46
+ "giu",
47
+ );
48
+
49
+ // ~/.netrc: `machine h login u password p` (also split across lines).
50
+ const NETRC = /\b((?:machine|login)\s+\S+\s+(?:login\s+\S+\s+)?password\s+)\S+/giu;
51
+
52
+ // Secrets passed as CLI flags: sshpass -p X, mysql -pX / -p X.
53
+ const FLAG_SECRETS: readonly RegExp[] = [
54
+ /\b(sshpass\s+-p\s*)\S+/gu,
55
+ /\b(mysql(?:dump|admin|import|show)?\b[^\n]*?\s-p)(?!\s|$)\S+/gu,
56
+ ];
57
+
58
+ // Signed-URL and query-string credentials (Azure SAS `sig=`, `token=`, …).
59
+ const QUERY_SECRET =
60
+ /([?&;](?:sig|signature|token|access_token|api_key|apikey|key|password|secret|client_secret|X-Amz-Signature|X-Goog-Signature)=)[^&\s#"']+/giu;
61
+
62
+ // A key block from BEGIN to END, or to the end of the text when truncated.
63
+ const PRIVATE_KEY =
64
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/gu;
65
+
66
+ // Long base64 runs: redacted when they decode to a credential, or sit on a
67
+ // line that decodes base64 (`echo … | base64 -d`).
68
+ const BASE64 = /[A-Za-z0-9+/]{32,}={0,2}/gu;
69
+
70
+ const ANSI = new RegExp(String.raw`\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*\u0007`, "gu");
71
+
72
+ export const stripAnsi = (value: string): string => value.replace(ANSI, "");
73
+
74
+ const decodesToSecret = (blob: string): boolean => {
75
+ if (/^[0-9a-f]+$/iu.test(blob)) return false; // hex: commit/tree ids
76
+ let decoded: string;
77
+ try {
78
+ decoded = Buffer.from(blob, "base64").toString("utf8");
79
+ } catch {
80
+ return false;
81
+ }
82
+ if (!/^[\x20-\x7e\s]+$/u.test(decoded)) return false;
83
+ return (
84
+ TOKEN_PATTERNS.some((pattern) => new RegExp(pattern.source, "u").test(decoded)) ||
85
+ /^[^\s:]{1,64}:[^\s]{6,}$/u.test(decoded.trim())
86
+ );
87
+ };
88
+
89
+ /** Mask secrets and strip terminal escapes. Works on whole (multi-line) text. */
90
+ export function redactText(value: string): string {
91
+ let out = stripAnsi(value).replace(PRIVATE_KEY, "[REDACTED PRIVATE KEY]");
92
+ out = out.replace(WEBHOOK, `$1/${R}`);
93
+ out = out.replace(URL_USERINFO, `$1${R}@`);
94
+ for (const pattern of TOKEN_PATTERNS) out = out.replace(pattern, R);
95
+ out = out.replace(CURL_USER, `$1$2${R}$2`);
96
+ out = out.replace(BEARER, `$1${R}`);
97
+ out = out.replace(QUOTED_ASSIGNMENT, (match, lead: string, quote: string, secret: string) =>
98
+ secret.startsWith(R) ? match : `${lead}${quote}${R}${quote}`,
99
+ );
100
+ out = out.replace(NETRC, `$1${R}`);
101
+ for (const pattern of FLAG_SECRETS) out = out.replace(pattern, `$1${R}`);
102
+ out = out.replace(QUERY_SECRET, `$1${R}`);
103
+ out = out.replace(
104
+ ASSIGNMENT,
105
+ (match, lead: string, scheme: string | undefined, secret: string) =>
106
+ secret.startsWith(R) ? match : `${lead}${scheme ?? ""}${R}`,
107
+ );
108
+ return out
109
+ .split("\n")
110
+ .map((line) =>
111
+ line.replace(BASE64, (blob) =>
112
+ /\bbase64\b/iu.test(line) || decodesToSecret(blob) ? R : blob,
113
+ ),
114
+ )
115
+ .join("\n");
116
+ }
117
+
118
+ // GitHub Actions prefixes every log line with an ISO timestamp; GitLab traces
119
+ // carry section markers and a leading timestamp/stream tag in newer runners.
120
+ const LINE_NOISE = /^(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s?)?(?:\d{2}[A-Z]\s)?/u;
121
+ const GITLAB_SECTION = /section_(?:start|end):\d+:[A-Za-z0-9_.-]+(?:\[[^\]]*\])?\r?/gu;
122
+
123
+ // Where the failure ends: everything after is runner cleanup.
124
+ const FAILURE_END = [/##\[error\]Process completed with exit code/u, /^ERROR: Job failed\b/u];
125
+
126
+ const MAX_LINE = 400;
127
+
128
+ // The first line that looks like the actual failure: a strong marker (a
129
+ // test-runner failure, an assertion, a thrown error) wins over a weak one
130
+ // (any "failed"/"ERROR" mention, which passing tests also print).
131
+ const STRONG_ERROR =
132
+ /^\s*(?:\(fail\)|FAIL\b|✗|✕|×|error(?:\[[A-Z0-9]+\])?:|[A-Za-z]*Error\b:|AssertionError|Traceback|panic:|Exception\b)/u;
133
+ const WEAK_ERROR = /\berror\b[:[]|\bERROR\b|\bFAIL(?:ED)?\b|\bfailed\b|\bassert/u;
134
+ // Passing test lines and summaries mention failure words without failing.
135
+ const NOT_FIRST_ERROR =
136
+ /^\s*(?:\(pass\)|\(skip\)|\(todo\)|✓|✔|ok\b|PASS\b)|^\s*\d+ fail\b|##\[error\]Process completed|^\s*0 (?:errors?|fail)/u;
137
+
138
+ /** Lines of context kept before the first error. */
139
+ const LEAD = 3;
140
+
141
+ /**
142
+ * A bounded, redacted view of a CI log: at most `lines` lines. Runner
143
+ * cleanup after the failure marker is dropped. When the first error sits
144
+ * before the final window, the view is the region around that first error,
145
+ * a `…` gap, then the end of the log (where the summary and exit code are).
146
+ */
147
+ export function logTail(text: string, lines: number): string[] {
148
+ if (lines <= 0) return [];
149
+ const cleaned = text
150
+ .replace(GITLAB_SECTION, "")
151
+ .split(/\r?\n/u)
152
+ // A bare \r rewrites the line in a terminal (progress bars): keep what
153
+ // was finally shown.
154
+ .map(
155
+ (raw) =>
156
+ stripAnsi(raw)
157
+ .split("\r")
158
+ .findLast((part) => part.trim()) ?? "",
159
+ )
160
+ .map((raw) => raw.replace(LINE_NOISE, ""));
161
+ // Redact the whole text so a secret spanning lines (a private key) goes.
162
+ const all = redactText(cleaned.join("\n")).split("\n");
163
+ let end = all.length;
164
+ for (let index = all.length - 1; index >= 0; index -= 1)
165
+ if (FAILURE_END.some((pattern) => pattern.test(all[index]))) {
166
+ end = index + 1;
167
+ break;
168
+ }
169
+ const kept = all.slice(0, end);
170
+ while (kept.length > 0 && !kept[kept.length - 1].trim()) kept.pop();
171
+ const cap = (value: string) => (value.length > MAX_LINE ? `${value.slice(0, MAX_LINE)}…` : value);
172
+ const tailStart = Math.max(0, kept.length - lines);
173
+ const find = (pattern: RegExp) =>
174
+ kept.findIndex((value) => pattern.test(value) && !NOT_FIRST_ERROR.test(value));
175
+ const strong = find(STRONG_ERROR);
176
+ const first = strong >= 0 ? strong : find(WEAK_ERROR);
177
+ if (first < 0 || first >= tailStart || lines < 8) return kept.slice(tailStart).map(cap);
178
+ const headCount = Math.ceil((lines - 1) * (2 / 3));
179
+ const from = Math.max(0, first - LEAD);
180
+ const head = kept.slice(from, from + headCount);
181
+ const tailCount = lines - 1 - head.length;
182
+ const tailFrom = Math.max(from + head.length, kept.length - tailCount);
183
+ const tail = kept.slice(tailFrom);
184
+ return [...head, ...(tailFrom > from + head.length ? ["…"] : []), ...tail].map(cap);
185
+ }
186
+
187
+ /** One-line, redacted, length-capped text (review comment bodies). */
188
+ export function shortBody(value: string, max = 300): string {
189
+ const flat = redactText(value).replace(/\s+/gu, " ").trim();
190
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
191
+ }