@brainervirus/workit-core 8.1.0 → 8.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-ship/SKILL.md +14 -14
- package/src/core/task-store.ts +4 -2
- package/src/core/workspaces.ts +84 -38
- package/src/forge/exec.ts +72 -3
- package/src/forge/github.ts +143 -0
- package/src/forge/gitlab.ts +167 -0
- package/src/forge/pr-edit.ts +291 -0
- package/src/forge/pr-ops.ts +103 -17
- package/src/forge/report.ts +4 -3
- package/src/forge/resolve.ts +328 -78
- package/src/forge/types.ts +32 -0
- package/src/git/ops.ts +3 -0
- package/src/git/rev.ts +10 -5
- package/src/hooks/handle.ts +33 -7
- package/src/hooks/hosts/claude-code.ts +12 -2
- package/src/hooks/hosts/codex.ts +33 -8
- package/src/hooks/hosts/cursor.ts +10 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/protocol.ts +8 -1
- package/src/hooks/raw-git.ts +612 -0
- package/src/hooks/shell-words.ts +128 -0
- package/src/hooks/write-gate.ts +1 -78
- package/src/host-session.ts +53 -0
- package/src/ledger.ts +17 -3
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/forge/types.ts
CHANGED
|
@@ -140,6 +140,28 @@ export type MergeResult = {
|
|
|
140
140
|
mergeSha: string | null;
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
+
/** The editable fields of a PR/MR (`workit pr ready|edit`). */
|
|
144
|
+
export type PrMeta = {
|
|
145
|
+
number: number;
|
|
146
|
+
url: string;
|
|
147
|
+
state: PrState;
|
|
148
|
+
draft: boolean;
|
|
149
|
+
title: string;
|
|
150
|
+
base: string;
|
|
151
|
+
headBranch: string;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** What `workit pr edit` changes; unset fields are left alone. */
|
|
155
|
+
export type PrEdit = {
|
|
156
|
+
title?: string;
|
|
157
|
+
body?: string;
|
|
158
|
+
base?: string;
|
|
159
|
+
addLabels: string[];
|
|
160
|
+
removeLabels: string[];
|
|
161
|
+
/** Logins; on GitHub `org/team` requests a team. */
|
|
162
|
+
addReviewers: string[];
|
|
163
|
+
};
|
|
164
|
+
|
|
143
165
|
/** A repository as the forge sees it: id and the repo it was forked from. */
|
|
144
166
|
export type RepoInfo = { id: number | null; parent: string | null };
|
|
145
167
|
|
|
@@ -163,4 +185,14 @@ export interface Forge {
|
|
|
163
185
|
merge(n: number, options: { sha: string; method: MergeMethod }): ForgeResult<MergeResult>;
|
|
164
186
|
/** Retarget a PR/MR to another base branch (S12 stacks). */
|
|
165
187
|
updateBase(n: number, base: string): ForgeResult<void>;
|
|
188
|
+
/** Title, draft flag, base and state of one PR/MR. */
|
|
189
|
+
prMeta(n: number): ForgeResult<PrMeta>;
|
|
190
|
+
/** Mark a draft ready (`draft: false`) or convert it back to a draft. */
|
|
191
|
+
setDraft(n: number, draft: boolean): ForgeResult<void>;
|
|
192
|
+
editPr(n: number, edit: PrEdit): ForgeResult<void>;
|
|
193
|
+
/** Reply to a review thread (GitHub review thread id, GitLab discussion id). */
|
|
194
|
+
replyThread(n: number, thread: string, body: string): ForgeResult<{ url: string | null }>;
|
|
195
|
+
resolveThread(n: number, thread: string): ForgeResult<void>;
|
|
196
|
+
/** One review thread of PR `n` read directly: null when it is not on that PR. */
|
|
197
|
+
threadState(n: number, thread: string): ForgeResult<"open" | "resolved" | null>;
|
|
166
198
|
}
|
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
|
}
|
package/src/hooks/handle.ts
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
import { sessionContextText, turnContextText } from "./context";
|
|
3
3
|
import type { HostDescriptor, Support } from "./descriptor";
|
|
4
4
|
import { shellPolicy } from "./policy";
|
|
5
|
+
import {
|
|
6
|
+
callKey,
|
|
7
|
+
NEXT_COMMAND,
|
|
8
|
+
noteRawCommit,
|
|
9
|
+
rawGitPost,
|
|
10
|
+
rawGitPre,
|
|
11
|
+
settlePendingCommit,
|
|
12
|
+
} from "./raw-git";
|
|
5
13
|
import { shellWrites, writeGate } from "./write-gate";
|
|
6
14
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
7
15
|
|
|
@@ -74,13 +82,32 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
74
82
|
return text ? { kind: "context", text } : NONE;
|
|
75
83
|
}
|
|
76
84
|
case "shell.pre": {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
85
|
+
const canDeny = usable(descriptor.shellPolicy.deny);
|
|
86
|
+
const policy = canDeny ? shellPolicy(input.cwd, event.command) : NONE;
|
|
87
|
+
if (policy.kind !== "none") return policy;
|
|
88
|
+
// Without a post-tool event, the previous raw commit is recorded now.
|
|
89
|
+
const postTool = usable(descriptor.events["shell.post"].support);
|
|
90
|
+
if (!postTool) settlePendingCommit(input);
|
|
91
|
+
const raw = canDeny ? rawGitPre(input, event.command) : NONE;
|
|
92
|
+
if (raw.kind === "deny") return raw;
|
|
93
|
+
const writes = usable(descriptor.events["write.pre"].support)
|
|
94
|
+
? shellWrites(event.command)
|
|
95
|
+
: { writes: false, targets: null };
|
|
96
|
+
const gate = writes.writes ? writeGate(input.cwd, writes.targets) : NONE;
|
|
97
|
+
if (gate.kind === "deny") return gate;
|
|
98
|
+
// The command will run: note HEAD so the post-tool hook (or the next
|
|
99
|
+
// command, without one) can tell whether a raw commit moved it.
|
|
100
|
+
noteRawCommit(
|
|
101
|
+
input,
|
|
102
|
+
event.command,
|
|
103
|
+
postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
|
|
104
|
+
);
|
|
105
|
+
return raw;
|
|
83
106
|
}
|
|
107
|
+
case "shell.post":
|
|
108
|
+
if (usable(descriptor.events["shell.post"].support))
|
|
109
|
+
rawGitPost(input, event.command, callKey(event.toolUseId, event.command));
|
|
110
|
+
return NONE;
|
|
84
111
|
case "write.pre":
|
|
85
112
|
return usable(descriptor.events["write.pre"].support)
|
|
86
113
|
? writeGate(input.cwd, event.paths)
|
|
@@ -99,7 +126,6 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
99
126
|
// Host permission policy owns other tools; attestation, prompt and stop
|
|
100
127
|
// control arrive with the CLI-observed evidence model.
|
|
101
128
|
case "tool.pre":
|
|
102
|
-
case "shell.post":
|
|
103
129
|
case "subagent.stop":
|
|
104
130
|
case "prompt.submit":
|
|
105
131
|
case "stop":
|