@brainervirus/workit-core 2.3.0 → 2.5.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,450 @@
1
+ // `workit pr create` and `workit pr merge` (design §2.1 S11): forge writes with
2
+ // a SHA bound before the call and verified after it.
3
+ //
4
+ // create: the branch must already be pushed (remote tip == local head); that
5
+ // SHA is bound, an open PR/MR for the branch is reused (`created: false`),
6
+ // and success means the forge reports the bound SHA as the PR head. The
7
+ // `pr.created` ledger row is what `ledger check --pr` resolves through (S13).
8
+ //
9
+ // merge: three independent gates, all required:
10
+ // 1. the S10 status document reads READY (required checks green, no
11
+ // conflicts, threads, review or draft blockers);
12
+ // 2. an accepted S13 verdict for that exact head (fresh or carried), unless
13
+ // the workspace grants `merge: true`;
14
+ // 3. the `merge` grant (autonomy.ts `requireGrant`; S16 fills the defaults).
15
+ // The merge call carries the head SHA (GitHub `sha=`, GitLab `sha=`), so a
16
+ // head that moves after the gates refuses instead of merging unverified code.
17
+ // --delete-branch deletes with a lease on that same SHA.
18
+ import { requireGrant } from "../autonomy";
19
+ import { isProtectedTarget } from "../core/branch";
20
+ import { vcsConfig } from "../core/vcs-config";
21
+ import { deleteRemoteBranch } from "../git/ops";
22
+ import { spawnSync } from "node:child_process";
23
+ import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
24
+ import {
25
+ appendObserved,
26
+ checkVerdicts,
27
+ readLedger,
28
+ type LedgerActor,
29
+ type VerdictCheck,
30
+ } from "../ledger";
31
+ import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
32
+ import type { ResolvedForge } from "./resolve";
33
+ import { failure, success, type ForgeResult, type MergeMethod, type PrRef } from "./types";
34
+
35
+ export type Sleep = (ms: number) => Promise<void>;
36
+
37
+ /** Post-verification retries: the forge may take a moment to show a new head or merge. */
38
+ const VERIFY_DELAYS_MS = [1_000, 2_000, 4_000];
39
+
40
+ const noun = (resolved: ResolvedForge): string =>
41
+ resolved.forge.kind === "github" ? "pull request" : "merge request";
42
+
43
+ const grantBlocked = (decision: Extract<ReturnType<typeof requireGrant>, { allowed: false }>) =>
44
+ failure("blocked", decision.error, decision.unblock);
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // create
48
+
49
+ export type CreateInput = {
50
+ base: string;
51
+ title: string;
52
+ body: string;
53
+ draft: boolean;
54
+ actor: LedgerActor;
55
+ };
56
+
57
+ export type CreateOutcome = {
58
+ number: number;
59
+ url: string;
60
+ branch: string;
61
+ base: string;
62
+ head: string;
63
+ created: boolean;
64
+ draft: boolean;
65
+ recorded: { id: string } | { error: string };
66
+ };
67
+
68
+ /**
69
+ * `--fill`: the title and body from the branch's commits on top of the base
70
+ * (fetched first). One commit gives its subject and body; several give the
71
+ * oldest subject and a list of every subject.
72
+ */
73
+ export function fillFromCommits(
74
+ cwd: string,
75
+ resolved: ResolvedForge,
76
+ base: string,
77
+ ): ForgeResult<{ title: string; body: string }> {
78
+ const tracking = `refs/remotes/${resolved.baseRemote}/${base}`;
79
+ if (!base.startsWith("-"))
80
+ fetchRefs(cwd, resolved.baseRemote, [`+refs/heads/${base}:${tracking}`]);
81
+ const from = resolveRef(cwd, tracking) ?? resolveRef(cwd, `refs/heads/${base}`);
82
+ if (!from)
83
+ return failure(
84
+ "not_found",
85
+ `base ${base} is not available locally`,
86
+ `git fetch ${resolved.baseRemote} ${base}`,
87
+ );
88
+ const log = spawnSync(
89
+ "git",
90
+ ["log", "--reverse", "--no-merges", "--format=%s%x1f%b%x1e", `${from}..HEAD`],
91
+ {
92
+ cwd,
93
+ encoding: "utf8",
94
+ timeout: GIT_TIMEOUTS.local,
95
+ killSignal: "SIGKILL",
96
+ },
97
+ );
98
+ const commits = (log.status === 0 ? log.stdout : "")
99
+ .split("\x1e")
100
+ .map((entry) => entry.trim())
101
+ .filter(Boolean)
102
+ .map((entry) => {
103
+ const [subject = "", body = ""] = entry.split("\x1f");
104
+ return { subject: subject.trim(), body: body.trim() };
105
+ });
106
+ if (commits.length === 0)
107
+ return failure(
108
+ "invalid_input",
109
+ `no commits on top of ${base}; nothing to describe`,
110
+ "pass --title <t>",
111
+ );
112
+ if (commits.length === 1) return success({ title: commits[0].subject, body: commits[0].body });
113
+ return success({
114
+ title: commits[0].subject,
115
+ body: commits.map((commit) => `- ${commit.subject}`).join("\n"),
116
+ });
117
+ }
118
+
119
+ /** The pushed head of the current branch, bound before any forge write. */
120
+ export function boundSource(
121
+ cwd: string,
122
+ resolved: ResolvedForge,
123
+ ): ForgeResult<{ branch: string; sha: string }> {
124
+ const branch = currentBranch(cwd);
125
+ if (!branch) return failure("invalid_input", "HEAD is detached; check out the PR branch");
126
+ const sha = resolveRef(cwd, `refs/heads/${branch}`);
127
+ if (!sha) return failure("not_found", `branch ${branch} has no commit`);
128
+ const remote = remoteRefTip(cwd, resolved.remote, `refs/heads/${branch}`);
129
+ if (!remote.ok) return failure(remote.code, remote.error);
130
+ if (remote.sha !== sha)
131
+ return failure(
132
+ "blocked",
133
+ remote.sha === null
134
+ ? `not_pushed: ${branch} is not on ${resolved.remote}`
135
+ : `not_pushed: ${resolved.remote}/${branch} is at ${remote.sha.slice(0, 12)}, local ${branch} is at ${sha.slice(0, 12)}`,
136
+ "workit git push",
137
+ );
138
+ return success({ branch, sha });
139
+ }
140
+
141
+ async function verifiedHead(
142
+ resolved: ResolvedForge,
143
+ branch: string,
144
+ sha: string,
145
+ first: PrRef,
146
+ sleep: Sleep,
147
+ ): Promise<ForgeResult<PrRef>> {
148
+ let ref = first;
149
+ for (const delay of [0, ...VERIFY_DELAYS_MS]) {
150
+ if (ref.headSha === sha) return success(ref);
151
+ if (delay) await sleep(delay);
152
+ const found = resolved.forge.findPr(branch, {
153
+ owner: resolved.headRepo.split("/")[0] ?? null,
154
+ projectId: resolved.headProjectId,
155
+ sha,
156
+ });
157
+ if (!found.ok) return found;
158
+ if (found.data && found.data.number === first.number) ref = found.data;
159
+ }
160
+ return ref.headSha === sha
161
+ ? success(ref)
162
+ : failure(
163
+ "failed",
164
+ `head_unverified: ${noun(resolved)} #${first.number} reports head ${ref.headSha?.slice(0, 12) ?? "(none)"}, not the pushed ${sha.slice(0, 12)}`,
165
+ `workit pr status --pr ${first.number}`,
166
+ );
167
+ }
168
+
169
+ export async function createPullRequest(
170
+ cwd: string,
171
+ resolved: ResolvedForge,
172
+ input: CreateInput,
173
+ sleep: Sleep,
174
+ ): Promise<ForgeResult<CreateOutcome>> {
175
+ const grant = requireGrant(cwd, "pr");
176
+ if (!grant.allowed) return grantBlocked(grant);
177
+ const source = boundSource(cwd, resolved);
178
+ if (!source.ok) return source;
179
+ const { branch, sha } = source.data;
180
+ if (branch === input.base)
181
+ return failure(
182
+ "invalid_input",
183
+ `the PR source and base are both ${branch}`,
184
+ "pass --base <branch>",
185
+ );
186
+
187
+ const existing = resolved.forge.findPr(branch, {
188
+ owner: resolved.headRepo.split("/")[0] ?? null,
189
+ projectId: resolved.headProjectId,
190
+ sha,
191
+ });
192
+ if (!existing.ok) return existing;
193
+ let created = false;
194
+ let ref: PrRef;
195
+ if (existing.data && existing.data.state === "open") ref = existing.data;
196
+ else {
197
+ const made = resolved.forge.createPr({
198
+ head: branch,
199
+ headRepo: resolved.headRepo,
200
+ headProjectId: resolved.headProjectId,
201
+ base: input.base,
202
+ title: input.title,
203
+ body: input.body,
204
+ draft: input.draft,
205
+ });
206
+ if (!made.ok) return made;
207
+ ref = made.data;
208
+ created = true;
209
+ }
210
+ const verified = await verifiedHead(resolved, branch, sha, ref, sleep);
211
+ if (!verified.ok) return verified;
212
+ const row = appendObserved(cwd, {
213
+ type: "pr.created",
214
+ actor: input.actor,
215
+ branch,
216
+ head: sha,
217
+ pr: ref.number,
218
+ base: input.base,
219
+ url: ref.url,
220
+ forge: resolved.forge.kind,
221
+ repo: resolved.forge.repo,
222
+ created,
223
+ });
224
+ return success({
225
+ number: ref.number,
226
+ url: ref.url,
227
+ branch,
228
+ base: input.base,
229
+ head: sha,
230
+ created,
231
+ draft: input.draft,
232
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
233
+ });
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // merge
238
+
239
+ export type MergeInput = {
240
+ pr: number | null;
241
+ method: MergeMethod;
242
+ deleteBranch: boolean;
243
+ actor: LedgerActor;
244
+ };
245
+
246
+ export type MergeOutcome = {
247
+ number: number;
248
+ url: string;
249
+ branch: string;
250
+ base: string;
251
+ head: string;
252
+ method: MergeMethod;
253
+ mergeSha: string | null;
254
+ verdict: { required: boolean; accepted: boolean; verdictId: string | null };
255
+ grant: { source: "autonomy" | "host_authority" };
256
+ deletedBranch: boolean | { error: string };
257
+ recorded: { id: string } | { error: string };
258
+ };
259
+
260
+ /** Why `pr merge` refused, for the envelope `data` (agents branch on `reason`). */
261
+ export type MergeRefusal = {
262
+ reason:
263
+ | "grant_required"
264
+ | "not_ready"
265
+ | "needs_verdict"
266
+ | "head_mismatch"
267
+ | "already_merged"
268
+ | "closed"
269
+ | "protected_branch";
270
+ next?: string;
271
+ blockers?: string[];
272
+ verdict?: Pick<VerdictCheck, "head" | "accepted" | "authors">;
273
+ };
274
+
275
+ export type MergeResult =
276
+ | { ok: true; data: MergeOutcome }
277
+ | {
278
+ ok: false;
279
+ code: "blocked" | "failed" | "unavailable" | "not_found" | "busy" | "invalid_input";
280
+ error: string;
281
+ unblock?: string;
282
+ refusal?: MergeRefusal;
283
+ };
284
+
285
+ const refuse = (error: string, unblock: string, refusal: MergeRefusal): MergeResult => ({
286
+ ok: false,
287
+ code: "blocked",
288
+ error,
289
+ unblock,
290
+ refusal,
291
+ });
292
+
293
+ const NEXT_HINTS: Record<string, string> = {
294
+ RESOLVE_CONFLICTS: "rebase onto the base, resolve, then workit git push --force-with-lease",
295
+ REBASE: "rebase onto the base, then workit git push --force-with-lease",
296
+ RESOLVE_THREADS: "address and resolve the open review threads (workit pr status lists them)",
297
+ FIX_CI:
298
+ "fix the failing checks (workit pr status shows the log tails), push, then workit ci wait",
299
+ WAITING_CI: "workit ci wait",
300
+ ADDRESS_REVIEW: "address the requested changes",
301
+ REVIEW: "get the required review",
302
+ MARK_READY: "mark the PR ready for review",
303
+ IN_MERGE_QUEUE: "the PR is already in the merge queue",
304
+ NOT_MERGEABLE: "workit pr status # the forge reports it cannot merge yet",
305
+ };
306
+
307
+ export async function mergePullRequest(
308
+ cwd: string,
309
+ resolved: ResolvedForge,
310
+ input: MergeInput,
311
+ sleep: Sleep,
312
+ ): Promise<MergeResult> {
313
+ const grant = requireGrant(cwd, "merge");
314
+ if (!grant.allowed) return refuse(grant.error, grant.unblock, { reason: "grant_required" });
315
+
316
+ const number = selectPr(cwd, resolved, { pr: input.pr });
317
+ if (!number.ok) return number;
318
+ const status = resolved.forge.prStatus(number.data);
319
+ if (!status.ok) return status;
320
+ const built = buildStatusDoc(cwd, resolved, status.data, { logLines: 0, behind: false });
321
+ if (!built.ok) return built;
322
+ const doc: PrStatusDoc = built.data;
323
+ const label = `${resolved.forge.kind === "github" ? "PR #" : "MR !"}${doc.number}`;
324
+ if (doc.state === "merged")
325
+ return refuse(
326
+ `already_merged: ${label} is already merged`,
327
+ `workit verify-delivery merged --pr ${doc.number}`,
328
+ {
329
+ reason: "already_merged",
330
+ },
331
+ );
332
+ if (doc.state === "closed")
333
+ return refuse(`closed: ${label} is closed`, "reopen it on the forge first", {
334
+ reason: "closed",
335
+ });
336
+ if (doc.next !== "READY")
337
+ return refuse(
338
+ `not_ready: ${label} is ${doc.next} (${doc.blockers.join(", ") || doc.next.toLowerCase()})`,
339
+ NEXT_HINTS[doc.next] ?? "workit pr status",
340
+ { reason: "not_ready", next: doc.next, blockers: doc.blockers },
341
+ );
342
+
343
+ const branch = doc.head.branch;
344
+ const head = doc.head.sha;
345
+ // --delete-branch never deletes a protected branch, the base, or the
346
+ // default target (a develop -> main release PR must keep develop). Refused
347
+ // before merging, so the caller decides without a half-done delivery.
348
+ if (input.deleteBranch) {
349
+ const resolvedVcs = vcsConfig("resolve", cwd);
350
+ const defaultTarget =
351
+ resolvedVcs.ok === false ? null : String(resolvedVcs.defaultTargetBranch ?? "") || null;
352
+ const lower = branch.toLowerCase();
353
+ const reason = isProtectedTarget(cwd, branch)
354
+ ? "is protected by the workspace branch policy"
355
+ : lower === doc.base.toLowerCase()
356
+ ? "is the PR base"
357
+ : defaultTarget !== null && lower === defaultTarget.toLowerCase()
358
+ ? "is the default target branch"
359
+ : null;
360
+ if (reason)
361
+ return refuse(
362
+ `protected_branch: --delete-branch would delete ${branch}, which ${reason}; workit never deletes it`,
363
+ `workit pr merge${input.pr ? ` --pr ${doc.number}` : ""} --method ${input.method} # without --delete-branch`,
364
+ { reason: "protected_branch" },
365
+ );
366
+ }
367
+ let verdict: MergeOutcome["verdict"] = {
368
+ required: grant.requireVerdict,
369
+ accepted: false,
370
+ verdictId: null,
371
+ };
372
+ if (grant.requireVerdict) {
373
+ const ledger = readLedger(cwd);
374
+ if (!ledger.ok) return ledger;
375
+ const check = checkVerdicts(cwd, branch, ledger.value.rows);
376
+ const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
377
+ if (check.head !== head)
378
+ return refuse(
379
+ `head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
380
+ `git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
381
+ { reason: "head_mismatch", verdict: summary },
382
+ );
383
+ if (!check.accepted.accepted)
384
+ return refuse(
385
+ `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
386
+ `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}`,
387
+ { reason: "needs_verdict", verdict: summary },
388
+ );
389
+ verdict = {
390
+ required: true,
391
+ accepted: true,
392
+ verdictId: typeof check.accepted.verdict?.id === "string" ? check.accepted.verdict.id : null,
393
+ };
394
+ }
395
+
396
+ const merged = resolved.forge.merge(doc.number, { sha: head, method: input.method });
397
+ if (!merged.ok) return merged;
398
+ // Post-verify: the forge must report the PR merged.
399
+ let state = "open";
400
+ for (const delay of [0, ...VERIFY_DELAYS_MS]) {
401
+ if (delay) await sleep(delay);
402
+ const after = resolved.forge.prStatus(doc.number);
403
+ if (after.ok) state = after.data.state;
404
+ if (state === "merged") break;
405
+ }
406
+ if (state !== "merged")
407
+ return {
408
+ ok: false,
409
+ code: "failed",
410
+ error: `merge_unverified: the forge accepted the merge of ${label} but still reports it ${state}`,
411
+ unblock: `workit verify-delivery merged --pr ${doc.number}`,
412
+ };
413
+
414
+ let deletedBranch: MergeOutcome["deletedBranch"] = false;
415
+ if (input.deleteBranch) {
416
+ const deleted = deleteRemoteBranch(cwd, resolved.remote, branch, head);
417
+ // The forge may delete merged branches itself; gone is deleted.
418
+ const gone = !deleted.ok && remoteRefTip(cwd, resolved.remote, `refs/heads/${branch}`);
419
+ deletedBranch =
420
+ deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
421
+ }
422
+ const row = appendObserved(cwd, {
423
+ type: "pr.merged",
424
+ actor: input.actor,
425
+ branch,
426
+ head,
427
+ pr: doc.number,
428
+ base: doc.base,
429
+ mergeSha: merged.data.mergeSha,
430
+ method: input.method,
431
+ verdictId: verdict.verdictId,
432
+ grant: grant.source,
433
+ });
434
+ return {
435
+ ok: true,
436
+ data: {
437
+ number: doc.number,
438
+ url: doc.url,
439
+ branch,
440
+ base: doc.base,
441
+ head,
442
+ method: input.method,
443
+ mergeSha: merged.data.mergeSha,
444
+ verdict,
445
+ grant: { source: grant.source },
446
+ deletedBranch,
447
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
448
+ },
449
+ };
450
+ }
@@ -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
+ }