@brainervirus/workit-core 8.1.0 → 8.1.1
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/package.json +1 -1
- package/src/core/workspaces.ts +84 -38
- package/src/forge/exec.ts +72 -3
- package/src/forge/report.ts +4 -3
- package/src/forge/resolve.ts +328 -78
- package/src/git/ops.ts +3 -0
- package/src/git/rev.ts +10 -5
package/package.json
CHANGED
package/src/core/workspaces.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
@@ -413,6 +414,70 @@ export const validateWorkspaceGlob = (glob: string): GlobValidation => {
|
|
|
413
414
|
return { ok: true };
|
|
414
415
|
};
|
|
415
416
|
|
|
417
|
+
/** A workspaces.json problem for the user to fix; CLI verbs report it as `blocked`. */
|
|
418
|
+
export class WorkspaceConfigError extends Error {
|
|
419
|
+
override name = "WorkspaceConfigError";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const mainCheckouts = new Map<string, string | null>();
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The main checkout of the linked worktree at `cwd` (null when cwd is not a
|
|
426
|
+
* linked worktree). A worktree outside every glob (~/.codex/worktrees/…,
|
|
427
|
+
* ../x, /tmp) belongs to the workspace of the checkout it was added from (B2).
|
|
428
|
+
*/
|
|
429
|
+
const mainCheckoutOf = (cwd: string): string | null => {
|
|
430
|
+
const cached = mainCheckouts.get(cwd);
|
|
431
|
+
if (cached !== undefined) return cached;
|
|
432
|
+
// The repository at cwd, not one an inherited GIT_DIR/GIT_WORK_TREE names.
|
|
433
|
+
const env = { ...process.env };
|
|
434
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"])
|
|
435
|
+
delete env[name];
|
|
436
|
+
const git = (args: string[]): string | null => {
|
|
437
|
+
const run = spawnSync("git", args, {
|
|
438
|
+
cwd,
|
|
439
|
+
env,
|
|
440
|
+
encoding: "utf8",
|
|
441
|
+
timeout: 5000,
|
|
442
|
+
windowsHide: true,
|
|
443
|
+
});
|
|
444
|
+
return run.status === 0 ? run.stdout : null;
|
|
445
|
+
};
|
|
446
|
+
let main: string | null = null;
|
|
447
|
+
const dirs = git(["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"]);
|
|
448
|
+
const [gitDir, common] = (dirs ?? "").trim().split(/\r?\n/u);
|
|
449
|
+
if (gitDir && common && path.resolve(gitDir) !== path.resolve(common)) {
|
|
450
|
+
// The first `worktree` entry is the main one, also with --separate-git-dir
|
|
451
|
+
// (where the common dir is not <main>/.git); a bare repo lists its own path.
|
|
452
|
+
const listed = /^worktree (.+)$/mu.exec(git(["worktree", "list", "--porcelain"]) ?? "")?.[1];
|
|
453
|
+
main = listed?.trim() || (path.basename(common) === ".git" ? path.dirname(common) : common);
|
|
454
|
+
}
|
|
455
|
+
mainCheckouts.set(cwd, main);
|
|
456
|
+
return main;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The entries whose glob matches cwd (its logical and real path); for a linked
|
|
461
|
+
* worktree that matches none, the entries matching its main checkout.
|
|
462
|
+
*/
|
|
463
|
+
const matchingEntries = <T extends { glob: string }>(cwd: string, entries: readonly T[]): T[] => {
|
|
464
|
+
const match = (where: string): T[] => {
|
|
465
|
+
const targets = [where, realpathOf(where)].map((value) => value.replaceAll("\\", "/"));
|
|
466
|
+
return entries.filter((entry) => {
|
|
467
|
+
const glob = entry.glob.replaceAll("\\", "/");
|
|
468
|
+
const canonical = canonicalGlob(glob);
|
|
469
|
+
return targets.some(
|
|
470
|
+
(target) =>
|
|
471
|
+
matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
};
|
|
475
|
+
const direct = match(cwd);
|
|
476
|
+
if (direct.length) return direct;
|
|
477
|
+
const main = mainCheckoutOf(cwd);
|
|
478
|
+
return main ? match(main) : direct;
|
|
479
|
+
};
|
|
480
|
+
|
|
416
481
|
const realpathOf = (p: string): string => {
|
|
417
482
|
// realpathSync.native goes through libuv, which expands Windows 8.3 short
|
|
418
483
|
// names (C:\Users\RUNNER~1 -> C:\Users\runneradmin); the plain realpathSync
|
|
@@ -453,13 +518,17 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
|
|
|
453
518
|
const named = matches.filter((entry) => entry.name === workspaceName);
|
|
454
519
|
if (named.length === 1) return named[0];
|
|
455
520
|
if (named.length > 1)
|
|
456
|
-
throw new
|
|
521
|
+
throw new WorkspaceConfigError(
|
|
522
|
+
`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`,
|
|
523
|
+
);
|
|
457
524
|
if (matches.length > 0) {
|
|
458
|
-
throw new
|
|
525
|
+
throw new WorkspaceConfigError(
|
|
459
526
|
`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
|
|
460
527
|
);
|
|
461
528
|
}
|
|
462
|
-
throw new
|
|
529
|
+
throw new WorkspaceConfigError(
|
|
530
|
+
`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`,
|
|
531
|
+
);
|
|
463
532
|
}
|
|
464
533
|
|
|
465
534
|
if (matches.length < 2) return matches[0];
|
|
@@ -468,7 +537,7 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
|
|
|
468
537
|
(entry) => globSpecificity(entry.glob) === highestSpecificity,
|
|
469
538
|
);
|
|
470
539
|
if (mostSpecific.length > 1) {
|
|
471
|
-
throw new
|
|
540
|
+
throw new WorkspaceConfigError(
|
|
472
541
|
`ambiguous workspace for ${cwd}; choose one of: ${mostSpecific.map((entry) => entry.name).join(", ")}`,
|
|
473
542
|
);
|
|
474
543
|
}
|
|
@@ -486,23 +555,7 @@ export const resolveWorkspaceFromEntries = <T extends { name: string; glob: stri
|
|
|
486
555
|
// written with the logical path, so a workspace would silently stop
|
|
487
556
|
// matching on macOS. Match both forms on each side; on Linux both forms
|
|
488
557
|
// are identical so behavior is unchanged.
|
|
489
|
-
const
|
|
490
|
-
const matches: T[] = [];
|
|
491
|
-
for (const ws of entries) {
|
|
492
|
-
const glob = ws.glob.replaceAll("\\", "/");
|
|
493
|
-
const canonical = canonicalGlob(glob);
|
|
494
|
-
let matched = false;
|
|
495
|
-
for (const target of targets) {
|
|
496
|
-
if (
|
|
497
|
-
matchWorkspace(glob, target) ||
|
|
498
|
-
(canonical !== glob && matchWorkspace(canonical, target))
|
|
499
|
-
) {
|
|
500
|
-
matched = true;
|
|
501
|
-
break;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
if (matched) matches.push(ws);
|
|
505
|
-
}
|
|
558
|
+
const matches = matchingEntries(cwd, entries);
|
|
506
559
|
return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
|
|
507
560
|
};
|
|
508
561
|
|
|
@@ -513,7 +566,8 @@ export const resolveWorkspaceFrom = (
|
|
|
513
566
|
workspaceName?: string,
|
|
514
567
|
): WorkspaceConfig | null => {
|
|
515
568
|
const result = readWorkspacesResult(dir);
|
|
516
|
-
if (result.status === "malformed" || result.status === "invalid")
|
|
569
|
+
if (result.status === "malformed" || result.status === "invalid")
|
|
570
|
+
throw new WorkspaceConfigError(result.error);
|
|
517
571
|
return resolveWorkspaceFromEntries(cwd, result.entries, workspaceName);
|
|
518
572
|
};
|
|
519
573
|
|
|
@@ -586,7 +640,7 @@ const matchRuntimeWorkspace = (
|
|
|
586
640
|
raw = readFileSync(file, "utf8");
|
|
587
641
|
} catch (error) {
|
|
588
642
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
589
|
-
throw new
|
|
643
|
+
throw new WorkspaceConfigError(
|
|
590
644
|
`${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
591
645
|
{ cause: error },
|
|
592
646
|
);
|
|
@@ -595,29 +649,21 @@ const matchRuntimeWorkspace = (
|
|
|
595
649
|
try {
|
|
596
650
|
parsed = JSON.parse(raw);
|
|
597
651
|
} catch {
|
|
598
|
-
throw new
|
|
652
|
+
throw new WorkspaceConfigError(`${file} is not valid JSON`);
|
|
599
653
|
}
|
|
600
654
|
const validated = runtimeWorkspaceIndexSchema.safeParse(parsed);
|
|
601
655
|
if (!validated.success) {
|
|
602
656
|
const detail = validated.error.issues
|
|
603
657
|
.map((issue) => `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}`)
|
|
604
658
|
.join("; ");
|
|
605
|
-
throw new
|
|
659
|
+
throw new WorkspaceConfigError(`${file} has invalid workspace matching data: ${detail}`);
|
|
606
660
|
}
|
|
607
661
|
const entries = validated.data.workspaces;
|
|
608
662
|
for (const entry of entries ?? []) {
|
|
609
663
|
const glob = validateWorkspaceGlob(entry.glob);
|
|
610
|
-
if (!glob.ok) throw new
|
|
664
|
+
if (!glob.ok) throw new WorkspaceConfigError(`${file} workspace ${entry.name}: ${glob.error}`);
|
|
611
665
|
}
|
|
612
|
-
const
|
|
613
|
-
const matches = (entries ?? []).filter((entry) => {
|
|
614
|
-
const glob = entry.glob.replaceAll("\\", "/");
|
|
615
|
-
const canonical = canonicalGlob(glob);
|
|
616
|
-
return targets.some(
|
|
617
|
-
(target) =>
|
|
618
|
-
matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
|
|
619
|
-
);
|
|
620
|
-
});
|
|
666
|
+
const matches = matchingEntries(cwd, entries ?? []);
|
|
621
667
|
const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
|
|
622
668
|
const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
|
|
623
669
|
return selected ? { file, selected } : null;
|
|
@@ -649,7 +695,7 @@ const resolveRuntimeWorkspaceCandidate = (
|
|
|
649
695
|
const detail = policy.error.issues
|
|
650
696
|
.map((issue) => `${issue.path.map(String).join(".") || "workspace"}: ${issue.message}`)
|
|
651
697
|
.join("; ");
|
|
652
|
-
throw new
|
|
698
|
+
throw new WorkspaceConfigError(`${file} has invalid ${kind} policy configuration: ${detail}`);
|
|
653
699
|
}
|
|
654
700
|
return policy.data as RuntimeWorkspaceCandidate;
|
|
655
701
|
};
|
|
@@ -670,12 +716,12 @@ export const resolveRuntimeWorkspacePolicy = (
|
|
|
670
716
|
candidate.defaultProfile &&
|
|
671
717
|
!Object.hasOwn(candidate.profiles ?? {}, candidate.defaultProfile)
|
|
672
718
|
)
|
|
673
|
-
throw new
|
|
719
|
+
throw new WorkspaceConfigError(
|
|
674
720
|
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(candidate.defaultProfile)}`,
|
|
675
721
|
);
|
|
676
722
|
const profile = profileName ? candidate.profiles?.[profileName] : undefined;
|
|
677
723
|
if (profileName && !profile)
|
|
678
|
-
throw new
|
|
724
|
+
throw new WorkspaceConfigError(
|
|
679
725
|
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(profileName)}`,
|
|
680
726
|
);
|
|
681
727
|
const profilePolicy = profile?.[key] as WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
|
package/src/forge/exec.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type CliRun = {
|
|
|
19
19
|
timedOut: boolean;
|
|
20
20
|
/** The binary is not on PATH. */
|
|
21
21
|
missing: boolean;
|
|
22
|
+
/** The timeout actually applied (a deadline may have shortened it). */
|
|
23
|
+
timeoutMs?: number;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
/** Runs one gh/glab command. Injected in tests to replay recorded API fixtures. */
|
|
@@ -109,6 +111,47 @@ export const installHint = (bin: CliBin): string =>
|
|
|
109
111
|
export const loginHint = (bin: CliBin, apiHost: string): string =>
|
|
110
112
|
`${bin} auth login --hostname ${apiHost}`;
|
|
111
113
|
|
|
114
|
+
/** Transport trouble (DNS, TCP, TLS, a 5xx from the forge): retryable, exit 5. */
|
|
115
|
+
const NETWORK =
|
|
116
|
+
/error connecting|could not resolve|no such host|connection (refused|reset|timed out)|network is unreachable|i\/o timeout|tls handshake|unexpected eof|\bHTTP 5\d\d\b|bad gateway|service unavailable|gateway time-?out/iu;
|
|
117
|
+
|
|
118
|
+
export const isNetworkError = (text: string): boolean => NETWORK.test(text);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The forge's own reason for a refusal: `gh api`/`glab api` print the error
|
|
122
|
+
* body on stdout (`message`, `errors[].message`), stderr only says "HTTP 422".
|
|
123
|
+
*/
|
|
124
|
+
export function forgeReason(run: Pick<CliRun, "stdout" | "stderr">): string {
|
|
125
|
+
const parts: string[] = [];
|
|
126
|
+
try {
|
|
127
|
+
const body = JSON.parse(run.stdout) as {
|
|
128
|
+
message?: unknown;
|
|
129
|
+
error?: unknown;
|
|
130
|
+
errors?: unknown;
|
|
131
|
+
};
|
|
132
|
+
const add = (value: unknown): void => {
|
|
133
|
+
if (typeof value === "string" && value.trim()) parts.push(value.trim());
|
|
134
|
+
else if (Array.isArray(value)) value.forEach(add);
|
|
135
|
+
else if (value && typeof value === "object") {
|
|
136
|
+
const record = value as Record<string, unknown>;
|
|
137
|
+
if (typeof record.message === "string") add(record.message);
|
|
138
|
+
else if (!("code" in record)) Object.values(record).forEach(add);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
add(body.message);
|
|
142
|
+
add(body.error);
|
|
143
|
+
add(body.errors);
|
|
144
|
+
} catch {
|
|
145
|
+
// not a JSON body
|
|
146
|
+
}
|
|
147
|
+
const stderr = (run.stderr.split(/\r?\n/u).find((value) => value.trim()) ?? "").trim();
|
|
148
|
+
const unique = [...new Set(parts)];
|
|
149
|
+
const text = unique.length
|
|
150
|
+
? `${unique.join("; ")}${stderr ? ` (${stderr.replace(/^gh: |^glab: /u, "")})` : ""}`
|
|
151
|
+
: stderr || (run.stdout.split(/\r?\n/u).find((value) => value.trim()) ?? "");
|
|
152
|
+
return redactText(text).slice(0, 300);
|
|
153
|
+
}
|
|
154
|
+
|
|
112
155
|
/** Map a failed CLI run to an envelope error. */
|
|
113
156
|
export function cliFailure<T>(
|
|
114
157
|
bin: CliBin,
|
|
@@ -119,7 +162,13 @@ export function cliFailure<T>(
|
|
|
119
162
|
): ForgeResult<T> {
|
|
120
163
|
if (run.missing) return failure("unavailable", `${bin} is not installed`, installHint(bin));
|
|
121
164
|
if (run.timedOut)
|
|
122
|
-
return failure(
|
|
165
|
+
return failure(
|
|
166
|
+
"unavailable",
|
|
167
|
+
run.timeoutMs === 0
|
|
168
|
+
? `${bin} api ${what} not attempted: the command's time budget ran out`
|
|
169
|
+
: `${bin} api ${what} timed out after ${run.timeoutMs ?? timeoutMs} ms`,
|
|
170
|
+
`check the network to ${apiHost}, then retry`,
|
|
171
|
+
);
|
|
123
172
|
const stderr = redactText(run.stderr).trim();
|
|
124
173
|
if (
|
|
125
174
|
/auth login|not logged in|no token|authentication required|bad credentials|\b401\b|unauthorized/iu.test(
|
|
@@ -133,6 +182,12 @@ export function cliFailure<T>(
|
|
|
133
182
|
);
|
|
134
183
|
if (/\b404\b|not found/iu.test(stderr)) return failure("not_found", `${what} was not found`);
|
|
135
184
|
const first = stderr.split(/\r?\n/u).find((value) => value.trim()) ?? `exit ${run.status}`;
|
|
185
|
+
if (isNetworkError(stderr))
|
|
186
|
+
return failure(
|
|
187
|
+
"unavailable",
|
|
188
|
+
`${bin} api ${what} failed (network): ${first.slice(0, 200)}`,
|
|
189
|
+
`check the network to ${apiHost}, then retry`,
|
|
190
|
+
);
|
|
136
191
|
return failure("failed", `${bin} api ${what} failed: ${first.slice(0, 200)}`);
|
|
137
192
|
}
|
|
138
193
|
|
|
@@ -197,7 +252,7 @@ export function apiWrite<T>(
|
|
|
197
252
|
const run = runner(bin, args, { timeoutMs });
|
|
198
253
|
if (run.status !== 0) {
|
|
199
254
|
const text = redactText(`${run.stderr}\n${run.stdout}`).trim();
|
|
200
|
-
const reason = (
|
|
255
|
+
const reason = forgeReason(run);
|
|
201
256
|
if (
|
|
202
257
|
!run.missing &&
|
|
203
258
|
!run.timedOut &&
|
|
@@ -210,7 +265,21 @@ export function apiWrite<T>(
|
|
|
210
265
|
);
|
|
211
266
|
if (!run.missing && !run.timedOut && /\b(405|406|422)\b/u.test(text))
|
|
212
267
|
return failure("blocked", `${what} refused by the forge: ${reason}`);
|
|
213
|
-
|
|
268
|
+
const failed = cliFailure<T>(bin, apiHost, run, what, timeoutMs);
|
|
269
|
+
// A timeout, a dropped connection or a 5xx may come after the forge
|
|
270
|
+
// applied the write: look before writing again.
|
|
271
|
+
if (
|
|
272
|
+
!failed.ok &&
|
|
273
|
+
failed.code === "unavailable" &&
|
|
274
|
+
!run.missing &&
|
|
275
|
+
!/ is not authenticated for /u.test(failed.error)
|
|
276
|
+
)
|
|
277
|
+
return failure(
|
|
278
|
+
"unavailable",
|
|
279
|
+
failed.error,
|
|
280
|
+
"workit pr status # the write may have gone through; check it first, then retry",
|
|
281
|
+
);
|
|
282
|
+
return failed;
|
|
214
283
|
}
|
|
215
284
|
if (!run.stdout.trim()) return { ok: true, data: {} as T };
|
|
216
285
|
try {
|
package/src/forge/report.ts
CHANGED
|
@@ -473,14 +473,15 @@ export function waitVerdict(
|
|
|
473
473
|
return { state: "waiting", reason: "head_mismatch" };
|
|
474
474
|
const checks = doc.checks.state;
|
|
475
475
|
if (checks === "failing") return { state: "failed", reason: "checks_failing" };
|
|
476
|
-
|
|
477
|
-
if (checks
|
|
478
|
-
if (doc.conflicts)
|
|
476
|
+
// Before "pending": CI never runs on a conflicting PR, so waiting cannot help (M8).
|
|
477
|
+
if (doc.conflicts && checks !== "passing")
|
|
479
478
|
return {
|
|
480
479
|
state: "blocked",
|
|
481
480
|
reason: "conflicts",
|
|
482
481
|
unblock: `rebase onto ${doc.base} and push; CI does not run on a conflicting PR`,
|
|
483
482
|
};
|
|
483
|
+
if (checks === "pending") return { state: "waiting", reason: "checks_pending" };
|
|
484
|
+
if (checks === "passing") return { state: "ready", reason: "checks_passing" };
|
|
484
485
|
const ciRequired =
|
|
485
486
|
doc.checks.missingRequired === null ||
|
|
486
487
|
doc.mergeState === "ci_must_pass" ||
|
package/src/forge/resolve.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// §2.0 Identity).
|
|
3
3
|
//
|
|
4
4
|
// - The push remote host decides GitHub vs GitLab. A workspace provider that
|
|
5
|
-
// disagrees is `blocked
|
|
5
|
+
// disagrees is `blocked` for the forge verbs, never silently overridden;
|
|
6
|
+
// `git push` (checkPushIdentity) only needs git, so it pushes with a note.
|
|
6
7
|
// - PRs/MRs live in the base repository: an `upstream` remote on the same
|
|
7
8
|
// forge, else the push repo's fork parent, else the push repo itself.
|
|
8
9
|
// - Every API call carries the workspace's credential explicitly, so the
|
|
@@ -23,9 +24,10 @@ import {
|
|
|
23
24
|
type ForgeHosts,
|
|
24
25
|
type SshConfig,
|
|
25
26
|
} from "../git/rev";
|
|
26
|
-
import { FORGE_TIMEOUTS, systemRunner, type ForgeRunner } from "./exec";
|
|
27
|
+
import { FORGE_TIMEOUTS, isNetworkError, systemRunner, type ForgeRunner } from "./exec";
|
|
27
28
|
import { createGitHubForge } from "./github";
|
|
28
29
|
import { createGitLabForge } from "./gitlab";
|
|
30
|
+
import { redactText } from "./redact";
|
|
29
31
|
import { failure, success, type Forge, type ForgeResult, type Identity } from "./types";
|
|
30
32
|
|
|
31
33
|
export type CredentialSource = "workspace_token_file" | "gh_account_token" | "cli_login";
|
|
@@ -62,51 +64,103 @@ export type ResolveOptions = {
|
|
|
62
64
|
now?: () => number;
|
|
63
65
|
/** ssh_config for Host aliases (default: the user's ~/.ssh/config). */
|
|
64
66
|
sshConfig?: string | SshConfig;
|
|
67
|
+
/** Absolute deadline (`now()` clock) for every call, resolution included; null = none. */
|
|
68
|
+
deadline?: number | null;
|
|
65
69
|
};
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// Per-process memo of credential lookups and /user answers (a long-lived MCP
|
|
72
|
+
// server re-asks after the TTL). Scoped by runner, so an injected test runner
|
|
73
|
+
// never sees another's answers, and keyed by the gh/glab config location, so
|
|
74
|
+
// another GH_CONFIG_DIR never reuses them; only successes are kept.
|
|
75
|
+
const MEMO_TTL_MS = 5 * 60_000;
|
|
76
|
+
const SYSTEM_SCOPE = {};
|
|
77
|
+
const memoScopes = new WeakMap<object, Map<string, { at: number; value: unknown }>>();
|
|
78
|
+
|
|
79
|
+
type MemoScope = { owner: object; key: string };
|
|
80
|
+
|
|
81
|
+
const memoScope = (runner: ForgeRunner | undefined, env: NodeJS.ProcessEnv): MemoScope => ({
|
|
82
|
+
owner: runner ?? SYSTEM_SCOPE,
|
|
83
|
+
key: [env.GH_CONFIG_DIR, env.GLAB_CONFIG_DIR, env.XDG_CONFIG_HOME, env.HOME]
|
|
84
|
+
.map((value) => value ?? "")
|
|
85
|
+
.join("|"),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function memo<T>(
|
|
89
|
+
scope: MemoScope,
|
|
90
|
+
key: string,
|
|
91
|
+
compute: () => { keep: boolean; value: T },
|
|
92
|
+
now = Date.now,
|
|
93
|
+
): T {
|
|
94
|
+
let entries = memoScopes.get(scope.owner);
|
|
95
|
+
if (!entries) memoScopes.set(scope.owner, (entries = new Map()));
|
|
96
|
+
const full = `${scope.key} ${key}`;
|
|
97
|
+
const hit = entries.get(full);
|
|
98
|
+
if (hit && now() - hit.at < MEMO_TTL_MS) return hit.value as T;
|
|
99
|
+
const { keep, value } = compute();
|
|
100
|
+
if (keep) entries.set(full, { at: now(), value });
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A runner whose calls are clamped to `limits.deadline`, carrying `token`. */
|
|
105
|
+
function clampedRunner(
|
|
106
|
+
base: ForgeRunner,
|
|
107
|
+
limits: { deadline: number | null },
|
|
108
|
+
now: () => number,
|
|
109
|
+
token?: string,
|
|
110
|
+
): ForgeRunner {
|
|
111
|
+
return (bin, args, callOptions) => {
|
|
112
|
+
let timeoutMs = callOptions.timeoutMs;
|
|
113
|
+
if (limits.deadline !== null) {
|
|
114
|
+
const remaining = limits.deadline - now();
|
|
115
|
+
if (remaining <= 0)
|
|
116
|
+
return {
|
|
117
|
+
status: null,
|
|
118
|
+
stdout: "",
|
|
119
|
+
stderr: "",
|
|
120
|
+
timedOut: true,
|
|
121
|
+
missing: false,
|
|
122
|
+
timeoutMs: 0,
|
|
123
|
+
};
|
|
124
|
+
timeoutMs = Math.min(timeoutMs, remaining);
|
|
125
|
+
}
|
|
126
|
+
const run = base(bin, args, { ...callOptions, timeoutMs, ...(token ? { token } : {}) });
|
|
127
|
+
return run.timedOut && run.timeoutMs === undefined ? { ...run, timeoutMs } : run;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `forge.identity` answered once per explicit credential per TTL (push, then
|
|
133
|
+
* pr create, …). The CLI's own login (`cli_login`) is never memoized: a
|
|
134
|
+
* `glab auth login` / `gh auth switch` must show on the next call.
|
|
135
|
+
*/
|
|
136
|
+
const memoizedIdentity = (forge: Forge, scope: MemoScope, credential: Credential): Forge => {
|
|
137
|
+
if (credential.credential === "cli_login") return forge;
|
|
138
|
+
const identity = (expected: string | null) => forge.identity(expected);
|
|
139
|
+
return {
|
|
140
|
+
...forge,
|
|
141
|
+
identity: (expected: string | null) =>
|
|
142
|
+
memo(
|
|
143
|
+
scope,
|
|
144
|
+
`identity ${forge.kind} ${forge.apiHost} ${credential.credential} ${credential.token ?? ""} ${expected ?? ""}`,
|
|
145
|
+
() => {
|
|
146
|
+
const value = identity(expected);
|
|
147
|
+
return { keep: value.ok, value };
|
|
148
|
+
},
|
|
149
|
+
),
|
|
73
150
|
};
|
|
74
|
-
return { github: host("github"), gitlab: host("gitlab") };
|
|
75
151
|
};
|
|
76
152
|
|
|
77
|
-
|
|
78
|
-
derived.kind === "github"
|
|
79
|
-
? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
|
|
80
|
-
: createGitLabForge({ apiHost: derived.apiHost, repo, runner });
|
|
153
|
+
type Credential = { token: string | undefined; credential: CredentialSource };
|
|
81
154
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} catch (error) {
|
|
90
|
-
return failure(
|
|
91
|
-
"blocked",
|
|
92
|
-
error instanceof Error ? error.message : String(error),
|
|
93
|
-
"fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
const hosts = configuredHosts();
|
|
97
|
-
const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
|
|
98
|
-
if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
|
|
99
|
-
const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
|
|
100
|
-
if (conflict) return failure("blocked", conflict.error, conflict.unblock);
|
|
101
|
-
const derived = pushed.forge;
|
|
155
|
+
/** The credential every API call carries (see the header). */
|
|
156
|
+
function resolveCredential(
|
|
157
|
+
workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>,
|
|
158
|
+
derived: DerivedForge,
|
|
159
|
+
run: ForgeRunner,
|
|
160
|
+
scope: MemoScope,
|
|
161
|
+
): ForgeResult<Credential> {
|
|
102
162
|
const { kind, apiHost } = derived;
|
|
103
|
-
const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
|
|
104
|
-
const now = options.now ?? Date.now;
|
|
105
163
|
const expectedAccount = workspace?.vcs?.account ?? null;
|
|
106
|
-
|
|
107
|
-
// The credential for every call.
|
|
108
|
-
let token: string | undefined;
|
|
109
|
-
let credential: CredentialSource = "cli_login";
|
|
110
164
|
const tokenFile = (workspace?.vcs as { tokenFile?: unknown } | undefined)?.tokenFile;
|
|
111
165
|
if (typeof tokenFile === "string" && tokenFile.trim()) {
|
|
112
166
|
// A stored token goes only to the host it is for: the public forge (also
|
|
@@ -127,6 +181,7 @@ export function resolveForge(
|
|
|
127
181
|
`token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`,
|
|
128
182
|
`if ${apiHost} is your ${kind} server, set ${kind}.host in ~/.config/workit/vcs.json (or vcs.host for the workspace); otherwise fix the push remote`,
|
|
129
183
|
);
|
|
184
|
+
let token = "";
|
|
130
185
|
try {
|
|
131
186
|
token = fs.readFileSync(tokenFile.trim(), "utf8").trim();
|
|
132
187
|
} catch {
|
|
@@ -142,42 +197,108 @@ export function resolveForge(
|
|
|
142
197
|
`workspace ${workspace?.name ?? "?"} vcs.tokenFile is empty`,
|
|
143
198
|
"fix vcs.tokenFile in ~/.config/workit/workspaces.json",
|
|
144
199
|
);
|
|
145
|
-
credential
|
|
146
|
-
}
|
|
147
|
-
|
|
200
|
+
return success({ token, credential: "workspace_token_file" });
|
|
201
|
+
}
|
|
202
|
+
// glab keeps one login per host, so a GitLab account is checked, not selected.
|
|
203
|
+
if (!expectedAccount || kind !== "github")
|
|
204
|
+
return success({ token: undefined, credential: "cli_login" });
|
|
205
|
+
return memo(scope, `token ${apiHost} ${expectedAccount}`, () => {
|
|
206
|
+
const issued = run("gh", ["auth", "token", "--hostname", apiHost, "--user", expectedAccount], {
|
|
148
207
|
timeoutMs: FORGE_TIMEOUTS.api,
|
|
149
208
|
});
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
"install the GitHub CLI (https://cli.github.com) and run: gh auth login",
|
|
155
|
-
);
|
|
156
|
-
token = issued.status === 0 ? issued.stdout.trim() : "";
|
|
157
|
-
if (!token)
|
|
158
|
-
return failure(
|
|
159
|
-
"blocked",
|
|
160
|
-
`identity_unavailable: gh has no login for ${expectedAccount} on ${apiHost} (workspace ${workspace?.name ?? "?"})`,
|
|
161
|
-
`gh auth login --hostname ${apiHost} # sign in as ${expectedAccount}; workit passes that account's token per call and never switches the active account`,
|
|
162
|
-
);
|
|
163
|
-
credential = "gh_account_token";
|
|
164
|
-
}
|
|
209
|
+
const value = authTokenResult(issued, apiHost, expectedAccount, workspace?.name ?? "?");
|
|
210
|
+
return { keep: value.ok, value };
|
|
211
|
+
});
|
|
212
|
+
}
|
|
165
213
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
214
|
+
/**
|
|
215
|
+
* `gh auth token --user`: a timeout or a transport error is `unavailable`
|
|
216
|
+
* (exit 5, retry); only gh saying it has no such login is identity_unavailable.
|
|
217
|
+
*/
|
|
218
|
+
function authTokenResult(
|
|
219
|
+
issued: ReturnType<ForgeRunner>,
|
|
220
|
+
apiHost: string,
|
|
221
|
+
account: string,
|
|
222
|
+
workspace: string,
|
|
223
|
+
): ForgeResult<Credential> {
|
|
224
|
+
if (issued.missing)
|
|
225
|
+
return failure(
|
|
226
|
+
"unavailable",
|
|
227
|
+
"gh is not installed",
|
|
228
|
+
"install the GitHub CLI (https://cli.github.com) and run: gh auth login",
|
|
229
|
+
);
|
|
230
|
+
if (issued.timedOut)
|
|
231
|
+
return failure(
|
|
232
|
+
"unavailable",
|
|
233
|
+
`gh auth token ${issued.timeoutMs === 0 ? "not attempted: the time budget ran out" : `timed out after ${issued.timeoutMs ?? FORGE_TIMEOUTS.api} ms`} (looking up the ${account} login on ${apiHost})`,
|
|
234
|
+
"gh auth status # check gh and the network, then retry",
|
|
235
|
+
);
|
|
236
|
+
const token = issued.status === 0 ? issued.stdout.trim() : "";
|
|
237
|
+
if (token) return success({ token, credential: "gh_account_token" });
|
|
238
|
+
const stderr = redactText(issued.stderr).trim();
|
|
239
|
+
const first = stderr.split(/\r?\n/u).find((line) => line.trim()) ?? "";
|
|
240
|
+
if (issued.status !== 0 && first && !/no oauth token|not logged in|no account/iu.test(stderr))
|
|
241
|
+
return failure(
|
|
242
|
+
"unavailable",
|
|
243
|
+
`gh auth token failed${isNetworkError(stderr) ? " (network)" : ""}: ${first.slice(0, 200)}`,
|
|
244
|
+
"gh auth status # check gh and the network, then retry",
|
|
245
|
+
);
|
|
246
|
+
return failure(
|
|
247
|
+
"blocked",
|
|
248
|
+
`identity_unavailable: gh has no login for ${account} on ${apiHost} (workspace ${workspace})`,
|
|
249
|
+
`gh auth login --hostname ${apiHost} # sign in as ${account}; workit passes that account's token per call and never switches the active account`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const configuredHosts = (): ForgeHosts => {
|
|
254
|
+
const vcs = readVcsConfig();
|
|
255
|
+
if (vcs.status !== "valid") return {};
|
|
256
|
+
const host = (kind: "github" | "gitlab"): string[] => {
|
|
257
|
+
const value = (vcs.config[kind] as { host?: unknown } | undefined)?.host;
|
|
258
|
+
return typeof value === "string" && value.trim() ? [value.trim()] : [];
|
|
176
259
|
};
|
|
260
|
+
return { github: host("github"), gitlab: host("gitlab") };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const createForge = (derived: DerivedForge, repo: string, runner: ForgeRunner): Forge =>
|
|
264
|
+
derived.kind === "github"
|
|
265
|
+
? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
|
|
266
|
+
: createGitLabForge({ apiHost: derived.apiHost, repo, runner });
|
|
267
|
+
|
|
268
|
+
export function resolveForge(
|
|
269
|
+
cwd: string,
|
|
270
|
+
options: ResolveOptions = {},
|
|
271
|
+
): ForgeResult<ResolvedForge> {
|
|
272
|
+
let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
|
|
273
|
+
try {
|
|
274
|
+
workspace = resolveRuntimeWorkspaceVcs(cwd);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return failure(
|
|
277
|
+
"blocked",
|
|
278
|
+
error instanceof Error ? error.message : String(error),
|
|
279
|
+
"fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const hosts = configuredHosts();
|
|
283
|
+
const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
|
|
284
|
+
if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
|
|
285
|
+
const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
|
|
286
|
+
if (conflict) return failure("blocked", conflict.error, conflict.unblock);
|
|
287
|
+
const derived = pushed.forge;
|
|
288
|
+
const { kind, apiHost } = derived;
|
|
289
|
+
const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
|
|
290
|
+
const scope = memoScope(options.runner, options.env ?? process.env);
|
|
291
|
+
const now = options.now ?? Date.now;
|
|
292
|
+
const expectedAccount = workspace?.vcs?.account ?? null;
|
|
293
|
+
// The deadline covers resolution too, so a verb's --timeout bounds it all.
|
|
294
|
+
const limits: { deadline: number | null } = { deadline: options.deadline ?? null };
|
|
295
|
+
const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
|
|
296
|
+
if (!credential.ok) return credential;
|
|
297
|
+
const runner = clampedRunner(base, limits, now, credential.data.token);
|
|
177
298
|
|
|
178
299
|
// Base repository: an `upstream` remote on the same forge, else the fork parent.
|
|
179
300
|
const headRepo = derived.repo;
|
|
180
|
-
const probe = createForge(derived, headRepo, runner);
|
|
301
|
+
const probe = memoizedIdentity(createForge(derived, headRepo, runner), scope, credential.data);
|
|
181
302
|
const info = probe.repoInfo(headRepo);
|
|
182
303
|
if (!info.ok && info.code !== "not_found") return info;
|
|
183
304
|
const headProjectId = info.ok ? info.data.id : null;
|
|
@@ -201,13 +322,16 @@ export function resolveForge(
|
|
|
201
322
|
: (remotes.find((name) => repoOfRemote(name) === baseRepo) ?? pushed.remote);
|
|
202
323
|
|
|
203
324
|
return success({
|
|
204
|
-
forge:
|
|
325
|
+
forge:
|
|
326
|
+
baseRepo === headRepo
|
|
327
|
+
? probe
|
|
328
|
+
: memoizedIdentity(createForge(derived, baseRepo, runner), scope, credential.data),
|
|
205
329
|
remote: pushed.remote,
|
|
206
330
|
url: pushed.url,
|
|
207
331
|
derived,
|
|
208
332
|
workspace: workspace?.name ?? null,
|
|
209
333
|
expectedAccount,
|
|
210
|
-
credential,
|
|
334
|
+
credential: credential.data.credential,
|
|
211
335
|
headRepo,
|
|
212
336
|
headProjectId,
|
|
213
337
|
fork: baseRepo !== headRepo,
|
|
@@ -225,16 +349,142 @@ export function checkIdentity(resolved: ResolvedForge): ForgeResult<Identity> {
|
|
|
225
349
|
const { forge, expectedAccount } = resolved;
|
|
226
350
|
const identity = forge.identity(expectedAccount);
|
|
227
351
|
if (!identity.ok) return identity;
|
|
228
|
-
if (identity.data.matches === false)
|
|
229
|
-
|
|
230
|
-
|
|
352
|
+
if (identity.data.matches === false)
|
|
353
|
+
return mismatch(resolved, identity.data.login ?? "?", resolved.workspace);
|
|
354
|
+
return identity;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const mismatch = <T>(
|
|
358
|
+
resolved: Pick<ResolvedForge, "forge" | "credential" | "expectedAccount">,
|
|
359
|
+
login: string,
|
|
360
|
+
workspace: string | null,
|
|
361
|
+
): ForgeResult<T> => {
|
|
362
|
+
const { forge, expectedAccount } = resolved;
|
|
363
|
+
const bin = forge.kind === "github" ? "gh" : "glab";
|
|
364
|
+
const where = `workspace ${workspace ?? "?"}`;
|
|
365
|
+
const fileFix = `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`;
|
|
366
|
+
return failure(
|
|
367
|
+
"blocked",
|
|
368
|
+
`identity_mismatch: the ${resolved.credential === "workspace_token_file" ? "vcs.tokenFile token" : `${bin} login`} is ${login} but ${where} expects ${expectedAccount}`,
|
|
369
|
+
resolved.credential === "workspace_token_file"
|
|
370
|
+
? fileFix
|
|
371
|
+
: forge.kind === "github"
|
|
372
|
+
? `gh auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`
|
|
373
|
+
: // glab keeps one login per host; switching it would change every repo.
|
|
374
|
+
`${fileFix} (glab keeps one login per host, so workit never switches it)`,
|
|
375
|
+
);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/** The forge could not answer at all, as opposed to answering against the account. */
|
|
379
|
+
const unreachable = (result: { code: string; error: string }): boolean =>
|
|
380
|
+
result.code === "unavailable" && !/ is not authenticated for /u.test(result.error);
|
|
381
|
+
|
|
382
|
+
/** Total budget for the identity check before a push (B1). */
|
|
383
|
+
export const PUSH_IDENTITY_BUDGET_MS = 5_000;
|
|
384
|
+
|
|
385
|
+
export type PushIdentity = {
|
|
386
|
+
/**
|
|
387
|
+
* verified: the forge says the credential is the workspace account.
|
|
388
|
+
* no_account: the workspace names no vcs.account, so there is nothing to check.
|
|
389
|
+
* skipped: the check could not run (forge down, slow, unknown, misconfigured);
|
|
390
|
+
* git pushes anyway and `reason` says why.
|
|
391
|
+
*/
|
|
392
|
+
status: "verified" | "no_account" | "skipped";
|
|
393
|
+
forge: "github" | "gitlab" | null;
|
|
394
|
+
account: string | null;
|
|
395
|
+
login: string | null;
|
|
396
|
+
reason?: string;
|
|
397
|
+
unblock?: string;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The best-effort identity check before `git push`: git decides the transport,
|
|
402
|
+
* so a forge that cannot answer (missing, slow, offline, unknown, another
|
|
403
|
+
* provider) pushes with `status: "skipped"` and the reason (S10, B1). Positive
|
|
404
|
+
* evidence still blocks: another account, no gh login for the workspace
|
|
405
|
+
* account, a broken token file or a rejected credential. All calls share one
|
|
406
|
+
* `budgetMs`; explicit credentials are memoized.
|
|
407
|
+
*/
|
|
408
|
+
export function checkPushIdentity(
|
|
409
|
+
cwd: string,
|
|
410
|
+
options: Omit<ResolveOptions, "deadline"> & { budgetMs?: number } = {},
|
|
411
|
+
): ForgeResult<PushIdentity> {
|
|
412
|
+
let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
|
|
413
|
+
try {
|
|
414
|
+
workspace = resolveRuntimeWorkspaceVcs(cwd);
|
|
415
|
+
} catch (error) {
|
|
231
416
|
return failure(
|
|
232
417
|
"blocked",
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
? `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`
|
|
236
|
-
: `${bin} auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`,
|
|
418
|
+
error instanceof Error ? error.message : String(error),
|
|
419
|
+
"fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
|
|
237
420
|
);
|
|
238
421
|
}
|
|
239
|
-
|
|
422
|
+
const account = workspace?.vcs?.account ?? null;
|
|
423
|
+
const skipped = (forge: PushIdentity["forge"], reason: string, unblock?: string) =>
|
|
424
|
+
success<PushIdentity>({
|
|
425
|
+
status: "skipped",
|
|
426
|
+
forge,
|
|
427
|
+
account,
|
|
428
|
+
login: null,
|
|
429
|
+
reason,
|
|
430
|
+
...(unblock ? { unblock } : {}),
|
|
431
|
+
});
|
|
432
|
+
const pushed = pushForge(cwd, {
|
|
433
|
+
branch: options.branch,
|
|
434
|
+
hosts: configuredHosts(),
|
|
435
|
+
sshConfig: options.sshConfig,
|
|
436
|
+
});
|
|
437
|
+
if (!pushed.ok) {
|
|
438
|
+
const host = /^unsupported_forge: .*?"([^"]+)"/u.exec(pushed.error)?.[1];
|
|
439
|
+
const what = /could not resolve ssh alias/u.test(pushed.error)
|
|
440
|
+
? `could not resolve ssh alias ${host} to GitHub or GitLab`
|
|
441
|
+
: pushed.error.startsWith("unsupported_forge: ssh host")
|
|
442
|
+
? `ssh host ${host} is not a GitHub/GitLab host or an alias workit can resolve`
|
|
443
|
+
: `${host} is not a GitHub or GitLab host workit knows`;
|
|
444
|
+
return skipped(
|
|
445
|
+
null,
|
|
446
|
+
host
|
|
447
|
+
? `unsupported_forge: ${what}; account checks skipped, pushed with git only (no PR or CI verbs)`
|
|
448
|
+
: pushed.error,
|
|
449
|
+
pushed.unblock,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
const derived = pushed.forge;
|
|
453
|
+
const conflict = forgeConflict(derived, workspace?.vcs?.provider);
|
|
454
|
+
if (conflict) return skipped(derived.kind, conflict.error, conflict.unblock);
|
|
455
|
+
if (!account) return success({ status: "no_account", forge: derived.kind, account, login: null });
|
|
456
|
+
|
|
457
|
+
const base =
|
|
458
|
+
options.runner ?? systemRunner(derived.kind, derived.apiHost, options.env ?? process.env);
|
|
459
|
+
const scope = memoScope(options.runner, options.env ?? process.env);
|
|
460
|
+
const now = options.now ?? Date.now;
|
|
461
|
+
const limits = { deadline: now() + (options.budgetMs ?? PUSH_IDENTITY_BUDGET_MS) };
|
|
462
|
+
const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
|
|
463
|
+
// A forge that cannot answer (missing CLI, timeout, transport) is skipped;
|
|
464
|
+
// local evidence against the account (no gh login for it, a broken
|
|
465
|
+
// vcs.tokenFile, a rejected credential) still blocks: over HTTPS git would
|
|
466
|
+
// push with whatever account gh's credential helper has active.
|
|
467
|
+
if (!credential.ok)
|
|
468
|
+
return unreachable(credential)
|
|
469
|
+
? skipped(derived.kind, credential.error, credential.unblock)
|
|
470
|
+
: credential;
|
|
471
|
+
const forge = memoizedIdentity(
|
|
472
|
+
createForge(derived, derived.repo, clampedRunner(base, limits, now, credential.data.token)),
|
|
473
|
+
scope,
|
|
474
|
+
credential.data,
|
|
475
|
+
);
|
|
476
|
+
const identity = forge.identity(account);
|
|
477
|
+
if (!identity.ok)
|
|
478
|
+
return unreachable(identity)
|
|
479
|
+
? skipped(derived.kind, identity.error, identity.unblock)
|
|
480
|
+
: identity;
|
|
481
|
+
if (identity.data.matches === false)
|
|
482
|
+
return mismatch(
|
|
483
|
+
{ forge, credential: credential.data.credential, expectedAccount: account },
|
|
484
|
+
identity.data.login ?? "?",
|
|
485
|
+
workspace?.name ?? null,
|
|
486
|
+
);
|
|
487
|
+
if (identity.data.matches === null)
|
|
488
|
+
return skipped(derived.kind, identity.data.note ?? "the credential cannot read /user");
|
|
489
|
+
return success({ status: "verified", forge: derived.kind, account, login: identity.data.login });
|
|
240
490
|
}
|
package/src/git/ops.ts
CHANGED
|
@@ -823,6 +823,8 @@ export function executePush(
|
|
|
823
823
|
setUpstream?: boolean;
|
|
824
824
|
actor: LedgerActor;
|
|
825
825
|
timeoutMs?: number;
|
|
826
|
+
/** The pre-push identity check's outcome, kept on the ledger row. */
|
|
827
|
+
identity?: Record<string, unknown>;
|
|
826
828
|
},
|
|
827
829
|
): ForgeResult<PushOutcome> {
|
|
828
830
|
const before = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.branch}`);
|
|
@@ -940,6 +942,7 @@ export function executePush(
|
|
|
940
942
|
previous: before.sha,
|
|
941
943
|
pushed,
|
|
942
944
|
forced,
|
|
945
|
+
...(input.identity ? { identity: input.identity } : {}),
|
|
943
946
|
});
|
|
944
947
|
return success({
|
|
945
948
|
remote: plan.remote,
|
package/src/git/rev.ts
CHANGED
|
@@ -879,15 +879,20 @@ export function pushForge(
|
|
|
879
879
|
const forge = deriveForge(url, options);
|
|
880
880
|
if (!forge) {
|
|
881
881
|
// Never echo the raw URL: it may carry a token (https://user:tok@…).
|
|
882
|
-
const
|
|
882
|
+
const parsed = parseRemoteUrl(url);
|
|
883
|
+
const host = parsed?.host || null;
|
|
883
884
|
return {
|
|
884
885
|
ok: false,
|
|
885
886
|
code: "unavailable",
|
|
886
887
|
error: host
|
|
887
|
-
?
|
|
888
|
+
? parsed?.protocol === "ssh" && !host.includes(".")
|
|
889
|
+
? `unsupported_forge: could not resolve ssh alias "${host}" to GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
|
|
890
|
+
: parsed?.protocol === "ssh"
|
|
891
|
+
? `unsupported_forge: ssh host "${host}" is not a GitHub/GitLab host or an alias workit can resolve; PR and CI verbs need one (workit git push still pushes with git)`
|
|
892
|
+
: `unsupported_forge: cannot tell whether push host "${host}" is GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
|
|
888
893
|
: `push URL ${redactRemote(url)} of remote "${remote}" is not a GitHub/GitLab URL`,
|
|
889
894
|
unblock: host
|
|
890
|
-
? `
|
|
895
|
+
? `if ${host} is an ssh alias of GitHub/GitLab, add "Host ${host}" with "HostName github.com" (or gitlab.com) to ~/.ssh/config; if it is a self-hosted GitHub/GitLab, set github.host or gitlab.host in ~/.config/workit/vcs.json; other forges (Bitbucket, Gitea…) have no PR/CI verbs`
|
|
891
896
|
: `git remote set-url --push ${remote} <ssh-or-https-url>`,
|
|
892
897
|
};
|
|
893
898
|
}
|
|
@@ -906,7 +911,7 @@ export function forgeConflict(
|
|
|
906
911
|
if (!configured || configured === derived.kind) return null;
|
|
907
912
|
return {
|
|
908
913
|
code: "blocked",
|
|
909
|
-
error: `forge_mismatch: push remote ${derived.host} is ${derived.kind} but the workspace vcs.provider is ${configured}`,
|
|
910
|
-
unblock: `
|
|
914
|
+
error: `forge_mismatch: push remote ${derived.host} is ${derived.kind} but the workspace vcs.provider is ${configured}, so its account and grants do not apply; PR and CI verbs stop here (workit git push still pushes with git)`,
|
|
915
|
+
unblock: `add a workspace whose glob matches only this repo, with "vcs": {"provider": "${derived.kind}"} (and its account), to ~/.config/workit/workspaces.json; the most specific glob wins`,
|
|
911
916
|
};
|
|
912
917
|
}
|