@brainervirus/workit-core 5.0.0 → 6.0.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.
@@ -1,2441 +0,0 @@
1
- import {
2
- branchPolicySnapshotFor,
3
- branchSetup,
4
- classifyBranchDirt,
5
- commitPolicySnapshotFor,
6
- resolveBranchPolicyFor,
7
- validateBranchNamePolicy,
8
- validateCommitMessagePolicy,
9
- } from "./branch";
10
- import {
11
- hostingCliAvailable,
12
- mergePr,
13
- prCreate,
14
- pushRemote,
15
- pushRemoteIdentity,
16
- pushTargetIsStable,
17
- safePushUrl,
18
- } from "./pr-create";
19
- import {
20
- changelogContext,
21
- docsRefreshContext,
22
- isSafeContextRange,
23
- prReadyContext,
24
- releaseNotesContext,
25
- } from "./repo-context";
26
- import { gitContext } from "./git";
27
- import { fetchGitHubIssueBody, fetchGitLabIssueBody } from "./tracker-issues";
28
- import { defaultOperations, ISSUE_RE, logTimeUpdate, postUpdate } from "./youtrack-tools";
29
- import {
30
- context as youTrackContext,
31
- fetchYouTrackIssueBody,
32
- youTrackConfigPath,
33
- youTrackRequest,
34
- youTrackToken,
35
- youTrackWorkDateMs,
36
- } from "./youtrack";
37
- import fs from "node:fs";
38
- import path from "node:path";
39
- import { spawnSync } from "node:child_process";
40
- import {
41
- canonicalJson,
42
- failure,
43
- success,
44
- sha256,
45
- type Caller,
46
- type Owner,
47
- type Result,
48
- type Ref,
49
- } from "./task-contract";
50
- import { changelogApply, changelogApplyPreview } from "./changelog";
51
- import type { ExternalActionRequest } from "./external-action";
52
- import {
53
- branchSetupIntent,
54
- externalActionDescriptor,
55
- externalActionRequest,
56
- } from "./external-action";
57
- import { normalizeChainSteps } from "./authority";
58
- import { resolveInside, run as coreRun } from "../core";
59
- import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
60
- import { deleteRemoteBranch } from "../git/ops";
61
- import { assertProductWriteAllowed, currentWriterOwnsTask } from "./workers";
62
- import { sameDirectoryIdentity, TaskStore } from "./task-store";
63
-
64
- const run = (root: string, args: string[]) => {
65
- return coreRun(root, "git", args);
66
- };
67
-
68
- const gitValue = (root: string, args: string[]): string | null => {
69
- const result = run(root, args);
70
- return result.exitCode === 0 && result.stdout.trim() ? result.stdout.trim() : null;
71
- };
72
-
73
- const gitRaw = (root: string, args: string[]): string | null => {
74
- const result = run(root, args);
75
- return result.exitCode === 0 ? result.stdout : null;
76
- };
77
-
78
- /** Return every staged path, including both sides of renames/copies. */
79
- const stagedPaths = (root: string): string[] | null => {
80
- const raw = gitRaw(root, ["diff", "--cached", "--name-status", "-z", "--find-renames", "HEAD"]);
81
- if (raw === null) return null;
82
- const tokens = raw.split("\0");
83
- const paths: string[] = [];
84
- for (let index = 0; index < tokens.length;) {
85
- const status = tokens[index++];
86
- if (!status) continue;
87
- const rename = /^(?:R|C)\d{3}$/.test(status);
88
- const count = rename ? 2 : 1;
89
- for (let offset = 0; offset < count; offset += 1) {
90
- const filePath = tokens[index++];
91
- if (!filePath) return null;
92
- paths.push(filePath);
93
- }
94
- }
95
- return [...new Set(paths)];
96
- };
97
-
98
- type RemoteParts = { host: string; path: string; protocol: string };
99
-
100
- const remoteParts = (value: string): RemoteParts | null => {
101
- const raw = value.trim();
102
- if (!raw) return null;
103
- try {
104
- if (!raw.includes("://")) {
105
- const match = /^(?:[^@\s]+@)?([^:]+):(.+)$/.exec(raw);
106
- if (!match) return null;
107
- return {
108
- host: match[1].toLowerCase(),
109
- path: `/${match[2]}`.replace(/\/$/, ""),
110
- protocol: "ssh:",
111
- };
112
- }
113
- const url = new URL(raw);
114
- return {
115
- host: url.hostname.toLowerCase(),
116
- path: url.pathname.replace(/\/$/, ""),
117
- protocol: url.protocol,
118
- };
119
- } catch {
120
- return null;
121
- }
122
- };
123
-
124
- const credentialFreeRemote = (value: string): string | null => pushRemoteIdentity(value);
125
-
126
- /** The task root stores coordination; a Git effect runs in the checkout named by the action. */
127
- const actionRoot = (taskRoot: string, request: ExternalActionRequest): Result<string> => {
128
- const chosen = "cwd" in request.payload ? request.payload.cwd : undefined;
129
- try {
130
- const directory = fs.realpathSync(chosen ? path.resolve(taskRoot, chosen) : taskRoot);
131
- if (request.operation === "context.read") return success(null, null, directory);
132
- const gitAction =
133
- request.operation.startsWith("git.") || request.operation.startsWith("hosting.");
134
- if (!gitAction) return success(null, null, directory);
135
- const checkout = gitValue(directory, ["rev-parse", "--show-toplevel"]);
136
- if (!checkout)
137
- return failure("capability_unavailable", "action target is not a Git repository", {
138
- outcome: "not_started",
139
- });
140
- return success(null, null, fs.realpathSync(checkout));
141
- } catch {
142
- return failure("invalid_input", "action target directory cannot be resolved", {
143
- outcome: "not_started",
144
- });
145
- }
146
- };
147
-
148
- const bindsActionTarget = (operation: ExternalActionRequest["operation"]): boolean =>
149
- operation === "context.read" || operation.startsWith("git.") || operation.startsWith("hosting.");
150
-
151
- const unknown = (operation: string) =>
152
- failure("external_outcome_unknown", "external action outcome is unknown", { operation });
153
-
154
- type ContextReadPayload = Extract<ExternalActionRequest, { operation: "context.read" }>["payload"];
155
-
156
- /** Read one fixed repository/provider context without approval or mutation. */
157
- export const readExternalContext = async (
158
- root: string,
159
- payload: ContextReadPayload,
160
- ): Promise<Result<unknown>> => {
161
- if (payload.range !== undefined && !isSafeContextRange(payload.range))
162
- return failure("invalid_input", "revision range is invalid", {
163
- fields: [{ path: "range", reason: "option-like or control characters are not allowed" }],
164
- });
165
- const render = (result: { stdout: string; stderr: string; exitCode: number; cwd: string }) =>
166
- result.exitCode === 0
167
- ? success(null, null, { kind: payload.kind, context: result.stdout })
168
- : failure("capability_unavailable", "requested context is unavailable", {
169
- capability: payload.kind,
170
- });
171
- switch (payload.kind) {
172
- case "git": {
173
- const context = gitContext(root);
174
- return typeof context.exitCode === "number" && context.exitCode !== 0
175
- ? failure("capability_unavailable", "Git context is unavailable", { capability: "git" })
176
- : success(null, null, { kind: payload.kind, context });
177
- }
178
- case "pr":
179
- return render(prReadyContext(root, payload.range));
180
- case "changelog":
181
- return render(changelogContext(root, payload.range));
182
- case "release":
183
- return render(releaseNotesContext(root, payload.range ?? "HEAD~1...HEAD"));
184
- case "affected":
185
- return render(docsRefreshContext(root, payload.range));
186
- case "youtrack": {
187
- const value = youTrackContext({
188
- workspace_root: root,
189
- ...(payload.specPath ? { spec_path: payload.specPath } : {}),
190
- ...(payload.planPath ? { plan_path: payload.planPath } : {}),
191
- ...(payload.issueId ? { issue_id: payload.issueId } : {}),
192
- ...(payload.issueUrl ? { issue_url: payload.issueUrl } : {}),
193
- ...(payload.issueRef ? { issue_ref: payload.issueRef } : {}),
194
- ...(payload.mode ? { mode: payload.mode } : {}),
195
- });
196
- if (value && typeof value === "object" && "error" in value)
197
- return failure("capability_unavailable", "YouTrack context is unavailable", {
198
- capability: "youtrack",
199
- });
200
- if (value.requiresMeetingChoice || !value.issueId)
201
- return success(null, null, { kind: payload.kind, context: value });
202
- const body = await fetchYouTrackIssueBody(value.issueId);
203
- if ("error" in body && body.kind === "request")
204
- return failure("capability_unavailable", "YouTrack issue body is unavailable", {
205
- capability: "youtrack",
206
- });
207
- return success(null, null, {
208
- kind: payload.kind,
209
- context: {
210
- ...value,
211
- ...("data" in body
212
- ? { issueBody: body.data }
213
- : { issueBody: null, issueBodyError: body.error }),
214
- },
215
- });
216
- }
217
- case "github_issue": {
218
- const ref = payload.issueId ?? payload.issueUrl ?? payload.issueRef;
219
- if (!ref)
220
- return failure("invalid_input", "github_issue needs issueId, issueUrl, or issueRef");
221
- const body = await fetchGitHubIssueBody(ref, root);
222
- if ("error" in body)
223
- return failure("capability_unavailable", `GitHub issue is unavailable: ${body.error}`, {
224
- capability: "github_issue",
225
- });
226
- return success(null, null, { kind: payload.kind, context: { issueBody: body.data } });
227
- }
228
- case "gitlab_issue": {
229
- const ref = payload.issueId ?? payload.issueUrl ?? payload.issueRef;
230
- if (!ref)
231
- return failure("invalid_input", "gitlab_issue needs issueId, issueUrl, or issueRef");
232
- const body = await fetchGitLabIssueBody(ref, root);
233
- if ("error" in body)
234
- return failure("capability_unavailable", `GitLab issue is unavailable: ${body.error}`, {
235
- capability: "gitlab_issue",
236
- });
237
- return success(null, null, { kind: payload.kind, context: { issueBody: body.data } });
238
- }
239
- }
240
- };
241
-
242
- const localAction = (operation: ExternalActionRequest["operation"]): boolean =>
243
- operation === "git.branch_setup" ||
244
- operation === "git.commit" ||
245
- operation === "git.push" ||
246
- operation === "hosting.pull_request" ||
247
- operation === "hosting.merge" ||
248
- operation === "hosting.delete_branch" ||
249
- operation === "changelog.apply";
250
-
251
- const targetWriterActionLease = Symbol("target-writer-action-lease");
252
-
253
- export const assertLocalExternalActionWriter = (
254
- root: string,
255
- caller: { host: Caller["host"]; actor: string },
256
- paths: string[] = ["."],
257
- expectedWorkspaceRevision?: string,
258
- ): Result<Owner> => {
259
- const store = new TaskStore(root);
260
- const listed = store.listTasks();
261
- const workspace = store.readWorkspace();
262
- if (!listed.ok) return listed;
263
- if (!workspace.ok) return workspace;
264
- if (!workspace.data) return failure("not_found", "workspace not found");
265
- if (
266
- expectedWorkspaceRevision !== undefined &&
267
- workspace.data.revision !== expectedWorkspaceRevision
268
- )
269
- return failure("revision_conflict", "checkout writer generation changed before the effect", {
270
- expectedWorkspaceRevision,
271
- actualWorkspaceRevision: workspace.data.revision,
272
- });
273
- const candidates = listed.data.filter((task) =>
274
- currentWriterOwnsTask(task, workspace.data!, caller),
275
- );
276
- if (candidates.length !== 1)
277
- return failure("permission_denied", "local external action requires exactly one active task");
278
- const allowed = assertProductWriteAllowed({
279
- task: candidates[0],
280
- workspace: workspace.data,
281
- caller: { host: caller.host, actor: caller.actor, workerId: null },
282
- paths,
283
- store,
284
- });
285
- return allowed;
286
- };
287
-
288
- const safePath = (root: string, value: string): string | null => {
289
- try {
290
- return resolveInside(root, value);
291
- } catch {
292
- return null;
293
- }
294
- };
295
-
296
- /** Read-only baseline bound into a branch-setup proposal: the exact remote
297
- * commit the base currently points at, without moving any local ref. */
298
- const branchCreationBaseline = (
299
- root: string,
300
- target: string,
301
- base: string,
302
- ): {
303
- base_branch: string;
304
- target_exists: boolean;
305
- local_base: string | null;
306
- remote_base: string | null;
307
- target_head: string | null;
308
- } => {
309
- const exists =
310
- gitValue(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]) !== null;
311
- if (exists)
312
- return {
313
- base_branch: base,
314
- target_exists: true,
315
- local_base: null,
316
- remote_base: null,
317
- target_head: gitValue(root, ["rev-parse", `refs/heads/${target}`]),
318
- };
319
- const remote = run(root, ["ls-remote", "origin", `refs/heads/${base}`]);
320
- const sha = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : "";
321
- const remoteBase = /^[0-9a-f]{40}$/.test(sha ?? "") ? sha : null;
322
- return {
323
- base_branch: base,
324
- target_exists: false,
325
- local_base: gitValue(root, ["rev-parse", "--verify", `refs/heads/${base}`]),
326
- remote_base: remoteBase,
327
- target_head: remoteBase,
328
- };
329
- };
330
-
331
- const shortSha = (value: unknown): string =>
332
- typeof value === "string" && value ? value.slice(0, 8) : "unknown";
333
-
334
- /** Concise, user-facing approval text for one resolved action. The exact
335
- * descriptor stays machine-facing; this is what the native question shows. */
336
- const actionProposalAtRoot = (
337
- request: ExternalActionRequest,
338
- descriptorPayload: unknown,
339
- ): { presented: string; approvedText: string } => {
340
- const payload = (descriptorPayload ?? {}) as Record<string, unknown>;
341
- const resolved = (payload.resolved ?? {}) as Record<string, unknown>;
342
- const payloadTarget = String(payload.target_branch ?? "");
343
- if (request.operation === "git.commit" && Array.isArray(payload.plan_steps)) {
344
- const steps = payload.plan_steps as unknown[];
345
- const chain = normalizeChainSteps(steps);
346
- if (!chain)
347
- return {
348
- presented: `Workit decision: action — Approve committing the plan's listed tasks?`,
349
- approvedText: `Approve the listed plan commits.`,
350
- };
351
- const kinds = chain.map((step) => step.kind);
352
- if (kinds.every((kind) => kind === "commit"))
353
- return {
354
- presented: `Workit decision: action — Approve committing the plan's ${steps.length} listed tasks on \`${String(payload.plan_branch ?? "")}\`?`,
355
- approvedText: `Approve the ${steps.length} listed plan commits.`,
356
- };
357
- const summary = [
358
- kinds.filter((kind) => kind === "branch").length
359
- ? `${kinds.filter((kind) => kind === "branch").length} branch`
360
- : null,
361
- kinds.filter((kind) => kind === "commit").length
362
- ? `${kinds.filter((kind) => kind === "commit").length} commits`
363
- : null,
364
- kinds.includes("pr") ? "PR" : null,
365
- ]
366
- .filter(Boolean)
367
- .join(", ");
368
- return {
369
- presented: `Workit decision: action — Approve the ${steps.length}-step chain on \`${String(payload.plan_branch ?? "")}\` (${summary})?`,
370
- approvedText: `Approve the ${steps.length}-step chain (${summary}).`,
371
- };
372
- }
373
- switch (request.operation) {
374
- case "git.branch_setup": {
375
- if (request.payload.action === "reapply_stash")
376
- return {
377
- presented:
378
- "Workit decision: action — Reapply the pre-checkout stash recorded in the branch manifest?",
379
- approvedText: "Reapply the recorded stash.",
380
- };
381
- const target = String(payload.target_branch ?? "");
382
- // A dirty tree binds stash: the approval carries the stash through,
383
- // so the question names it instead of failing later at preflight.
384
- const stashes = payload.stash === "yes" && resolved.dirty === true;
385
- if (resolved.target_exists === true)
386
- return stashes
387
- ? {
388
- presented: `Workit decision: action — Stash the dirty working tree and switch to branch \`${target}\`?`,
389
- approvedText: `Stash and switch to \`${target}\`.`,
390
- }
391
- : {
392
- presented: `Workit decision: action — Switch to branch \`${target}\`?`,
393
- approvedText: `Switch to \`${target}\`.`,
394
- };
395
- const base = String(resolved.base_branch ?? "the base branch");
396
- const localBase =
397
- typeof resolved.local_base === "string"
398
- ? `local ${shortSha(resolved.local_base)}`
399
- : "no local base";
400
- const remoteBase = shortSha(resolved.remote_base);
401
- return stashes
402
- ? {
403
- presented: `Workit decision: action — Stash and create branch \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase})?`,
404
- approvedText: `Stash and create \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase}).`,
405
- }
406
- : {
407
- presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase})?`,
408
- approvedText: `Create \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase}).`,
409
- };
410
- }
411
- case "git.commit": {
412
- const paths = Array.isArray(resolved.paths) ? (resolved.paths as string[]) : [];
413
- return {
414
- presented: `Workit decision: action — Commit ${paths.length} staged file(s) as "${String(request.payload.message)}"?`,
415
- approvedText: `Commit as "${String(request.payload.message)}".`,
416
- };
417
- }
418
- case "git.push":
419
- return {
420
- presented: `Workit decision: action — Push branch \`${String(payload.branch ?? "")}\` to \`${String(resolved.remote ?? "unknown remote")}\` via \`${String(resolved.apiHost ?? "Git transport")}\`${resolved.account ? ` as ${String(resolved.account)}` : ""}?`,
421
- approvedText: `Push \`${String(payload.branch ?? "")}\` to ${String(resolved.remote ?? "unknown remote")} via ${String(resolved.apiHost ?? "Git transport")}${resolved.account ? ` as ${String(resolved.account)}` : ""}.`,
422
- };
423
- case "hosting.pull_request": {
424
- const sourceCommit = shortSha(resolved.source_commit);
425
- const mergeBase =
426
- typeof resolved.merge_base_commit === "string"
427
- ? ` on local merge base ${shortSha(resolved.merge_base_commit)}`
428
- : "";
429
- return {
430
- presented: `Workit decision: action — Open a PR in \`${String(resolved.remote ?? "unknown remote")}\` via \`${String(resolved.apiHost ?? "unknown host")}\`${resolved.account ? ` as ${String(resolved.account)}` : ""} from \`${String(resolved.source_branch ?? "")}\`@${sourceCommit} to \`${payloadTarget}\`${mergeBase} titled "${String(request.payload.title)}"?`,
431
- approvedText: `Open the PR in ${String(resolved.remote ?? "unknown remote")} via ${String(resolved.apiHost ?? "unknown host")}${resolved.account ? ` as ${String(resolved.account)}` : ""}: ${String(resolved.source_branch ?? "")}@${sourceCommit} → ${payloadTarget}${mergeBase}, "${String(request.payload.title)}".`,
432
- };
433
- }
434
- case "hosting.merge":
435
- return {
436
- presented: `Workit decision: action — Merge \`${String(resolved.source_branch ?? "")}\` into \`${payloadTarget}\` in \`${String(resolved.remote ?? "unknown remote")}\` via \`${String(resolved.apiHost ?? "unknown host")}\`${resolved.account ? ` as ${String(resolved.account)}` : ""} (squash, delete branch)?`,
437
- approvedText: `Merge ${String(resolved.source_branch ?? "")} → ${payloadTarget} in ${String(resolved.remote ?? "unknown remote")} via ${String(resolved.apiHost ?? "unknown host")}${resolved.account ? ` as ${String(resolved.account)}` : ""}.`,
438
- };
439
- case "hosting.delete_branch":
440
- return {
441
- presented: `Workit decision: action — Delete merged branch \`${String(payload.branch ?? "")}\` from \`${String(resolved.remote ?? "")}\` via \`${String(resolved.apiHost ?? "unknown host")}\` as \`${String(resolved.account ?? "unknown account")}\` at ${shortSha(resolved.tip)} (PR/MR #${String(resolved.pr ?? "")})?`,
442
- approvedText: `Delete merged branch ${String(payload.branch ?? "")} from ${String(resolved.remote ?? "unknown remote")} via ${String(resolved.apiHost ?? "unknown host")} as ${String(resolved.account ?? "unknown account")}.`,
443
- };
444
- case "changelog.apply":
445
- return {
446
- presented: `Workit decision: action — Apply changelog changes at \`${String(payload.path ?? "CHANGELOG.md")}\`?`,
447
- approvedText: `Apply changelog changes at ${String(payload.path ?? "CHANGELOG.md")}.`,
448
- };
449
- case "youtrack.update":
450
- return {
451
- presented: `Workit decision: action — Update YouTrack issue ${String(request.payload.issueId)}${request.payload.minutes ? ` and log ${String(request.payload.minutes)}m` : ""}?`,
452
- approvedText: `Update YouTrack issue ${String(request.payload.issueId)}.`,
453
- };
454
- case "youtrack.time":
455
- return {
456
- presented: `Workit decision: action — Log ${String(request.payload.minutes)}m to YouTrack issue ${String(request.payload.issueId)}?`,
457
- approvedText: `Log time to YouTrack issue ${String(request.payload.issueId)}.`,
458
- };
459
- case "youtrack.meeting":
460
- return {
461
- presented: `Workit decision: action — Log meeting time to YouTrack issue ${String(request.payload.issueId)}?`,
462
- approvedText: `Log meeting time to YouTrack issue ${String(request.payload.issueId)}.`,
463
- };
464
- default:
465
- return {
466
- presented: `Workit decision: action — Approve ${request.operation}?`,
467
- approvedText: `Approve ${request.operation}.`,
468
- };
469
- }
470
- };
471
-
472
- export const actionProposalQuestion = (
473
- request: ExternalActionRequest,
474
- descriptorPayload: unknown,
475
- ): { presented: string; approvedText: string } => {
476
- const proposal = actionProposalAtRoot(request, descriptorPayload);
477
- const cwd = (descriptorPayload as { cwd?: unknown })?.cwd;
478
- return typeof cwd === "string"
479
- ? { ...proposal, presented: `${proposal.presented} Target directory: ${cwd}` }
480
- : proposal;
481
- };
482
-
483
- export type WorkDateInfo = {
484
- localDate: string;
485
- timezone: string;
486
- timezoneSource: "youtrack.json" | "process";
487
- };
488
-
489
- export type ResolvedExternalAction = {
490
- request: ExternalActionRequest;
491
- descriptorPayload: unknown;
492
- marker?: string;
493
- /** Display-only YouTrack work-item date context. Deliberately outside
494
- * descriptorPayload: the descriptor is the hashed approval binding and must
495
- * not depend on the process timezone of whoever resolves it. */
496
- workDate?: WorkDateInfo;
497
- };
498
-
499
- const workDateOf = (data: WorkDateInfo): WorkDateInfo => ({
500
- localDate: data.localDate,
501
- timezone: data.timezone,
502
- timezoneSource: data.timezoneSource,
503
- });
504
-
505
- export type HostingReadEvidence = {
506
- outcome: "succeeded" | "unknown";
507
- evidenceDigest: string;
508
- observation: object;
509
- data?: { provider: "github" | "gitlab" | "youtrack"; id: string | number };
510
- step?: string;
511
- };
512
-
513
- /** Rebuild the originally approved hosting target from persisted descriptor bytes. */
514
- export const approvedResolvedExternalAction = (content: string): Result<ResolvedExternalAction> => {
515
- try {
516
- const value = JSON.parse(content) as { operation?: unknown; payload?: unknown };
517
- if (
518
- value.operation !== "git.branch_setup" &&
519
- value.operation !== "git.commit" &&
520
- value.operation !== "git.push" &&
521
- value.operation !== "hosting.pull_request" &&
522
- value.operation !== "hosting.merge" &&
523
- value.operation !== "hosting.delete_branch" &&
524
- value.operation !== "changelog.apply" &&
525
- value.operation !== "youtrack.update" &&
526
- value.operation !== "youtrack.time" &&
527
- value.operation !== "youtrack.meeting"
528
- )
529
- return failure("invalid_input", "approved action is not a reconciliable external action");
530
- if (!value.payload || typeof value.payload !== "object")
531
- return failure("invalid_input", "approved action payload is incomplete");
532
- const payload = { ...(value.payload as Record<string, unknown>) };
533
- const resolved = payload.resolved;
534
- delete payload.resolved;
535
- const request = externalActionRequest({ operation: value.operation, payload });
536
- if (!request.ok || !resolved || typeof resolved !== "object")
537
- return failure("invalid_input", "approved hosting target is incomplete");
538
- const target = resolved as Record<string, unknown>;
539
- if (
540
- value.operation === "git.commit" &&
541
- (typeof target.head !== "string" ||
542
- typeof target.staged !== "string" ||
543
- typeof payload.message !== "string")
544
- )
545
- return failure("invalid_input", "approved Git commit target is incomplete");
546
- if (
547
- value.operation === "git.push" &&
548
- (typeof target.branch !== "string" ||
549
- typeof target.commit !== "string" ||
550
- typeof target.remote !== "string")
551
- )
552
- return failure("invalid_input", "approved Git push target is incomplete");
553
- if (
554
- value.operation === "git.branch_setup" &&
555
- (typeof target.head !== "string" ||
556
- (payload.action === "reapply_stash"
557
- ? typeof target.stash_commit !== "string"
558
- : typeof payload.target_branch !== "string" || typeof target.target_head !== "string"))
559
- )
560
- return failure("invalid_input", "approved Git branch target is incomplete");
561
- if (
562
- value.operation === "hosting.pull_request" &&
563
- (typeof target.marker !== "string" ||
564
- typeof target.source_branch !== "string" ||
565
- typeof target.source_commit !== "string" ||
566
- typeof target.remote !== "string" ||
567
- typeof payload.target_branch !== "string")
568
- )
569
- return failure("invalid_input", "approved hosting target is incomplete");
570
- if (
571
- value.operation === "hosting.merge" &&
572
- (typeof target.marker !== "string" ||
573
- typeof target.source_branch !== "string" ||
574
- typeof target.source_commit !== "string" ||
575
- typeof target.remote !== "string" ||
576
- typeof payload.target_branch !== "string")
577
- )
578
- return failure("invalid_input", "approved hosting target is incomplete");
579
- if (
580
- value.operation === "hosting.delete_branch" &&
581
- (typeof target.tip !== "string" ||
582
- typeof target.remote !== "string" ||
583
- typeof target.pr !== "string")
584
- )
585
- return failure("invalid_input", "approved branch deletion target is incomplete");
586
- if (
587
- value.operation === "changelog.apply" &&
588
- (typeof target.target !== "string" ||
589
- typeof target.beforeDigest !== "string" ||
590
- typeof target.beforeExists !== "boolean" ||
591
- typeof target.afterDigest !== "string")
592
- )
593
- return failure("invalid_input", "approved changelog target is incomplete");
594
- if (
595
- value.operation !== "git.branch_setup" &&
596
- value.operation !== "git.commit" &&
597
- value.operation !== "git.push" &&
598
- value.operation !== "hosting.pull_request" &&
599
- value.operation !== "hosting.merge" &&
600
- value.operation !== "hosting.delete_branch" &&
601
- value.operation !== "changelog.apply" &&
602
- (typeof target.marker !== "string" ||
603
- typeof target.baseUrl !== "string" ||
604
- typeof target.issueId !== "string" ||
605
- !Array.isArray(target.steps))
606
- )
607
- return failure("invalid_input", "approved YouTrack target is incomplete");
608
- return success(null, null, {
609
- request: request.data,
610
- descriptorPayload: value.payload,
611
- ...(typeof target.marker === "string" ? { marker: target.marker } : {}),
612
- });
613
- } catch {
614
- return failure("invalid_input", "approved action descriptor is invalid");
615
- }
616
- };
617
-
618
- const remoteRepo = (value: string): string | null => {
619
- const parts = remoteParts(value);
620
- if (!parts) return null;
621
- const repo = parts.path.replace(/^\//, "").replace(/\.git$/, "");
622
- return repo.includes("/") ? repo : null;
623
- };
624
-
625
- const remoteIdentity = (value: string): string | null => pushRemoteIdentity(value);
626
-
627
- const remoteProvider = (value: string): "github" | "gitlab" | null => {
628
- const host = remoteParts(value)?.host;
629
- return host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null;
630
- };
631
-
632
- const hostingCliEnv = (provider: "github" | "gitlab", host: string): NodeJS.ProcessEnv => ({
633
- ...process.env,
634
- PATH: process.env.PATH ?? "",
635
- ...(provider === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
636
- });
637
-
638
- const hostingProvider = (root: string, remote: string): "github" | "gitlab" | null => {
639
- const known = remoteProvider(remote);
640
- if (known) return known;
641
- const parts = remoteParts(remote);
642
- if (!parts?.host || (parts.protocol !== "ssh:" && parts.protocol !== "https:")) return null;
643
- const cfg = vcsConfig("load", root);
644
- return cfg.ok && (cfg.provider === "github" || cfg.provider === "gitlab") ? cfg.provider : null;
645
- };
646
-
647
- const remoteBranchTip = (
648
- root: string,
649
- provider: "github" | "gitlab",
650
- host: string,
651
- repo: string,
652
- branch: string,
653
- ): Result<string | null> => {
654
- const endpoint =
655
- provider === "github"
656
- ? `repos/${repo}/git/ref/heads/${branch.split("/").map(encodeURIComponent).join("/")}`
657
- : `projects/${encodeURIComponent(repo)}/repository/branches/${encodeURIComponent(branch)}`;
658
- const response = spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
659
- cwd: root,
660
- encoding: "utf8",
661
- env: hostingCliEnv(provider, host),
662
- });
663
- if (response.status !== 0) {
664
- if (/\b404\b/.test(response.stderr ?? "")) {
665
- const project =
666
- provider === "github" ? `repos/${repo}` : `projects/${encodeURIComponent(repo)}`;
667
- const visible = spawnSync(provider === "github" ? "gh" : "glab", ["api", project], {
668
- cwd: root,
669
- encoding: "utf8",
670
- env: hostingCliEnv(provider, host),
671
- });
672
- try {
673
- const record = JSON.parse(visible.stdout ?? "") as { id?: unknown };
674
- if (visible.status === 0 && typeof record.id === "number") return success(null, null, null);
675
- } catch {}
676
- }
677
- return failure("capability_unavailable", "remote branch could not be read", {
678
- outcome: "not_started",
679
- });
680
- }
681
- try {
682
- const parsed = JSON.parse(response.stdout ?? "") as Record<string, any>;
683
- const tip = provider === "github" ? parsed.object?.sha : parsed.commit?.id;
684
- return typeof tip === "string" && /^[a-f0-9]{40,64}$/.test(tip)
685
- ? success(null, null, tip)
686
- : failure("capability_unavailable", "remote branch response is incomplete", {
687
- outcome: "not_started",
688
- });
689
- } catch {
690
- return failure("capability_unavailable", "remote branch response is invalid", {
691
- outcome: "not_started",
692
- });
693
- }
694
- };
695
-
696
- const mergedBranch = (root: string, branch: string): Result<Record<string, string>> => {
697
- if (run(root, ["check-ref-format", "--branch", branch]).exitCode !== 0)
698
- return failure("invalid_input", "branch name is invalid", { outcome: "not_started" });
699
- const remote = pushRemote(root);
700
- const identity = remote ? credentialFreeRemote(remote) : null;
701
- const repo = remote ? remoteRepo(remote) : null;
702
- const provider = remote ? hostingProvider(root, remote) : null;
703
- if (!remote || !identity || !repo || !provider)
704
- return failure("capability_unavailable", "branch remote is unavailable", {
705
- outcome: "not_started",
706
- });
707
- const auth = vcsCliIdentity(root);
708
- if (!auth.ok || auth.provider !== provider)
709
- return failure("capability_unavailable", "hosting CLI identity is unavailable", {
710
- outcome: "not_started",
711
- });
712
- if (!hostingApiHostMatches(remote, auth.host))
713
- return failure(
714
- "capability_unavailable",
715
- "hosting API host does not match the push destination",
716
- {
717
- outcome: "not_started",
718
- },
719
- );
720
- const live = remoteBranchTip(root, provider, auth.host, repo, branch);
721
- if (!live.ok) return live;
722
- const tip = live.data;
723
- if (!tip)
724
- return failure("invalid_input", "remote branch is absent or could not be read", {
725
- outcome: "not_started",
726
- });
727
- const endpoint =
728
- provider === "github"
729
- ? `repos/${repo}/pulls?state=closed&head=${encodeURIComponent(`${repo.split("/")[0]}:${branch}`)}&per_page=100`
730
- : `projects/${encodeURIComponent(repo)}/merge_requests?state=merged&source_branch=${encodeURIComponent(branch)}&per_page=100`;
731
- const response = spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
732
- cwd: root,
733
- encoding: "utf8",
734
- env: hostingCliEnv(provider, auth.host),
735
- });
736
- if (response.status !== 0)
737
- return failure("capability_unavailable", "merged branch evidence is unavailable", {
738
- outcome: "not_started",
739
- });
740
- try {
741
- const records = JSON.parse(response.stdout ?? "") as unknown;
742
- if (!Array.isArray(records) || records.length >= 100) throw new Error("incomplete PR listing");
743
- const match = records.find(
744
- (record) =>
745
- record &&
746
- typeof record === "object" &&
747
- (provider === "github" ? record.head?.sha === tip : record.sha === tip) &&
748
- typeof record.merged_at === "string" &&
749
- (provider === "github" ? record.head?.ref === branch : record.source_branch === branch),
750
- );
751
- const id = match?.number ?? match?.iid;
752
- if (typeof id !== "number") throw new Error("no exact merged head");
753
- return success(null, null, {
754
- branch,
755
- tip,
756
- remote: identity,
757
- provider,
758
- repo,
759
- pr: String(id),
760
- account: String(auth.username),
761
- apiHost: String(auth.host),
762
- });
763
- } catch {
764
- return failure("capability_unavailable", "branch tip does not match a merged PR head", {
765
- outcome: "not_started",
766
- });
767
- }
768
- };
769
-
770
- const unknownHostingEvidence = (operation: string): Result<HostingReadEvidence> =>
771
- failure("external_outcome_unknown", "hosting action outcome remains unknown", {
772
- operation,
773
- outcome: "unknown",
774
- });
775
-
776
- /** Fixed, read-only GitHub/GitLab lookup; callers must keep the returned observation private. */
777
- export const readHostingAction = async (
778
- root: string,
779
- resolved: ResolvedExternalAction,
780
- actionRef: Ref,
781
- ): Promise<Result<HostingReadEvidence>> => {
782
- const operation = resolved.request.operation;
783
- if ((operation !== "hosting.pull_request" && operation !== "hosting.merge") || !resolved.marker)
784
- return unknownHostingEvidence(operation);
785
- try {
786
- const cfg = vcsConfig("load", root);
787
- if (!cfg.ok || !cfg.provider || (cfg.provider !== "github" && cfg.provider !== "gitlab"))
788
- return unknownHostingEvidence(operation);
789
- const auth = vcsCliIdentity(root);
790
- if (!auth.ok || auth.provider !== cfg.provider) return unknownHostingEvidence(operation);
791
- const remote = pushRemote(root);
792
- const repo = remote ? remoteRepo(remote) : null;
793
- const approved = resolved.descriptorPayload as {
794
- target_branch?: unknown;
795
- resolved?: Record<string, unknown>;
796
- };
797
- const target = approved.target_branch;
798
- const source = approved.resolved;
799
- const branch = source?.source_branch;
800
- const sourceCommit = source?.source_commit;
801
- const approvedRemote = source?.remote;
802
- const currentIdentity = remote ? remoteIdentity(remote) : null;
803
- const approvedIdentity =
804
- typeof approvedRemote === "string" ? remoteIdentity(approvedRemote) : null;
805
- if (
806
- !repo ||
807
- typeof branch !== "string" ||
808
- typeof target !== "string" ||
809
- typeof sourceCommit !== "string" ||
810
- typeof approvedRemote !== "string" ||
811
- !currentIdentity ||
812
- currentIdentity !== approvedIdentity ||
813
- repo !== remoteRepo(approvedRemote) ||
814
- hostingProvider(root, approvedRemote) !== cfg.provider ||
815
- !hostingApiHostMatches(approvedRemote, auth.host) ||
816
- (typeof source?.apiHost === "string" && source.apiHost !== auth.host)
817
- )
818
- return unknownHostingEvidence(operation);
819
- const endpoint =
820
- cfg.provider === "github"
821
- ? `repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${repo.split("/")[0]}:${branch}`)}&base=${encodeURIComponent(target)}&per_page=100&page=1`
822
- : `projects/${encodeURIComponent(repo)}/merge_requests?state=all&source_branch=${encodeURIComponent(branch)}&target_branch=${encodeURIComponent(target)}&per_page=100&page=1`;
823
- const cli = cfg.provider === "github" ? "gh" : "glab";
824
- const response = spawnSync(cli, ["api", endpoint], {
825
- cwd: root,
826
- encoding: "utf8",
827
- env: hostingCliEnv(cfg.provider, auth.host),
828
- });
829
- if (response.status !== 0) return unknownHostingEvidence(operation);
830
- const values: unknown = JSON.parse(response.stdout ?? "");
831
- // A full page is intentionally treated as truncated until pagination is added.
832
- if (!Array.isArray(values) || values.length >= 100) return unknownHostingEvidence(operation);
833
- const records: Record<string, unknown>[] = [];
834
- for (const item of values) {
835
- if (!item || typeof item !== "object" || Array.isArray(item))
836
- return unknownHostingEvidence(operation);
837
- const record = item as Record<string, unknown>;
838
- const body = record.body ?? record.description;
839
- const base = record.base;
840
- const head = record.head;
841
- const githubShape =
842
- cfg.provider === "github" &&
843
- base !== null &&
844
- typeof base === "object" &&
845
- !Array.isArray(base) &&
846
- head !== null &&
847
- typeof head === "object" &&
848
- !Array.isArray(head) &&
849
- typeof (base as Record<string, unknown>).ref === "string" &&
850
- typeof (head as Record<string, unknown>).ref === "string" &&
851
- typeof (head as Record<string, unknown>).sha === "string";
852
- const gitlabShape =
853
- cfg.provider === "gitlab" &&
854
- typeof record.target_branch === "string" &&
855
- typeof record.source_branch === "string" &&
856
- typeof record.sha === "string";
857
- const targetValue =
858
- cfg.provider === "github" && githubShape
859
- ? (base as Record<string, unknown>).ref
860
- : record.target_branch;
861
- const sourceValue =
862
- cfg.provider === "github" && githubShape
863
- ? (head as Record<string, unknown>).ref
864
- : record.source_branch;
865
- const shaValue =
866
- cfg.provider === "github" && githubShape
867
- ? (head as Record<string, unknown>).sha
868
- : record.sha;
869
- const idValue = record.number ?? record.iid ?? record.id;
870
- if (
871
- (body !== undefined && body !== null && typeof body !== "string") ||
872
- (cfg.provider === "github" ? !githubShape : !gitlabShape) ||
873
- typeof targetValue !== "string" ||
874
- typeof sourceValue !== "string" ||
875
- typeof shaValue !== "string" ||
876
- (typeof idValue !== "string" && typeof idValue !== "number")
877
- )
878
- return unknownHostingEvidence(operation);
879
- records.push(record);
880
- }
881
- const matches = records.filter((item) => {
882
- const body = String(item.body ?? item.description ?? "");
883
- const base = item.base as Record<string, unknown> | undefined;
884
- const head = item.head as Record<string, unknown> | undefined;
885
- const itemTarget = cfg.provider === "github" ? base?.ref : item.target_branch;
886
- const itemSource = cfg.provider === "github" ? head?.ref : item.source_branch;
887
- const itemSha = cfg.provider === "github" ? head?.sha : item.sha;
888
- const merged =
889
- cfg.provider === "github"
890
- ? typeof item.merged_at === "string"
891
- : item.state === "merged" || typeof item.merged_at === "string";
892
- return (
893
- (operation === "hosting.merge" ? merged : body.includes(resolved.marker!)) &&
894
- itemTarget === target &&
895
- itemSource === branch &&
896
- itemSha === sourceCommit
897
- );
898
- });
899
- if (
900
- operation === "hosting.merge" &&
901
- records.some((item) => {
902
- const base = item.base as Record<string, unknown> | undefined;
903
- const head = item.head as Record<string, unknown> | undefined;
904
- return (
905
- (cfg.provider === "github" ? base?.ref : item.target_branch) === target &&
906
- (cfg.provider === "github" ? head?.ref : item.source_branch) === branch &&
907
- (cfg.provider === "github"
908
- ? typeof item.merged_at !== "string"
909
- : item.state !== "merged" && typeof item.merged_at !== "string")
910
- );
911
- })
912
- )
913
- return unknownHostingEvidence(operation);
914
- if (matches.length !== 1) return unknownHostingEvidence(operation);
915
- const item = matches[0];
916
- const id = item.number ?? item.iid ?? item.id;
917
- if (typeof id !== "string" && typeof id !== "number") return unknownHostingEvidence(operation);
918
- const evidenceDigest = sha256({
919
- operation,
920
- provider: cfg.provider,
921
- id,
922
- marker: resolved.marker,
923
- target,
924
- branch,
925
- sourceCommit,
926
- });
927
- const observation = {
928
- kind: "provider_read",
929
- actionRef,
930
- outcome: "succeeded" as const,
931
- evidenceDigest,
932
- };
933
- return success(null, null, {
934
- outcome: "succeeded",
935
- evidenceDigest,
936
- observation,
937
- data: { provider: cfg.provider, id },
938
- });
939
- } catch {
940
- return unknownHostingEvidence(operation);
941
- }
942
- };
943
-
944
- const appendMarker = (text: string, marker: string): string =>
945
- `${text}${text ? "\n\n" : ""}${marker}`;
946
-
947
- /** Fixed, read-only YouTrack lookup for comments and work items. */
948
- export const readYouTrackAction = async (
949
- root: string,
950
- resolved: ResolvedExternalAction,
951
- actionRef: Ref,
952
- ): Promise<Result<HostingReadEvidence>> => {
953
- const operation = resolved.request.operation;
954
- if (
955
- operation !== "youtrack.update" &&
956
- operation !== "youtrack.time" &&
957
- operation !== "youtrack.meeting"
958
- )
959
- return unknownHostingEvidence(operation);
960
- try {
961
- const target = resolved.descriptorPayload as { resolved?: Record<string, unknown> };
962
- const details = target.resolved;
963
- const issueId = details?.issueId;
964
- const baseUrl = details?.baseUrl;
965
- const marker = details?.marker;
966
- if (
967
- typeof issueId !== "string" ||
968
- !ISSUE_RE.test(issueId) ||
969
- typeof baseUrl !== "string" ||
970
- typeof marker !== "string"
971
- )
972
- return unknownHostingEvidence(operation);
973
- if (
974
- !details ||
975
- !Array.isArray(details.steps) ||
976
- !details.steps.every((step) => typeof step === "string")
977
- )
978
- return unknownHostingEvidence(operation);
979
- const creds = youTrackToken();
980
- if ("error" in creds || creds.base !== baseUrl) return unknownHostingEvidence(operation);
981
- const bounded = "&$top=100&$skip=0";
982
- const read = async (endpoint: string): Promise<unknown[] | null> => {
983
- const result = await youTrackRequest(`${baseUrl}${endpoint}${bounded}`, {
984
- method: "GET",
985
- token: creds.token,
986
- });
987
- if (result.status !== 0) return null;
988
- try {
989
- const parsed = JSON.parse(result.stdout);
990
- return Array.isArray(parsed) && parsed.length < 100 ? parsed : null;
991
- } catch {
992
- return null;
993
- }
994
- };
995
- const comments =
996
- operation === "youtrack.time"
997
- ? []
998
- : await read(
999
- `/api/issues/${encodeURIComponent(issueId)}/comments?fields=id,text,author(id,login),deleted,created`,
1000
- );
1001
- const workItems =
1002
- (operation === "youtrack.update" && details.steps.includes("time")) ||
1003
- operation === "youtrack.time" ||
1004
- operation === "youtrack.meeting"
1005
- ? await read(
1006
- `/api/issues/${encodeURIComponent(issueId)}/timeTracking/workItems?fields=id,text,author(id),creator(id),duration(minutes),date`,
1007
- )
1008
- : [];
1009
- if (comments === null || workItems === null) return unknownHostingEvidence(operation);
1010
- const commentText = typeof details.commentText === "string" ? details.commentText : null;
1011
- const workText = typeof details.workText === "string" ? details.workText : null;
1012
- const minutes = typeof details.minutes === "number" ? details.minutes : null;
1013
- const dateMs = typeof details.dateMs === "number" ? details.dateMs : null;
1014
- const validComment = (value: unknown): value is Record<string, unknown> => {
1015
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1016
- const item = value as Record<string, unknown>;
1017
- return (
1018
- typeof item.id === "string" &&
1019
- typeof item.text === "string" &&
1020
- (item.deleted === undefined || typeof item.deleted === "boolean") &&
1021
- (item.created === undefined || typeof item.created === "number")
1022
- );
1023
- };
1024
- const validWork = (value: unknown): value is Record<string, unknown> => {
1025
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1026
- const item = value as Record<string, unknown>;
1027
- const duration = item.duration;
1028
- return (
1029
- typeof item.id === "string" &&
1030
- typeof item.text === "string" &&
1031
- !!duration &&
1032
- typeof duration === "object" &&
1033
- !Array.isArray(duration) &&
1034
- typeof (duration as Record<string, unknown>).minutes === "number" &&
1035
- typeof item.date === "number"
1036
- );
1037
- };
1038
- if (
1039
- comments.some((value) => !validComment(value)) ||
1040
- workItems.some((value) => !validWork(value))
1041
- )
1042
- return unknownHostingEvidence(operation);
1043
- const matchingComments =
1044
- commentText === null
1045
- ? []
1046
- : comments.filter(
1047
- (value) =>
1048
- !((value as Record<string, unknown>).deleted === true) &&
1049
- (value as Record<string, unknown>).text === commentText,
1050
- );
1051
- const matchingWork =
1052
- workText === null
1053
- ? []
1054
- : workItems.filter((value) => {
1055
- const item = value as Record<string, unknown>;
1056
- return (
1057
- item.text === workText &&
1058
- (item.duration as Record<string, unknown>).minutes === minutes &&
1059
- item.date === dateMs
1060
- );
1061
- });
1062
- const needComment = commentText !== null;
1063
- // `workText` is also retained for update descriptors so the later time
1064
- // step can use the exact approved text. It is only an expected provider
1065
- // object when the approved workflow actually contains the time step.
1066
- const needWork = details.steps.includes("time") && workText !== null;
1067
- if (needComment && matchingComments.length !== 1) return unknownHostingEvidence(operation);
1068
- // An absent work item is deliberately not evidence that the time step was
1069
- // never applied: the POST may have succeeded before its response was lost.
1070
- // The persisted workflow records which step was attempted; only an exact
1071
- // provider match can reconcile it.
1072
- if (needWork && matchingWork.length !== 1) return unknownHostingEvidence(operation);
1073
- const ids = [...matchingComments, ...matchingWork].map(
1074
- (value) => (value as Record<string, unknown>).id,
1075
- );
1076
- const evidenceDigest = sha256({ operation, issueId, marker, ids, minutes, dateMs });
1077
- const observation = {
1078
- kind: "provider_read",
1079
- actionRef,
1080
- outcome: "succeeded" as const,
1081
- evidenceDigest,
1082
- };
1083
- return success(null, null, {
1084
- outcome: "succeeded",
1085
- evidenceDigest,
1086
- observation,
1087
- data: { provider: "youtrack", id: String(ids[0] ?? "") },
1088
- });
1089
- } catch {
1090
- return unknownHostingEvidence(operation);
1091
- }
1092
- };
1093
-
1094
- export const readLocalAction = async (
1095
- root: string,
1096
- resolved: ResolvedExternalAction,
1097
- actionRef: Ref,
1098
- ): Promise<Result<HostingReadEvidence>> => {
1099
- const operation = resolved.request.operation;
1100
- const target = (resolved.descriptorPayload as { resolved?: Record<string, unknown> }).resolved;
1101
- if (!target) return unknownHostingEvidence(operation);
1102
- let observed: Record<string, unknown> | null = null;
1103
- if (operation === "git.commit") {
1104
- const commit = gitValue(root, ["rev-parse", "HEAD"]);
1105
- const parent = commit ? gitValue(root, ["rev-parse", `${commit}^`]) : null;
1106
- const message = commit ? gitValue(root, ["show", "-s", "--format=%B", commit]) : null;
1107
- const diff =
1108
- commit && parent
1109
- ? gitRaw(root, ["diff", "--raw", "-z", "--no-ext-diff", "--no-textconv", parent, commit])
1110
- : null;
1111
- if (
1112
- commit &&
1113
- parent === target.head &&
1114
- message === resolved.request.payload.message &&
1115
- diff !== null &&
1116
- sha256(diff) === target.staged
1117
- )
1118
- observed = { commit, parent, message, staged: target.staged };
1119
- } else if (operation === "git.push") {
1120
- const branch = target.branch;
1121
- const commit = target.commit;
1122
- const approvedRemote = target.remote;
1123
- const currentRemote = pushRemote(root);
1124
- const currentIdentity = currentRemote ? credentialFreeRemote(currentRemote) : null;
1125
- if (
1126
- typeof branch === "string" &&
1127
- typeof commit === "string" &&
1128
- typeof approvedRemote === "string" &&
1129
- currentIdentity === approvedRemote
1130
- ) {
1131
- const remote = run(root, ["ls-remote", currentRemote!, `refs/heads/${branch}`]);
1132
- const remoteCommit = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : null;
1133
- if (remoteCommit === commit) observed = { branch, commit, remote: approvedRemote };
1134
- }
1135
- } else if (operation === "git.branch_setup") {
1136
- const targetBranch = resolved.request.payload.target_branch;
1137
- const sdd = resolved.request.payload.sdd_dir;
1138
- if (resolved.request.payload.action === "reapply_stash" && typeof sdd === "string") {
1139
- try {
1140
- const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
1141
- stash_ref?: unknown;
1142
- };
1143
- const stashes = gitRaw(root, ["stash", "list", "--format=%H"]);
1144
- if (
1145
- typeof target.stash_commit === "string" &&
1146
- manifest.stash_ref === undefined &&
1147
- stashes !== null &&
1148
- !stashes.split("\n").includes(target.stash_commit)
1149
- )
1150
- observed = { stash_commit: target.stash_commit };
1151
- } catch {}
1152
- } else if (
1153
- typeof targetBranch === "string" &&
1154
- typeof sdd === "string" &&
1155
- gitValue(root, ["branch", "--show-current"]) === targetBranch &&
1156
- typeof target.target_head === "string" &&
1157
- gitValue(root, ["rev-parse", "HEAD"]) === target.target_head
1158
- ) {
1159
- try {
1160
- const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
1161
- branch?: unknown;
1162
- };
1163
- if (manifest.branch === targetBranch) observed = { branch: targetBranch };
1164
- } catch {}
1165
- }
1166
- } else if (operation === "changelog.apply") {
1167
- const afterDigest = target.afterDigest;
1168
- const preview = changelogApplyPreview({ ...resolved.request.payload, workspace_root: root });
1169
- if (!preview.error && typeof afterDigest === "string" && preview.beforeDigest === afterDigest)
1170
- observed = { target: target.target, afterDigest };
1171
- }
1172
- if (!observed) return unknownHostingEvidence(operation);
1173
- const evidenceDigest = sha256({ operation, observed });
1174
- return success(null, null, {
1175
- outcome: "succeeded",
1176
- evidenceDigest,
1177
- observation: {
1178
- kind: "provider_read",
1179
- actionRef,
1180
- outcome: "succeeded",
1181
- evidenceDigest,
1182
- },
1183
- });
1184
- };
1185
-
1186
- export const readExternalAction = async (
1187
- root: string,
1188
- resolved: ResolvedExternalAction,
1189
- actionRef: Ref,
1190
- ): Promise<Result<HostingReadEvidence>> => {
1191
- const target = actionRoot(root, resolved.request);
1192
- if (!target.ok) return unknownHostingEvidence(resolved.request.operation);
1193
- root = target.data;
1194
- return resolved.request.operation === "git.branch_setup" ||
1195
- resolved.request.operation === "git.commit" ||
1196
- resolved.request.operation === "git.push" ||
1197
- resolved.request.operation === "changelog.apply"
1198
- ? readLocalAction(root, resolved, actionRef)
1199
- : resolved.request.operation === "hosting.delete_branch"
1200
- ? readDeletedBranch(root, resolved, actionRef)
1201
- : resolved.request.operation === "hosting.pull_request" ||
1202
- resolved.request.operation === "hosting.merge"
1203
- ? readHostingAction(root, resolved, actionRef)
1204
- : readYouTrackAction(root, resolved, actionRef);
1205
- };
1206
-
1207
- const readDeletedBranch = async (
1208
- root: string,
1209
- resolved: ResolvedExternalAction,
1210
- actionRef: Ref,
1211
- ): Promise<Result<HostingReadEvidence>> => {
1212
- const details = (resolved.descriptorPayload as { resolved?: Record<string, unknown> }).resolved;
1213
- if (
1214
- !details ||
1215
- typeof details.branch !== "string" ||
1216
- typeof details.tip !== "string" ||
1217
- typeof details.remote !== "string" ||
1218
- credentialFreeRemote(pushRemote(root) ?? "") !== details.remote
1219
- )
1220
- return unknownHostingEvidence("hosting.delete_branch");
1221
- const provider = details.provider;
1222
- const repo = details.repo;
1223
- const apiHost = details.apiHost;
1224
- if (
1225
- (provider !== "github" && provider !== "gitlab") ||
1226
- typeof repo !== "string" ||
1227
- typeof apiHost !== "string" ||
1228
- !hostingApiHostMatches(pushRemote(root) ?? "", apiHost)
1229
- )
1230
- return unknownHostingEvidence("hosting.delete_branch");
1231
- const live = remoteBranchTip(root, provider, apiHost, repo, details.branch);
1232
- if (!live.ok || live.data !== null) return unknownHostingEvidence("hosting.delete_branch");
1233
- const evidenceDigest = sha256(details);
1234
- return success(null, null, {
1235
- outcome: "succeeded",
1236
- evidenceDigest,
1237
- observation: { kind: "provider_read", actionRef, outcome: "succeeded", evidenceDigest },
1238
- });
1239
- };
1240
-
1241
- const youTrackBase = (): string | null => {
1242
- try {
1243
- const config = JSON.parse(fs.readFileSync(youTrackConfigPath(), "utf8")) as {
1244
- baseUrl?: unknown;
1245
- };
1246
- const value = typeof config.baseUrl === "string" ? config.baseUrl.trim() : "";
1247
- if (!value || /[\s#?]/.test(value)) return null;
1248
- const url = new URL(value);
1249
- return `${url.protocol}//${url.host}${url.pathname}`.replace(/\/$/, "");
1250
- } catch {
1251
- return null;
1252
- }
1253
- };
1254
-
1255
- const youTrackWritePreflight = (): Result<null> => {
1256
- if (process.env.WORKFLOW_YT_WRITE !== "1")
1257
- return failure("capability_unavailable", "YouTrack write capability is unavailable", {
1258
- capability: "youtrack_write",
1259
- outcome: "not_started",
1260
- });
1261
- const credentials = youTrackToken();
1262
- if ("error" in credentials)
1263
- return failure("capability_unavailable", "YouTrack credentials are unavailable", {
1264
- capability: "youtrack",
1265
- outcome: "not_started",
1266
- });
1267
- return success(null, null, null);
1268
- };
1269
-
1270
- /**
1271
- * A dirty tree binds stash up front so the proposal names it and one
1272
- * approval carries the whole effect instead of failing at preflight.
1273
- * Carry-class dirt (untracked or docs-confined) rides along silently.
1274
- * Returns the original resolution untouched unless an upgrade applies.
1275
- */
1276
- export const upgradeBranchSetupForStash = (
1277
- root: string,
1278
- parsed: ExternalActionRequest,
1279
- resolved: Result<ResolvedExternalAction>,
1280
- ): Result<ResolvedExternalAction> => {
1281
- if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
1282
- const target = actionRoot(root, parsed);
1283
- if (
1284
- (resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
1285
- true ||
1286
- (resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
1287
- !target.ok ||
1288
- classifyBranchDirt(target.data) !== "stash-required"
1289
- )
1290
- return resolved;
1291
- const upgraded = resolveExternalActionRequest(root, {
1292
- ...parsed,
1293
- payload: { ...(parsed.payload as Record<string, unknown>), stash: "yes" },
1294
- } as typeof parsed);
1295
- return upgraded.ok ? upgraded : resolved;
1296
- };
1297
-
1298
- const resolveExternalActionAtRoot = (
1299
- root: string,
1300
- request: ExternalActionRequest,
1301
- preservedDateMs?: number,
1302
- ): Result<ResolvedExternalAction> => {
1303
- try {
1304
- switch (request.operation) {
1305
- case "git.branch_setup": {
1306
- const sdd_dir = safePath(root, request.payload.sdd_dir ?? "docs");
1307
- if (!sdd_dir) return failure("invalid_input", "sdd_dir must stay inside the workspace");
1308
- const action = request.payload.action ?? "setup";
1309
- const policySnapshot = branchPolicySnapshotFor(root);
1310
- const policy = policySnapshot.branchPolicy;
1311
- const target_branch = request.payload.target_branch;
1312
- if (action !== "reapply_stash" && !target_branch)
1313
- return failure(
1314
- "invalid_input",
1315
- "target_branch (the working branch to create or switch to) is required for setup",
1316
- {
1317
- outcome: "not_started",
1318
- fields: [{ path: "target_branch", reason: "required for action=setup" }],
1319
- },
1320
- );
1321
- if (target_branch) {
1322
- const check = validateBranchNamePolicy(
1323
- target_branch,
1324
- policySnapshot.namePolicy,
1325
- policySnapshot.source,
1326
- );
1327
- if (!check.ok)
1328
- return failure("permission_denied", check.error, {
1329
- outcome: "not_started",
1330
- fields: [{ path: "target_branch", reason: check.rule }],
1331
- });
1332
- }
1333
- const head = gitValue(root, ["rev-parse", "HEAD"]);
1334
- if (!head) return failure("storage_error", "current Git commit could not be resolved");
1335
- const dirty = Boolean((gitRaw(root, ["status", "--short"]) ?? "").trim());
1336
- const creation =
1337
- action === "reapply_stash" || !target_branch
1338
- ? { base_branch: policy.defaultTargetBranch }
1339
- : branchCreationBaseline(root, target_branch, policy.defaultTargetBranch);
1340
- let stashTarget: { stash_ref: string; stash_commit: string } | undefined;
1341
- if (action === "reapply_stash") {
1342
- try {
1343
- const manifest = JSON.parse(
1344
- fs.readFileSync(path.join(sdd_dir, "manifest.json"), "utf8"),
1345
- ) as { stash_ref?: unknown };
1346
- const stash_ref = typeof manifest.stash_ref === "string" ? manifest.stash_ref : "";
1347
- const stash_commit = stash_ref ? gitValue(root, ["rev-parse", stash_ref]) : null;
1348
- if (stash_ref && stash_commit) stashTarget = { stash_ref, stash_commit };
1349
- } catch {}
1350
- }
1351
- const normalized = {
1352
- operation: request.operation,
1353
- payload: {
1354
- ...request.payload,
1355
- action,
1356
- sdd_dir,
1357
- stash: request.payload.stash ?? "no",
1358
- ...(target_branch ? { target_branch } : {}),
1359
- },
1360
- } as ExternalActionRequest;
1361
- return success(null, null, {
1362
- request: normalized,
1363
- descriptorPayload: {
1364
- ...normalized.payload,
1365
- resolved: {
1366
- head,
1367
- dirty,
1368
- ...creation,
1369
- ...stashTarget,
1370
- ...(target_branch ? { branchPolicyFingerprint: policySnapshot.fingerprint } : {}),
1371
- },
1372
- },
1373
- });
1374
- }
1375
- case "git.push": {
1376
- const branch = request.payload.branch ?? gitValue(root, ["branch", "--show-current"]);
1377
- const commit = branch ? gitValue(root, ["rev-parse", branch]) : null;
1378
- const remote = pushRemote(root);
1379
- const identity = remote ? credentialFreeRemote(remote) : null;
1380
- const provider = remote ? hostingProvider(root, remote) : null;
1381
- const auth = provider ? vcsCliIdentity(root) : null;
1382
- if (provider && (!auth?.ok || auth.provider !== provider))
1383
- return failure("capability_unavailable", "hosting CLI identity is unavailable", {
1384
- outcome: "not_started",
1385
- });
1386
- if (provider && auth?.ok && remote && !hostingApiHostMatches(remote, auth.host))
1387
- return failure(
1388
- "capability_unavailable",
1389
- "hosting API host does not match the push destination",
1390
- {
1391
- outcome: "not_started",
1392
- },
1393
- );
1394
- return branch && commit && identity
1395
- ? success(null, null, {
1396
- request: {
1397
- operation: request.operation,
1398
- payload: { branch, ...(request.payload.cwd ? { cwd: request.payload.cwd } : {}) },
1399
- },
1400
- descriptorPayload: {
1401
- branch,
1402
- ...(request.payload.cwd ? { cwd: request.payload.cwd } : {}),
1403
- resolved: {
1404
- branch,
1405
- commit,
1406
- remote: identity,
1407
- ...(auth?.ok ? { account: auth.username } : {}),
1408
- ...(auth?.ok ? { apiHost: auth.host } : {}),
1409
- },
1410
- },
1411
- })
1412
- : failure("invalid_input", "git push requires a resolvable current branch");
1413
- }
1414
- case "git.commit": {
1415
- const planSteps = request.payload.plan_steps;
1416
- const planBranch = request.payload.plan_branch;
1417
- const head = gitValue(root, ["rev-parse", "HEAD"]);
1418
- if (!head) return failure("storage_error", "Git state could not be resolved");
1419
- const branch = gitValue(root, ["branch", "--show-current"]);
1420
- if (planSteps !== undefined || planBranch !== undefined) {
1421
- const chain = Array.isArray(planSteps) ? normalizeChainSteps(planSteps) : null;
1422
- if (!chain)
1423
- return failure(
1424
- "invalid_input",
1425
- "plan_steps must be 1-32 unique non-empty commit messages, branch targets, or pull_request markers",
1426
- {
1427
- outcome: "not_started",
1428
- fields: [{ path: "plan_steps", reason: "unique steps" }],
1429
- },
1430
- );
1431
- const commitSteps = chain.filter((step) => step.kind === "commit");
1432
- const branchSteps = chain.filter((step) => step.kind === "branch");
1433
- const commitPolicy = commitSteps.length ? commitPolicySnapshotFor(root) : null;
1434
- const branchPolicy = branchSteps.length ? branchPolicySnapshotFor(root) : null;
1435
- for (const step of commitSteps) {
1436
- if (step.kind !== "commit" || !commitPolicy) continue;
1437
- const check = validateCommitMessagePolicy(
1438
- step.message,
1439
- commitPolicy.policy,
1440
- commitPolicy.source,
1441
- );
1442
- if (!check.ok)
1443
- return failure("permission_denied", check.error, {
1444
- outcome: "not_started",
1445
- fields: [{ path: "plan_steps", reason: check.rule }],
1446
- });
1447
- }
1448
- for (const step of branchSteps) {
1449
- if (step.kind !== "branch" || !branchPolicy) continue;
1450
- const check = validateBranchNamePolicy(
1451
- step.target,
1452
- branchPolicy.namePolicy,
1453
- branchPolicy.source,
1454
- );
1455
- if (!check.ok)
1456
- return failure("permission_denied", check.error, {
1457
- outcome: "not_started",
1458
- fields: [{ path: "plan_steps", reason: check.rule }],
1459
- });
1460
- }
1461
- if (typeof planBranch !== "string" || !planBranch.trim())
1462
- return failure("invalid_input", "plan_branch must name the working branch", {
1463
- outcome: "not_started",
1464
- fields: [{ path: "plan_branch", reason: "required" }],
1465
- });
1466
- // A chain that creates its own branch names that branch; otherwise
1467
- // the plan runs on the branch already checked out.
1468
- const first = chain[0];
1469
- const createsBranch = first?.kind === "branch";
1470
- if (createsBranch ? planBranch !== first.target : planBranch !== branch)
1471
- return failure(
1472
- "invalid_input",
1473
- createsBranch
1474
- ? "plan_branch must name the branch the chain creates"
1475
- : "plan_branch must name the current working branch",
1476
- {
1477
- outcome: "not_started",
1478
- fields: [
1479
- {
1480
- path: "plan_branch",
1481
- reason: createsBranch ? "chain branch" : "current branch",
1482
- },
1483
- ],
1484
- },
1485
- );
1486
- return success(null, null, {
1487
- request,
1488
- descriptorPayload: {
1489
- plan_steps: planSteps,
1490
- plan_branch: planBranch,
1491
- resolved: {
1492
- head,
1493
- branch,
1494
- steps: planSteps,
1495
- ...(commitPolicy ? { commitPolicyFingerprint: commitPolicy.fingerprint } : {}),
1496
- ...(branchPolicy ? { branchPolicyFingerprint: branchPolicy.fingerprint } : {}),
1497
- },
1498
- },
1499
- });
1500
- }
1501
- const staged = gitRaw(root, [
1502
- "diff",
1503
- "--cached",
1504
- "--raw",
1505
- "-z",
1506
- "--no-ext-diff",
1507
- "--no-textconv",
1508
- "HEAD",
1509
- ]);
1510
- const paths = stagedPaths(root);
1511
- const message = request.payload.message;
1512
- if (typeof message !== "string" || !message)
1513
- return failure("invalid_input", "git.commit requires a commit message", {
1514
- outcome: "not_started",
1515
- fields: [{ path: "message", reason: "required unless plan_steps is provided" }],
1516
- });
1517
- if (staged === null || paths === null)
1518
- return failure("storage_error", "Git state could not be resolved");
1519
- const commitPolicy = commitPolicySnapshotFor(root);
1520
- const commitCheck = validateCommitMessagePolicy(
1521
- message,
1522
- commitPolicy.policy,
1523
- commitPolicy.source,
1524
- );
1525
- if (!commitCheck.ok)
1526
- return failure("permission_denied", commitCheck.error, {
1527
- outcome: "not_started",
1528
- fields: [{ path: "message", reason: commitCheck.rule }],
1529
- });
1530
- return success(null, null, {
1531
- request,
1532
- descriptorPayload: {
1533
- ...request.payload,
1534
- resolved: {
1535
- head,
1536
- branch,
1537
- staged: sha256(staged),
1538
- paths,
1539
- commitPolicyFingerprint: commitPolicy.fingerprint,
1540
- },
1541
- },
1542
- });
1543
- }
1544
- case "hosting.pull_request": {
1545
- const target_branch =
1546
- request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
1547
- const source_branch = gitValue(root, ["branch", "--show-current"]);
1548
- const source_commit = source_branch
1549
- ? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
1550
- : null;
1551
- const remote = pushRemote(root);
1552
- const identity = remote ? credentialFreeRemote(remote) : null;
1553
- const vcs = vcsConfig("load", root);
1554
- const provider = remote ? hostingProvider(root, remote) : null;
1555
- if (
1556
- !source_branch ||
1557
- !source_commit ||
1558
- !identity ||
1559
- !vcs.ok ||
1560
- !provider ||
1561
- provider !== vcs.provider
1562
- )
1563
- return failure(
1564
- "capability_unavailable",
1565
- "hosting target is not bound to the configured remote provider",
1566
- { capability: "hosting.pull_request", outcome: "not_started" },
1567
- );
1568
- const auth = vcsCliIdentity(root);
1569
- if (!auth.ok || auth.provider !== provider || !hostingCliAvailable(provider))
1570
- return failure("capability_unavailable", "hosting CLI identity is unavailable", {
1571
- capability: "hosting.pull_request",
1572
- outcome: "not_started",
1573
- });
1574
- if (!remote || !hostingApiHostMatches(remote, auth.host))
1575
- return failure(
1576
- "capability_unavailable",
1577
- "hosting API host does not match the push destination",
1578
- { capability: "hosting.pull_request", outcome: "not_started" },
1579
- );
1580
- const merge_base_commit =
1581
- resolveBranchPolicyFor(root).integration === "merge"
1582
- ? gitValue(root, ["rev-parse", "--verify", `refs/heads/${target_branch}`])
1583
- : null;
1584
- const normalized = {
1585
- operation: request.operation,
1586
- payload: { ...request.payload, target_branch },
1587
- } as ExternalActionRequest;
1588
- const marker = `<!-- workit-action:${sha256({ operation: request.operation, payload: normalized.payload, source_commit, remote: identity })} -->`;
1589
- return success(null, null, {
1590
- request: normalized,
1591
- marker,
1592
- descriptorPayload: {
1593
- ...normalized.payload,
1594
- resolved: {
1595
- source_branch,
1596
- source_commit,
1597
- remote: identity,
1598
- target_branch,
1599
- marker,
1600
- account: auth.username,
1601
- apiHost: auth.host,
1602
- ...(merge_base_commit ? { merge_base_commit } : {}),
1603
- },
1604
- },
1605
- });
1606
- }
1607
- case "hosting.merge": {
1608
- const target_branch =
1609
- request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
1610
- const source_branch =
1611
- request.payload.source_branch ?? gitValue(root, ["branch", "--show-current"]);
1612
- const source_commit = source_branch
1613
- ? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
1614
- : null;
1615
- const remote = pushRemote(root);
1616
- const identity = remote ? credentialFreeRemote(remote) : null;
1617
- const vcs = vcsConfig("load", root);
1618
- const provider = remote ? hostingProvider(root, remote) : null;
1619
- if (!source_branch || !source_commit || !identity || !vcs.ok || provider !== vcs.provider)
1620
- return failure(
1621
- "capability_unavailable",
1622
- "hosting target is not bound to the configured remote provider",
1623
- { capability: "hosting.merge" },
1624
- );
1625
- const auth = vcsCliIdentity(root);
1626
- if (!vcs.ok || !auth.ok)
1627
- return failure("capability_unavailable", "hosting credentials are unavailable", {
1628
- capability: "hosting.merge",
1629
- outcome: "not_started",
1630
- });
1631
- if (!remote || !hostingApiHostMatches(remote, auth.host))
1632
- return failure(
1633
- "capability_unavailable",
1634
- "hosting API host does not match the push destination",
1635
- {
1636
- capability: "hosting.merge",
1637
- outcome: "not_started",
1638
- },
1639
- );
1640
- if (!hostingCliAvailable(String(vcs.provider)))
1641
- return failure("capability_unavailable", "hosting CLI is unavailable", {
1642
- capability: "hosting.merge",
1643
- outcome: "not_started",
1644
- });
1645
- const normalized = {
1646
- operation: request.operation,
1647
- payload: { ...request.payload, target_branch, source_branch },
1648
- } as ExternalActionRequest;
1649
- const marker = `<!-- workit-action:${sha256({ operation: request.operation, payload: normalized.payload, source_commit, remote: identity })} -->`;
1650
- return success(null, null, {
1651
- request: normalized,
1652
- marker,
1653
- descriptorPayload: {
1654
- ...normalized.payload,
1655
- resolved: {
1656
- source_branch,
1657
- source_commit,
1658
- remote: identity,
1659
- target_branch,
1660
- marker,
1661
- account: auth.username,
1662
- apiHost: auth.host,
1663
- },
1664
- },
1665
- });
1666
- }
1667
- case "hosting.delete_branch": {
1668
- const checked = mergedBranch(root, request.payload.branch);
1669
- return checked.ok
1670
- ? success(null, null, {
1671
- request,
1672
- descriptorPayload: { ...request.payload, resolved: checked.data },
1673
- })
1674
- : checked;
1675
- }
1676
- case "changelog.apply": {
1677
- const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1678
- if (preview.error) return failure("invalid_input", preview.error);
1679
- const target = String(preview.path);
1680
- const relative =
1681
- path.relative(fs.realpathSync(root), target).replaceAll(path.sep, "/") || "CHANGELOG.md";
1682
- const normalized = {
1683
- operation: request.operation,
1684
- payload: {
1685
- ...request.payload,
1686
- path: relative,
1687
- entries: preview.entries,
1688
- normalize_only: preview.normalize_only,
1689
- },
1690
- } as ExternalActionRequest;
1691
- return success(null, null, {
1692
- request: normalized,
1693
- descriptorPayload: {
1694
- ...normalized.payload,
1695
- resolved: {
1696
- target,
1697
- beforeDigest: preview.beforeDigest,
1698
- beforeExists: preview.beforeExists,
1699
- afterDigest: preview.afterDigest,
1700
- },
1701
- },
1702
- });
1703
- }
1704
- case "context.read":
1705
- return success(null, null, { request, descriptorPayload: request.payload });
1706
- case "youtrack.time":
1707
- if (request.payload.dateMs === undefined)
1708
- return failure("invalid_input", "youtrack.time requires an explicit dateMs");
1709
- {
1710
- const available = youTrackWritePreflight();
1711
- if (!available.ok) return available;
1712
- const baseUrl = youTrackBase();
1713
- const date = youTrackWorkDateMs(String(request.payload.dateMs));
1714
- if (!baseUrl)
1715
- return failure("capability_unavailable", "YouTrack base URL is unavailable", {
1716
- capability: "youtrack",
1717
- });
1718
- if ("error" in date)
1719
- return failure("capability_unavailable", "YouTrack work date is unavailable", {
1720
- capability: "youtrack",
1721
- });
1722
- const marker = `<!-- workit-action:${sha256({ operation: request.operation, issueId: request.payload.issueId, baseUrl, payload: request.payload, dateMs: date.data.dateMs })} -->`;
1723
- const workText = appendMarker(request.payload.text ?? "", marker);
1724
- return success(null, null, {
1725
- request,
1726
- marker,
1727
- workDate: workDateOf(date.data),
1728
- descriptorPayload: {
1729
- ...request.payload,
1730
- resolved: {
1731
- baseUrl,
1732
- issueId: request.payload.issueId,
1733
- marker,
1734
- dateMs: date.data.dateMs,
1735
- workText,
1736
- minutes: request.payload.minutes,
1737
- steps: ["time"],
1738
- },
1739
- },
1740
- });
1741
- }
1742
- case "youtrack.update":
1743
- case "youtrack.meeting": {
1744
- const available = youTrackWritePreflight();
1745
- if (!available.ok) return available;
1746
- const baseUrl = youTrackBase();
1747
- const date = youTrackWorkDateMs(
1748
- preservedDateMs === undefined ? "auto" : String(preservedDateMs),
1749
- );
1750
- if (!baseUrl)
1751
- return failure("capability_unavailable", "YouTrack base URL is unavailable", {
1752
- capability: "youtrack",
1753
- });
1754
- if ("error" in date)
1755
- return failure("capability_unavailable", "YouTrack work date is unavailable", {
1756
- capability: "youtrack",
1757
- });
1758
- const marker = `<!-- workit-action:${sha256({ operation: request.operation, issueId: request.payload.issueId, baseUrl, payload: request.payload, dateMs: date.data.dateMs })} -->`;
1759
- const text =
1760
- request.operation === "youtrack.update" ? request.payload.markdown : request.payload.text;
1761
- const annotated = appendMarker(text, marker);
1762
- const steps =
1763
- request.operation === "youtrack.update"
1764
- ? request.payload.minutes
1765
- ? ["comment", "time"]
1766
- : ["comment"]
1767
- : ["time"];
1768
- return success(null, null, {
1769
- request,
1770
- marker,
1771
- workDate: workDateOf(date.data),
1772
- descriptorPayload: {
1773
- ...request.payload,
1774
- resolved: {
1775
- baseUrl,
1776
- issueId: request.payload.issueId,
1777
- marker,
1778
- dateMs: date.data.dateMs,
1779
- ...(request.operation === "youtrack.update" ? { commentText: annotated } : {}),
1780
- workText: annotated,
1781
- ...(request.payload.minutes !== undefined
1782
- ? { minutes: request.payload.minutes }
1783
- : {}),
1784
- steps,
1785
- },
1786
- },
1787
- });
1788
- }
1789
- default:
1790
- return success(null, null, {
1791
- request: request,
1792
- descriptorPayload: (request as unknown as { payload: unknown }).payload,
1793
- });
1794
- }
1795
- } catch {
1796
- return failure("storage_error", "external action target could not be resolved");
1797
- }
1798
- };
1799
-
1800
- export const resolveExternalActionRequest = (
1801
- taskRoot: string,
1802
- request: ExternalActionRequest,
1803
- preservedDateMs?: number,
1804
- ): Result<ResolvedExternalAction> => {
1805
- const target = actionRoot(taskRoot, request);
1806
- if (!target.ok) return target;
1807
- const bindTarget = bindsActionTarget(request.operation);
1808
- const normalized = bindTarget
1809
- ? ({ ...request, payload: { ...request.payload, cwd: target.data } } as ExternalActionRequest)
1810
- : request;
1811
- const resolved = resolveExternalActionAtRoot(target.data, normalized, preservedDateMs);
1812
- if (!resolved.ok || !bindTarget) return resolved;
1813
- return success(null, null, {
1814
- ...resolved.data,
1815
- request: {
1816
- ...resolved.data.request,
1817
- payload: { ...resolved.data.request.payload, cwd: target.data },
1818
- } as ExternalActionRequest,
1819
- descriptorPayload: {
1820
- ...(resolved.data.descriptorPayload as Record<string, unknown>),
1821
- cwd: target.data,
1822
- },
1823
- });
1824
- };
1825
-
1826
- export const prBabysitNext = (output: string): string =>
1827
- `PR ready: ${output}. Babysitting was explicitly requested. Follow workit-ship to resolve conflicts, review threads, and get checks green. Stop at PR-ready; a PR or babysit request does not authorize merge or release.`;
1828
-
1829
- export const executeConcreteExternalAction = async (
1830
- request: ExternalActionRequest,
1831
- root: string,
1832
- marker?: string,
1833
- dateMs?: number,
1834
- step?: string,
1835
- workText?: string,
1836
- caller?: { host: Caller["host"]; actor: string },
1837
- approvedBeforeDigest?: string,
1838
- approvedBeforeExists?: boolean,
1839
- coordinationRoot: string = root,
1840
- approvedTip?: string,
1841
- approvedRemote?: string,
1842
- approvedAccount?: string,
1843
- approvedApiHost?: string,
1844
- approvedSourceCommit?: string,
1845
- approvedCommit?: string,
1846
- approvedRemoteBase?: string,
1847
- approvedLocalBase?: string | null,
1848
- writerActionLease?: symbol,
1849
- approvedSourceBranch?: string,
1850
- approvedMergeBaseCommit?: string,
1851
- expectedWorkspaceRevision?: string,
1852
- ): Promise<Result<unknown>> => {
1853
- if (localAction(request.operation)) {
1854
- if (!caller)
1855
- return failure(
1856
- "capability_unavailable",
1857
- "local external action writer authority is unavailable",
1858
- { outcome: "not_started" },
1859
- );
1860
- const paths =
1861
- request.operation === "git.commit"
1862
- ? stagedPaths(root)
1863
- : request.operation === "changelog.apply"
1864
- ? [request.payload.path ?? "CHANGELOG.md"]
1865
- : ["."];
1866
- if (paths === null)
1867
- return failure(
1868
- "capability_unavailable",
1869
- "local external action staged paths are unavailable",
1870
- { outcome: "not_started" },
1871
- );
1872
- const writer = assertLocalExternalActionWriter(
1873
- coordinationRoot,
1874
- caller,
1875
- paths,
1876
- expectedWorkspaceRevision,
1877
- );
1878
- if (!writer.ok)
1879
- return failure(
1880
- "capability_unavailable",
1881
- "local external action writer authority is unavailable",
1882
- { outcome: "not_started" },
1883
- );
1884
- if (writerActionLease !== targetWriterActionLease)
1885
- return new TaskStore(root).withExternalActionLock(() =>
1886
- executeConcreteExternalAction(
1887
- request,
1888
- root,
1889
- marker,
1890
- dateMs,
1891
- step,
1892
- workText,
1893
- caller,
1894
- approvedBeforeDigest,
1895
- approvedBeforeExists,
1896
- coordinationRoot,
1897
- approvedTip,
1898
- approvedRemote,
1899
- approvedAccount,
1900
- approvedApiHost,
1901
- approvedSourceCommit,
1902
- approvedCommit,
1903
- approvedRemoteBase,
1904
- approvedLocalBase,
1905
- targetWriterActionLease,
1906
- approvedSourceBranch,
1907
- approvedMergeBaseCommit,
1908
- expectedWorkspaceRevision,
1909
- ),
1910
- );
1911
- if (!sameDirectoryIdentity(root, coordinationRoot)) {
1912
- const targetWorkspace = new TaskStore(root).readWorkspace();
1913
- if (!targetWorkspace.ok) return targetWorkspace;
1914
- const owner = targetWorkspace.data?.writer?.owner;
1915
- if (
1916
- owner &&
1917
- (owner.taskId !== writer.data.taskId ||
1918
- owner.workerId !== writer.data.workerId ||
1919
- owner.session.host !== caller.host ||
1920
- owner.session.handle !== caller.actor)
1921
- )
1922
- return failure("writer_conflict", "target checkout is owned by another writer", {
1923
- outcome: "not_started",
1924
- });
1925
- }
1926
- }
1927
- switch (request.operation) {
1928
- case "git.branch_setup": {
1929
- const result = branchSetup({
1930
- ...request.payload,
1931
- sdd_dir: request.payload.sdd_dir ?? "docs",
1932
- workspace_root: root,
1933
- expected_remote_base: approvedRemoteBase,
1934
- expected_local_base: approvedLocalBase,
1935
- });
1936
- if ("error" in result) {
1937
- if (result.phase === "preflight")
1938
- return failure("invalid_input", result.error, {
1939
- outcome: "not_started",
1940
- operation: request.operation,
1941
- });
1942
- return unknown(request.operation);
1943
- }
1944
- return success(null, null, result);
1945
- }
1946
- case "git.commit": {
1947
- const message = request.payload.message;
1948
- if (typeof message !== "string" || !message)
1949
- return failure("invalid_input", "git.commit requires a commit message", {
1950
- outcome: "not_started",
1951
- fields: [{ path: "message", reason: "required" }],
1952
- });
1953
- const result = run(root, ["commit", "-m", message]);
1954
- return result.exitCode === 0
1955
- ? success(null, null, { stdout: result.stdout.trim() })
1956
- : unknown(request.operation);
1957
- }
1958
- case "git.push": {
1959
- const remote = pushRemote(root);
1960
- const identity = remote ? pushRemoteIdentity(remote) : null;
1961
- const target = remote ? safePushUrl(remote) : null;
1962
- const provider = remote ? hostingProvider(root, remote) : null;
1963
- const auth = provider ? vcsCliIdentity(root) : null;
1964
- if (
1965
- !remote ||
1966
- !identity ||
1967
- !target ||
1968
- !pushTargetIsStable(root, remote) ||
1969
- (approvedRemote && identity !== approvedRemote)
1970
- )
1971
- return failure(
1972
- "capability_unavailable",
1973
- "push destination is ambiguous, rewritten, or changed after approval",
1974
- {
1975
- outcome: "not_started",
1976
- },
1977
- );
1978
- if (provider && (!auth?.ok || !hostingApiHostMatches(remote, auth.host)))
1979
- return failure(
1980
- "capability_unavailable",
1981
- "hosting API host does not match the push destination",
1982
- {
1983
- outcome: "not_started",
1984
- },
1985
- );
1986
- if (
1987
- approvedAccount &&
1988
- (!auth?.ok || auth.username?.toLowerCase() !== approvedAccount.toLowerCase())
1989
- )
1990
- return failure(
1991
- "capability_unavailable",
1992
- "approved hosting account changed before execution",
1993
- {
1994
- outcome: "not_started",
1995
- },
1996
- );
1997
- if (approvedApiHost && (!auth?.ok || auth.host !== approvedApiHost))
1998
- return failure("capability_unavailable", "approved hosting host changed before execution", {
1999
- outcome: "not_started",
2000
- });
2001
- const branch = request.payload.branch!;
2002
- if (!approvedCommit || !/^[a-f0-9]{40,64}$/i.test(approvedCommit))
2003
- return failure("capability_unavailable", "approved push commit is unavailable", {
2004
- outcome: "not_started",
2005
- });
2006
- if (run(root, ["check-ref-format", "--branch", branch]).exitCode !== 0)
2007
- return failure("invalid_input", "push branch name is invalid", { outcome: "not_started" });
2008
- if (gitValue(root, ["rev-parse", "--verify", `refs/heads/${branch}`]) !== approvedCommit)
2009
- return failure("capability_unavailable", "approved push branch changed before execution", {
2010
- outcome: "not_started",
2011
- });
2012
- const result = run(root, ["push", target, `${approvedCommit}:refs/heads/${branch}`]);
2013
- return result.exitCode === 0
2014
- ? success(null, null, { stdout: result.stdout.trim() })
2015
- : unknown(request.operation);
2016
- }
2017
- case "hosting.pull_request": {
2018
- if (
2019
- !approvedSourceCommit ||
2020
- !approvedSourceBranch ||
2021
- !approvedRemote ||
2022
- !approvedAccount ||
2023
- !approvedApiHost
2024
- )
2025
- return failure("capability_unavailable", "approved PR source or target is incomplete", {
2026
- outcome: "not_started",
2027
- });
2028
- const body = appendMarker(request.payload.body ?? "", marker ?? "");
2029
- const result = prCreate(
2030
- {
2031
- ...process.env,
2032
- WF_PR_TITLE: request.payload.title,
2033
- WF_PR_BODY: body,
2034
- WF_PR_CONFIRMED: "true",
2035
- WF_PR_DRAFT: request.payload.draft ? "true" : "false",
2036
- WF_PR_TARGET: request.payload.target_branch ?? "",
2037
- WORKIT_EXPECTED_SOURCE_BRANCH: approvedSourceBranch,
2038
- WORKIT_EXPECTED_SOURCE_COMMIT: approvedSourceCommit,
2039
- WORKIT_EXPECTED_REMOTE: approvedRemote,
2040
- WORKIT_EXPECTED_ACCOUNT: approvedAccount,
2041
- WORKIT_EXPECTED_API_HOST: approvedApiHost,
2042
- ...(approvedMergeBaseCommit
2043
- ? { WORKIT_EXPECTED_MERGE_BASE_COMMIT: approvedMergeBaseCommit }
2044
- : {}),
2045
- },
2046
- root,
2047
- );
2048
- if (result.error || result.ok === false) return unknown(request.operation);
2049
- const babysit = request.payload.babysit === true;
2050
- return success(null, null, {
2051
- ...result,
2052
- ...(request.payload.babysit !== undefined ? { babysit: request.payload.babysit } : {}),
2053
- ...(babysit
2054
- ? {
2055
- babysitSkill: "workit-ship",
2056
- next: prBabysitNext(String(result.output ?? "")),
2057
- }
2058
- : {}),
2059
- });
2060
- }
2061
- case "hosting.merge": {
2062
- const target = String(request.payload.target_branch ?? "");
2063
- const source = String(request.payload.source_branch ?? "");
2064
- if (!target || !source)
2065
- return failure("invalid_input", "hosting.merge requires target and source branches", {
2066
- outcome: "not_started",
2067
- fields: [{ path: "target_branch", reason: "required" }],
2068
- });
2069
- if (!approvedSourceCommit || !approvedRemote || !approvedAccount || !approvedApiHost)
2070
- return failure("capability_unavailable", "approved merge target is incomplete", {
2071
- outcome: "not_started",
2072
- });
2073
- const result = mergePr(root, {
2074
- target,
2075
- source,
2076
- sourceCommit: approvedSourceCommit,
2077
- remote: approvedRemote,
2078
- account: approvedAccount,
2079
- apiHost: approvedApiHost,
2080
- });
2081
- return result.error || result.ok === false
2082
- ? unknown(request.operation)
2083
- : success(null, null, result);
2084
- }
2085
- case "hosting.delete_branch": {
2086
- const remote = pushRemote(root);
2087
- const identity = remote ? pushRemoteIdentity(remote) : null;
2088
- const target = remote ? safePushUrl(remote) : null;
2089
- const auth = vcsCliIdentity(root);
2090
- if (!approvedTip || !/^[a-f0-9]{40,64}$/.test(approvedTip))
2091
- return failure("invalid_input", "approved branch tip is unavailable", {
2092
- outcome: "not_started",
2093
- });
2094
- if (
2095
- !remote ||
2096
- !identity ||
2097
- !target ||
2098
- !pushTargetIsStable(root, remote) ||
2099
- (approvedRemote && identity !== approvedRemote)
2100
- )
2101
- return failure(
2102
- "capability_unavailable",
2103
- "deletion destination is ambiguous, rewritten, or changed after approval",
2104
- {
2105
- outcome: "not_started",
2106
- },
2107
- );
2108
- if (!auth.ok || !hostingApiHostMatches(remote, auth.host))
2109
- return failure(
2110
- "capability_unavailable",
2111
- "hosting API host does not match the deletion destination",
2112
- {
2113
- outcome: "not_started",
2114
- },
2115
- );
2116
- if (
2117
- approvedAccount &&
2118
- (!auth.ok || auth.username?.toLowerCase() !== approvedAccount.toLowerCase())
2119
- )
2120
- return failure(
2121
- "capability_unavailable",
2122
- "approved hosting account changed before execution",
2123
- {
2124
- outcome: "not_started",
2125
- },
2126
- );
2127
- if (approvedApiHost && (!auth.ok || auth.host !== approvedApiHost))
2128
- return failure("capability_unavailable", "approved hosting host changed before execution", {
2129
- outcome: "not_started",
2130
- });
2131
- const branch = request.payload.branch;
2132
- const result = deleteRemoteBranch(root, target, branch, approvedTip);
2133
- if (result.ok) return success(null, null, { branch, deletedTip: approvedTip });
2134
- return result.lease
2135
- ? failure("capability_unavailable", "remote branch tip changed before deletion", {
2136
- outcome: "not_started",
2137
- })
2138
- : unknown(request.operation);
2139
- }
2140
- case "changelog.apply": {
2141
- const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
2142
- if (
2143
- preview.error ||
2144
- (approvedBeforeDigest !== undefined && preview.beforeDigest !== approvedBeforeDigest) ||
2145
- (approvedBeforeExists !== undefined && preview.beforeExists !== approvedBeforeExists)
2146
- )
2147
- return failure(
2148
- "capability_unavailable",
2149
- "approved changelog target changed before execution",
2150
- { outcome: "not_started" },
2151
- );
2152
- const result = changelogApply({ ...request.payload, workspace_root: root });
2153
- return result.error
2154
- ? failure("storage_error", String(result.error))
2155
- : success(null, null, result);
2156
- }
2157
- case "youtrack.update": {
2158
- if (step === "comment") {
2159
- const result = await defaultOperations.postComment(
2160
- request.payload.issueId,
2161
- appendMarker(request.payload.markdown, marker ?? ""),
2162
- root,
2163
- );
2164
- return success(null, null, result);
2165
- }
2166
- if (step === "time") {
2167
- const result = await logTimeUpdate(
2168
- {
2169
- confirmed: true,
2170
- issueId: request.payload.issueId,
2171
- minutes: request.payload.minutes,
2172
- text: workText ?? appendMarker(request.payload.markdown, marker ?? ""),
2173
- dateMs,
2174
- workspace_root: root,
2175
- },
2176
- defaultOperations,
2177
- );
2178
- return result.ok ? success(null, null, result.data) : unknown(request.operation);
2179
- }
2180
- const result = await postUpdate(
2181
- {
2182
- confirmed: true,
2183
- issueId: request.payload.issueId,
2184
- markdown: appendMarker(request.payload.markdown, marker ?? ""),
2185
- minutes: request.payload.minutes,
2186
- dateMs,
2187
- workspace_root: root,
2188
- },
2189
- defaultOperations,
2190
- );
2191
- return result.ok ? success(null, null, result.data) : unknown(request.operation);
2192
- }
2193
- case "youtrack.time": {
2194
- const result = await logTimeUpdate(
2195
- {
2196
- confirmed: true,
2197
- issueId: request.payload.issueId,
2198
- minutes: request.payload.minutes,
2199
- text: appendMarker(request.payload.text ?? "", marker ?? ""),
2200
- dateMs: dateMs ?? request.payload.dateMs,
2201
- workspace_root: root,
2202
- },
2203
- defaultOperations,
2204
- );
2205
- return result.ok ? success(null, null, result.data) : unknown(request.operation);
2206
- }
2207
- case "youtrack.meeting": {
2208
- const result = await logTimeUpdate(
2209
- {
2210
- confirmed: true,
2211
- issueId: request.payload.issueId,
2212
- minutes: request.payload.minutes,
2213
- text: appendMarker(request.payload.text, marker ?? ""),
2214
- dateMs,
2215
- workspace_root: root,
2216
- },
2217
- defaultOperations,
2218
- );
2219
- return result.ok ? success(null, null, result.data) : unknown(request.operation);
2220
- }
2221
- case "context.read":
2222
- return readExternalContext(root, request.payload);
2223
- }
2224
- };
2225
-
2226
- /** Re-resolve the concrete target after approval so mutable repository/config state cannot redirect it. */
2227
- export const executeResolvedExternalAction = async (
2228
- resolved: ResolvedExternalAction,
2229
- root: string,
2230
- step?: string,
2231
- caller?: { host: Caller["host"]; actor: string },
2232
- expectedWorkspaceRevision?: string,
2233
- ): Promise<Result<unknown>> => {
2234
- const approvedDetails =
2235
- resolved.descriptorPayload && typeof resolved.descriptorPayload === "object"
2236
- ? (
2237
- resolved.descriptorPayload as {
2238
- resolved?: {
2239
- dateMs?: unknown;
2240
- beforeDigest?: unknown;
2241
- beforeExists?: unknown;
2242
- branchPolicyFingerprint?: unknown;
2243
- commitPolicyFingerprint?: unknown;
2244
- };
2245
- }
2246
- ).resolved
2247
- : undefined;
2248
- const preservedDateMs =
2249
- (resolved.request.operation === "youtrack.update" ||
2250
- resolved.request.operation === "youtrack.meeting") &&
2251
- typeof approvedDetails?.dateMs === "number"
2252
- ? approvedDetails.dateMs
2253
- : undefined;
2254
- const fresh = resolveExternalActionRequest(root, resolved.request, preservedDateMs);
2255
- if (!fresh.ok) {
2256
- if (fresh.code === "permission_denied") return fresh;
2257
- return failure(
2258
- "capability_unavailable",
2259
- "approved external action target changed before execution",
2260
- { outcome: "not_started" },
2261
- );
2262
- }
2263
- const currentResolved = (
2264
- fresh.data.descriptorPayload as {
2265
- resolved?: {
2266
- branchPolicyFingerprint?: unknown;
2267
- commitPolicyFingerprint?: unknown;
2268
- };
2269
- }
2270
- ).resolved;
2271
- for (const [key, label] of [
2272
- ["branchPolicyFingerprint", "branch naming policy"],
2273
- ["commitPolicyFingerprint", "commit style policy"],
2274
- ] as const) {
2275
- const approvedFingerprint = approvedDetails?.[key];
2276
- const currentFingerprint = currentResolved?.[key];
2277
- if (
2278
- (typeof approvedFingerprint === "string" || typeof currentFingerprint === "string") &&
2279
- approvedFingerprint !== currentFingerprint
2280
- )
2281
- return failure(
2282
- "permission_denied",
2283
- `${label} changed since approval; approve the operation again`,
2284
- { outcome: "not_started" },
2285
- );
2286
- }
2287
- const freshDescriptor = externalActionDescriptor(
2288
- resolved.request.operation,
2289
- fresh.data.descriptorPayload,
2290
- );
2291
- const approvedDescriptor = externalActionDescriptor(
2292
- resolved.request.operation,
2293
- resolved.descriptorPayload,
2294
- );
2295
- const freshBranchIntent =
2296
- resolved.request.operation === "git.branch_setup" ? branchSetupIntent(freshDescriptor) : null;
2297
- const approvedBranchIntent =
2298
- resolved.request.operation === "git.branch_setup"
2299
- ? branchSetupIntent(approvedDescriptor)
2300
- : null;
2301
- const unchangedBranchIntent =
2302
- freshBranchIntent !== null &&
2303
- approvedBranchIntent !== null &&
2304
- canonicalJson(freshBranchIntent) === canonicalJson(approvedBranchIntent);
2305
- if (freshDescriptor !== approvedDescriptor && !unchangedBranchIntent)
2306
- return failure(
2307
- "capability_unavailable",
2308
- "approved external action target changed before execution",
2309
- { outcome: "not_started" },
2310
- );
2311
- const resolvedDetails = fresh.data.descriptorPayload as {
2312
- resolved?: {
2313
- dateMs?: unknown;
2314
- beforeDigest?: unknown;
2315
- beforeExists?: unknown;
2316
- remote_base?: unknown;
2317
- local_base?: unknown;
2318
- target_exists?: unknown;
2319
- tip?: unknown;
2320
- remote?: unknown;
2321
- account?: unknown;
2322
- apiHost?: unknown;
2323
- source_commit?: unknown;
2324
- commit?: unknown;
2325
- source_branch?: unknown;
2326
- merge_base_commit?: unknown;
2327
- };
2328
- };
2329
- const resolvedDate =
2330
- typeof resolvedDetails.resolved?.dateMs === "number"
2331
- ? resolvedDetails.resolved.dateMs
2332
- : undefined;
2333
- const workText = (fresh.data.descriptorPayload as { resolved?: { workText?: unknown } }).resolved
2334
- ?.workText;
2335
- const beforeDigest =
2336
- typeof resolvedDetails.resolved?.beforeDigest === "string"
2337
- ? resolvedDetails.resolved.beforeDigest
2338
- : undefined;
2339
- const beforeExists =
2340
- typeof resolvedDetails.resolved?.beforeExists === "boolean"
2341
- ? resolvedDetails.resolved.beforeExists
2342
- : undefined;
2343
- const approvedTip =
2344
- typeof resolvedDetails.resolved?.tip === "string" ? resolvedDetails.resolved.tip : undefined;
2345
- const approvedRemote =
2346
- typeof resolvedDetails.resolved?.remote === "string"
2347
- ? resolvedDetails.resolved.remote
2348
- : undefined;
2349
- const approvedAccount =
2350
- typeof resolvedDetails.resolved?.account === "string"
2351
- ? resolvedDetails.resolved.account
2352
- : undefined;
2353
- const approvedApiHost =
2354
- typeof resolvedDetails.resolved?.apiHost === "string"
2355
- ? resolvedDetails.resolved.apiHost
2356
- : undefined;
2357
- const approvedSourceCommit =
2358
- typeof resolvedDetails.resolved?.source_commit === "string"
2359
- ? resolvedDetails.resolved.source_commit
2360
- : undefined;
2361
- const approvedCommit =
2362
- typeof resolvedDetails.resolved?.commit === "string"
2363
- ? resolvedDetails.resolved.commit
2364
- : undefined;
2365
- const approvedRemoteBase =
2366
- typeof resolvedDetails.resolved?.remote_base === "string"
2367
- ? resolvedDetails.resolved.remote_base
2368
- : undefined;
2369
- const approvedLocalBase =
2370
- typeof resolvedDetails.resolved?.local_base === "string"
2371
- ? resolvedDetails.resolved.local_base
2372
- : resolvedDetails.resolved?.local_base === null
2373
- ? null
2374
- : undefined;
2375
- const approvedSourceBranch =
2376
- typeof resolvedDetails.resolved?.source_branch === "string"
2377
- ? resolvedDetails.resolved.source_branch
2378
- : undefined;
2379
- const approvedMergeBaseCommit =
2380
- typeof resolvedDetails.resolved?.merge_base_commit === "string"
2381
- ? resolvedDetails.resolved.merge_base_commit
2382
- : undefined;
2383
- if (
2384
- fresh.data.request.operation === "git.branch_setup" &&
2385
- fresh.data.request.payload.action !== "reapply_stash" &&
2386
- resolvedDetails.resolved?.target_exists === false &&
2387
- !approvedRemoteBase
2388
- )
2389
- return failure("capability_unavailable", "approved remote base SHA could not be resolved", {
2390
- outcome: "not_started",
2391
- });
2392
- const target = actionRoot(root, fresh.data.request);
2393
- if (!target.ok) return target;
2394
- const result = await executeConcreteExternalAction(
2395
- fresh.data.request,
2396
- target.data,
2397
- fresh.data.marker,
2398
- resolvedDate,
2399
- step,
2400
- typeof workText === "string" ? workText : undefined,
2401
- caller,
2402
- beforeDigest,
2403
- beforeExists,
2404
- root,
2405
- approvedTip,
2406
- approvedRemote,
2407
- approvedAccount,
2408
- approvedApiHost,
2409
- approvedSourceCommit,
2410
- approvedCommit,
2411
- approvedRemoteBase,
2412
- approvedLocalBase,
2413
- undefined,
2414
- approvedSourceBranch,
2415
- approvedMergeBaseCommit,
2416
- expectedWorkspaceRevision,
2417
- );
2418
- if (result.ok && fresh.data.request.operation === "hosting.pull_request") {
2419
- const observed = await readHostingAction(target.data, fresh.data, {
2420
- kind: "host",
2421
- host: caller?.host ?? "workit_cli",
2422
- handle: `post-create:${fresh.data.marker ?? "unknown"}`,
2423
- });
2424
- const output = (result.data as { output?: unknown }).output;
2425
- const id =
2426
- typeof output === "string"
2427
- ? /\/(?:pull|merge_requests)\/(\d+)(?:[/?#]|$)/.exec(output)?.[1]
2428
- : null;
2429
- if (!observed.ok || !id || String(observed.data.data?.id) !== id)
2430
- return unknown("hosting.pull_request");
2431
- }
2432
- if (!result.ok && result.code === "external_outcome_unknown" && caller) {
2433
- const observed = await readExternalAction(root, fresh.data, {
2434
- kind: "host",
2435
- host: caller.host,
2436
- handle: `local-recovery:${fresh.data.request.operation}`,
2437
- });
2438
- if (observed.ok) return success(null, null, { recovered: true });
2439
- }
2440
- return result;
2441
- };