@brainervirus/workit-core 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +31 -8
- package/src/core/auto-approval.ts +44 -32
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +1102 -209
- package/src/core/external-action.ts +230 -103
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +56 -52
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +16 -3
- package/src/core/task-engine.ts +136 -31
- package/src/core/task-evaluation.ts +3 -5
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workers.ts +17 -0
- package/src/core/workspaces.ts +548 -46
- package/src/core.ts +1 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
|
+
branchPolicySnapshotFor,
|
|
2
3
|
branchSetup,
|
|
3
4
|
classifyBranchDirt,
|
|
5
|
+
commitPolicySnapshotFor,
|
|
4
6
|
resolveBranchPolicyFor,
|
|
5
|
-
|
|
7
|
+
validateBranchNamePolicy,
|
|
8
|
+
validateCommitMessagePolicy,
|
|
6
9
|
} from "./branch";
|
|
7
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
hostingCliAvailable,
|
|
12
|
+
mergePr,
|
|
13
|
+
prCreate,
|
|
14
|
+
pushRemote,
|
|
15
|
+
pushRemoteIdentity,
|
|
16
|
+
pushTargetIsStable,
|
|
17
|
+
safePushUrl,
|
|
18
|
+
} from "./pr-create";
|
|
8
19
|
import {
|
|
9
20
|
changelogContext,
|
|
10
21
|
docsRefreshContext,
|
|
@@ -25,16 +36,29 @@ import {
|
|
|
25
36
|
} from "./youtrack";
|
|
26
37
|
import fs from "node:fs";
|
|
27
38
|
import path from "node:path";
|
|
28
|
-
import {
|
|
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";
|
|
29
50
|
import { changelogApply, changelogApplyPreview } from "./changelog";
|
|
30
51
|
import type { ExternalActionRequest } from "./external-action";
|
|
31
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
branchSetupIntent,
|
|
54
|
+
externalActionDescriptor,
|
|
55
|
+
externalActionRequest,
|
|
56
|
+
} from "./external-action";
|
|
32
57
|
import { normalizeChainSteps } from "./authority";
|
|
33
58
|
import { resolveInside, run as coreRun } from "../core";
|
|
34
|
-
import { vcsConfig } from "./vcs-config";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import { TaskStore } from "./task-store";
|
|
59
|
+
import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
|
|
60
|
+
import { assertProductWriteAllowed, currentWriterOwnsTask } from "./workers";
|
|
61
|
+
import { sameDirectoryIdentity, TaskStore } from "./task-store";
|
|
38
62
|
|
|
39
63
|
const run = (root: string, args: string[]) => {
|
|
40
64
|
return coreRun(root, "git", args);
|
|
@@ -70,10 +94,6 @@ const stagedPaths = (root: string): string[] | null => {
|
|
|
70
94
|
return [...new Set(paths)];
|
|
71
95
|
};
|
|
72
96
|
|
|
73
|
-
const pushRemote = (root: string): string | null =>
|
|
74
|
-
gitValue(root, ["remote", "get-url", "--push", "origin"]) ??
|
|
75
|
-
gitValue(root, ["remote", "get-url", "origin"]);
|
|
76
|
-
|
|
77
97
|
type RemoteParts = { host: string; path: string; protocol: string };
|
|
78
98
|
|
|
79
99
|
const remoteParts = (value: string): RemoteParts | null => {
|
|
@@ -100,11 +120,33 @@ const remoteParts = (value: string): RemoteParts | null => {
|
|
|
100
120
|
}
|
|
101
121
|
};
|
|
102
122
|
|
|
103
|
-
const credentialFreeRemote = (value: string): string | null =>
|
|
104
|
-
|
|
105
|
-
|
|
123
|
+
const credentialFreeRemote = (value: string): string | null => pushRemoteIdentity(value);
|
|
124
|
+
|
|
125
|
+
/** The task root stores coordination; a Git effect runs in the checkout named by the action. */
|
|
126
|
+
const actionRoot = (taskRoot: string, request: ExternalActionRequest): Result<string> => {
|
|
127
|
+
const chosen = "cwd" in request.payload ? request.payload.cwd : undefined;
|
|
128
|
+
try {
|
|
129
|
+
const directory = fs.realpathSync(chosen ? path.resolve(taskRoot, chosen) : taskRoot);
|
|
130
|
+
if (request.operation === "context.read") return success(null, null, directory);
|
|
131
|
+
const gitAction =
|
|
132
|
+
request.operation.startsWith("git.") || request.operation.startsWith("hosting.");
|
|
133
|
+
if (!gitAction) return success(null, null, directory);
|
|
134
|
+
const checkout = gitValue(directory, ["rev-parse", "--show-toplevel"]);
|
|
135
|
+
if (!checkout)
|
|
136
|
+
return failure("capability_unavailable", "action target is not a Git repository", {
|
|
137
|
+
outcome: "not_started",
|
|
138
|
+
});
|
|
139
|
+
return success(null, null, fs.realpathSync(checkout));
|
|
140
|
+
} catch {
|
|
141
|
+
return failure("invalid_input", "action target directory cannot be resolved", {
|
|
142
|
+
outcome: "not_started",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
106
145
|
};
|
|
107
146
|
|
|
147
|
+
const bindsActionTarget = (operation: ExternalActionRequest["operation"]): boolean =>
|
|
148
|
+
operation === "context.read" || operation.startsWith("git.") || operation.startsWith("hosting.");
|
|
149
|
+
|
|
108
150
|
const unknown = (operation: string) =>
|
|
109
151
|
failure("external_outcome_unknown", "external action outcome is unknown", { operation });
|
|
110
152
|
|
|
@@ -201,26 +243,34 @@ const localAction = (operation: ExternalActionRequest["operation"]): boolean =>
|
|
|
201
243
|
operation === "git.commit" ||
|
|
202
244
|
operation === "git.push" ||
|
|
203
245
|
operation === "hosting.pull_request" ||
|
|
246
|
+
operation === "hosting.merge" ||
|
|
247
|
+
operation === "hosting.delete_branch" ||
|
|
204
248
|
operation === "changelog.apply";
|
|
205
249
|
|
|
250
|
+
const targetWriterActionLease = Symbol("target-writer-action-lease");
|
|
251
|
+
|
|
206
252
|
export const assertLocalExternalActionWriter = (
|
|
207
253
|
root: string,
|
|
208
254
|
caller: { host: Caller["host"]; actor: string },
|
|
209
255
|
paths: string[] = ["."],
|
|
210
|
-
|
|
256
|
+
expectedWorkspaceRevision?: string,
|
|
257
|
+
): Result<Owner> => {
|
|
211
258
|
const store = new TaskStore(root);
|
|
212
259
|
const listed = store.listTasks();
|
|
213
260
|
const workspace = store.readWorkspace();
|
|
214
261
|
if (!listed.ok) return listed as Result<never>;
|
|
215
262
|
if (!workspace.ok) return workspace as Result<never>;
|
|
216
263
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
264
|
+
if (
|
|
265
|
+
expectedWorkspaceRevision !== undefined &&
|
|
266
|
+
workspace.data.revision !== expectedWorkspaceRevision
|
|
267
|
+
)
|
|
268
|
+
return failure("revision_conflict", "checkout writer generation changed before the effect", {
|
|
269
|
+
expectedWorkspaceRevision,
|
|
270
|
+
actualWorkspaceRevision: workspace.data.revision,
|
|
271
|
+
});
|
|
272
|
+
const candidates = listed.data.filter((task) =>
|
|
273
|
+
currentWriterOwnsTask(task, workspace.data!, caller),
|
|
224
274
|
);
|
|
225
275
|
if (candidates.length !== 1)
|
|
226
276
|
return failure("permission_denied", "local external action requires exactly one active task");
|
|
@@ -231,7 +281,7 @@ export const assertLocalExternalActionWriter = (
|
|
|
231
281
|
paths,
|
|
232
282
|
store,
|
|
233
283
|
});
|
|
234
|
-
return allowed
|
|
284
|
+
return allowed;
|
|
235
285
|
};
|
|
236
286
|
|
|
237
287
|
const safePath = (root: string, value: string): string | null => {
|
|
@@ -248,28 +298,41 @@ const branchCreationBaseline = (
|
|
|
248
298
|
root: string,
|
|
249
299
|
target: string,
|
|
250
300
|
base: string,
|
|
251
|
-
): {
|
|
301
|
+
): {
|
|
302
|
+
base_branch: string;
|
|
303
|
+
target_exists: boolean;
|
|
304
|
+
local_base: string | null;
|
|
305
|
+
remote_base: string | null;
|
|
306
|
+
target_head: string | null;
|
|
307
|
+
} => {
|
|
252
308
|
const exists =
|
|
253
309
|
gitValue(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]) !== null;
|
|
254
|
-
if (exists)
|
|
310
|
+
if (exists)
|
|
311
|
+
return {
|
|
312
|
+
base_branch: base,
|
|
313
|
+
target_exists: true,
|
|
314
|
+
local_base: null,
|
|
315
|
+
remote_base: null,
|
|
316
|
+
target_head: gitValue(root, ["rev-parse", `refs/heads/${target}`]),
|
|
317
|
+
};
|
|
255
318
|
const remote = run(root, ["ls-remote", "origin", `refs/heads/${base}`]);
|
|
256
319
|
const sha = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : "";
|
|
320
|
+
const remoteBase = /^[0-9a-f]{40}$/.test(sha ?? "") ? (sha as string) : null;
|
|
257
321
|
return {
|
|
258
322
|
base_branch: base,
|
|
259
323
|
target_exists: false,
|
|
260
|
-
|
|
324
|
+
local_base: gitValue(root, ["rev-parse", "--verify", `refs/heads/${base}`]),
|
|
325
|
+
remote_base: remoteBase,
|
|
326
|
+
target_head: remoteBase,
|
|
261
327
|
};
|
|
262
328
|
};
|
|
263
329
|
|
|
264
330
|
const shortSha = (value: unknown): string =>
|
|
265
331
|
typeof value === "string" && value ? value.slice(0, 8) : "unknown";
|
|
266
332
|
|
|
267
|
-
const isProtectedBranch = (root: string, branch: string): boolean =>
|
|
268
|
-
resolveBranchPolicyFor(root).protected.has(branch.toLowerCase());
|
|
269
|
-
|
|
270
333
|
/** Concise, user-facing approval text for one resolved action. The exact
|
|
271
334
|
* descriptor stays machine-facing; this is what the native question shows. */
|
|
272
|
-
|
|
335
|
+
const actionProposalAtRoot = (
|
|
273
336
|
request: ExternalActionRequest,
|
|
274
337
|
descriptorPayload: unknown,
|
|
275
338
|
): { presented: string; approvedText: string } => {
|
|
@@ -329,14 +392,19 @@ export const actionProposalQuestion = (
|
|
|
329
392
|
approvedText: `Switch to \`${target}\`.`,
|
|
330
393
|
};
|
|
331
394
|
const base = String(resolved.base_branch ?? "the base branch");
|
|
395
|
+
const localBase =
|
|
396
|
+
typeof resolved.local_base === "string"
|
|
397
|
+
? `local ${shortSha(resolved.local_base)}`
|
|
398
|
+
: "no local base";
|
|
399
|
+
const remoteBase = shortSha(resolved.remote_base);
|
|
332
400
|
return stashes
|
|
333
401
|
? {
|
|
334
|
-
presented: `Workit decision: action — Stash
|
|
335
|
-
approvedText: `Stash and create \`${target}\` from \`${base}\`
|
|
402
|
+
presented: `Workit decision: action — Stash and create branch \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase})?`,
|
|
403
|
+
approvedText: `Stash and create \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase}).`,
|
|
336
404
|
}
|
|
337
405
|
: {
|
|
338
|
-
presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (
|
|
339
|
-
approvedText: `Create \`${target}\` from \`${base}\`
|
|
406
|
+
presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase})?`,
|
|
407
|
+
approvedText: `Create \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase}).`,
|
|
340
408
|
};
|
|
341
409
|
}
|
|
342
410
|
case "git.commit": {
|
|
@@ -348,18 +416,29 @@ export const actionProposalQuestion = (
|
|
|
348
416
|
}
|
|
349
417
|
case "git.push":
|
|
350
418
|
return {
|
|
351
|
-
presented: `Workit decision: action — Push branch \`${String(payload.branch ?? "")}\` to
|
|
352
|
-
approvedText: `Push \`${String(payload.branch ?? "")}\` to
|
|
419
|
+
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)}` : ""}?`,
|
|
420
|
+
approvedText: `Push \`${String(payload.branch ?? "")}\` to ${String(resolved.remote ?? "unknown remote")} via ${String(resolved.apiHost ?? "Git transport")}${resolved.account ? ` as ${String(resolved.account)}` : ""}.`,
|
|
353
421
|
};
|
|
354
|
-
case "hosting.pull_request":
|
|
422
|
+
case "hosting.pull_request": {
|
|
423
|
+
const sourceCommit = shortSha(resolved.source_commit);
|
|
424
|
+
const mergeBase =
|
|
425
|
+
typeof resolved.merge_base_commit === "string"
|
|
426
|
+
? ` on local merge base ${shortSha(resolved.merge_base_commit)}`
|
|
427
|
+
: "";
|
|
355
428
|
return {
|
|
356
|
-
presented: `Workit decision: action — Open a PR from \`${String(resolved.source_branch ?? "")}
|
|
357
|
-
approvedText: `Open the PR: ${String(resolved.source_branch ?? "")} → ${payloadTarget}, "${String(request.payload.title)}".`,
|
|
429
|
+
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)}"?`,
|
|
430
|
+
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)}".`,
|
|
358
431
|
};
|
|
432
|
+
}
|
|
359
433
|
case "hosting.merge":
|
|
360
434
|
return {
|
|
361
|
-
presented: `Workit decision: action — Merge \`${String(resolved.source_branch ?? "")}\` into \`${payloadTarget}\` (squash, delete branch)?`,
|
|
362
|
-
approvedText: `Merge ${String(resolved.source_branch ?? "")} → ${payloadTarget}.`,
|
|
435
|
+
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)?`,
|
|
436
|
+
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)}` : ""}.`,
|
|
437
|
+
};
|
|
438
|
+
case "hosting.delete_branch":
|
|
439
|
+
return {
|
|
440
|
+
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 ?? "")})?`,
|
|
441
|
+
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")}.`,
|
|
363
442
|
};
|
|
364
443
|
case "changelog.apply":
|
|
365
444
|
return {
|
|
@@ -389,6 +468,17 @@ export const actionProposalQuestion = (
|
|
|
389
468
|
}
|
|
390
469
|
};
|
|
391
470
|
|
|
471
|
+
export const actionProposalQuestion = (
|
|
472
|
+
request: ExternalActionRequest,
|
|
473
|
+
descriptorPayload: unknown,
|
|
474
|
+
): { presented: string; approvedText: string } => {
|
|
475
|
+
const proposal = actionProposalAtRoot(request, descriptorPayload);
|
|
476
|
+
const cwd = (descriptorPayload as { cwd?: unknown })?.cwd;
|
|
477
|
+
return typeof cwd === "string"
|
|
478
|
+
? { ...proposal, presented: `${proposal.presented} Target directory: ${cwd}` }
|
|
479
|
+
: proposal;
|
|
480
|
+
};
|
|
481
|
+
|
|
392
482
|
export type ResolvedExternalAction = {
|
|
393
483
|
request: ExternalActionRequest;
|
|
394
484
|
descriptorPayload: unknown;
|
|
@@ -408,8 +498,12 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
408
498
|
try {
|
|
409
499
|
const value = JSON.parse(content) as { operation?: unknown; payload?: unknown };
|
|
410
500
|
if (
|
|
501
|
+
value.operation !== "git.branch_setup" &&
|
|
502
|
+
value.operation !== "git.commit" &&
|
|
503
|
+
value.operation !== "git.push" &&
|
|
411
504
|
value.operation !== "hosting.pull_request" &&
|
|
412
505
|
value.operation !== "hosting.merge" &&
|
|
506
|
+
value.operation !== "hosting.delete_branch" &&
|
|
413
507
|
value.operation !== "changelog.apply" &&
|
|
414
508
|
value.operation !== "youtrack.update" &&
|
|
415
509
|
value.operation !== "youtrack.time" &&
|
|
@@ -425,6 +519,28 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
425
519
|
if (!request.ok || !resolved || typeof resolved !== "object")
|
|
426
520
|
return failure("invalid_input", "approved hosting target is incomplete");
|
|
427
521
|
const target = resolved as Record<string, unknown>;
|
|
522
|
+
if (
|
|
523
|
+
value.operation === "git.commit" &&
|
|
524
|
+
(typeof target.head !== "string" ||
|
|
525
|
+
typeof target.staged !== "string" ||
|
|
526
|
+
typeof payload.message !== "string")
|
|
527
|
+
)
|
|
528
|
+
return failure("invalid_input", "approved Git commit target is incomplete");
|
|
529
|
+
if (
|
|
530
|
+
value.operation === "git.push" &&
|
|
531
|
+
(typeof target.branch !== "string" ||
|
|
532
|
+
typeof target.commit !== "string" ||
|
|
533
|
+
typeof target.remote !== "string")
|
|
534
|
+
)
|
|
535
|
+
return failure("invalid_input", "approved Git push target is incomplete");
|
|
536
|
+
if (
|
|
537
|
+
value.operation === "git.branch_setup" &&
|
|
538
|
+
(typeof target.head !== "string" ||
|
|
539
|
+
(payload.action === "reapply_stash"
|
|
540
|
+
? typeof target.stash_commit !== "string"
|
|
541
|
+
: typeof payload.target_branch !== "string" || typeof target.target_head !== "string"))
|
|
542
|
+
)
|
|
543
|
+
return failure("invalid_input", "approved Git branch target is incomplete");
|
|
428
544
|
if (
|
|
429
545
|
value.operation === "hosting.pull_request" &&
|
|
430
546
|
(typeof target.marker !== "string" ||
|
|
@@ -443,6 +559,13 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
443
559
|
typeof payload.target_branch !== "string")
|
|
444
560
|
)
|
|
445
561
|
return failure("invalid_input", "approved hosting target is incomplete");
|
|
562
|
+
if (
|
|
563
|
+
value.operation === "hosting.delete_branch" &&
|
|
564
|
+
(typeof target.tip !== "string" ||
|
|
565
|
+
typeof target.remote !== "string" ||
|
|
566
|
+
typeof target.pr !== "string")
|
|
567
|
+
)
|
|
568
|
+
return failure("invalid_input", "approved branch deletion target is incomplete");
|
|
446
569
|
if (
|
|
447
570
|
value.operation === "changelog.apply" &&
|
|
448
571
|
(typeof target.target !== "string" ||
|
|
@@ -452,7 +575,12 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
452
575
|
)
|
|
453
576
|
return failure("invalid_input", "approved changelog target is incomplete");
|
|
454
577
|
if (
|
|
578
|
+
value.operation !== "git.branch_setup" &&
|
|
579
|
+
value.operation !== "git.commit" &&
|
|
580
|
+
value.operation !== "git.push" &&
|
|
455
581
|
value.operation !== "hosting.pull_request" &&
|
|
582
|
+
value.operation !== "hosting.merge" &&
|
|
583
|
+
value.operation !== "hosting.delete_branch" &&
|
|
456
584
|
value.operation !== "changelog.apply" &&
|
|
457
585
|
(typeof target.marker !== "string" ||
|
|
458
586
|
typeof target.baseUrl !== "string" ||
|
|
@@ -463,7 +591,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
463
591
|
return success(null, null, {
|
|
464
592
|
request: request.data,
|
|
465
593
|
descriptorPayload: value.payload,
|
|
466
|
-
marker:
|
|
594
|
+
...(typeof target.marker === "string" ? { marker: target.marker } : {}),
|
|
467
595
|
});
|
|
468
596
|
} catch {
|
|
469
597
|
return failure("invalid_input", "approved action descriptor is invalid");
|
|
@@ -477,16 +605,151 @@ const remoteRepo = (value: string): string | null => {
|
|
|
477
605
|
return repo.includes("/") ? repo : null;
|
|
478
606
|
};
|
|
479
607
|
|
|
480
|
-
const remoteIdentity = (value: string): string | null =>
|
|
481
|
-
const parts = remoteParts(value);
|
|
482
|
-
return parts ? `${parts.host}${parts.path.replace(/\.git$/, "")}` : null;
|
|
483
|
-
};
|
|
608
|
+
const remoteIdentity = (value: string): string | null => pushRemoteIdentity(value);
|
|
484
609
|
|
|
485
610
|
const remoteProvider = (value: string): "github" | "gitlab" | null => {
|
|
486
611
|
const host = remoteParts(value)?.host;
|
|
487
612
|
return host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null;
|
|
488
613
|
};
|
|
489
614
|
|
|
615
|
+
const hostingCliEnv = (provider: "github" | "gitlab", host: string): NodeJS.ProcessEnv => ({
|
|
616
|
+
...process.env,
|
|
617
|
+
PATH: process.env.PATH ?? "",
|
|
618
|
+
...(provider === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const hostingProvider = (root: string, remote: string): "github" | "gitlab" | null => {
|
|
622
|
+
const known = remoteProvider(remote);
|
|
623
|
+
if (known) return known;
|
|
624
|
+
const parts = remoteParts(remote);
|
|
625
|
+
if (!parts?.host || (parts.protocol !== "ssh:" && parts.protocol !== "https:")) return null;
|
|
626
|
+
const cfg = vcsConfig("load", root);
|
|
627
|
+
return cfg.ok && (cfg.provider === "github" || cfg.provider === "gitlab") ? cfg.provider : null;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const remoteBranchTip = (
|
|
631
|
+
root: string,
|
|
632
|
+
provider: "github" | "gitlab",
|
|
633
|
+
host: string,
|
|
634
|
+
repo: string,
|
|
635
|
+
branch: string,
|
|
636
|
+
): Result<string | null> => {
|
|
637
|
+
const endpoint =
|
|
638
|
+
provider === "github"
|
|
639
|
+
? `repos/${repo}/git/ref/heads/${branch.split("/").map(encodeURIComponent).join("/")}`
|
|
640
|
+
: `projects/${encodeURIComponent(repo)}/repository/branches/${encodeURIComponent(branch)}`;
|
|
641
|
+
const response = spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
|
|
642
|
+
cwd: root,
|
|
643
|
+
encoding: "utf8",
|
|
644
|
+
env: hostingCliEnv(provider, host),
|
|
645
|
+
});
|
|
646
|
+
if (response.status !== 0) {
|
|
647
|
+
if (/\b404\b/.test(response.stderr ?? "")) {
|
|
648
|
+
const project =
|
|
649
|
+
provider === "github" ? `repos/${repo}` : `projects/${encodeURIComponent(repo)}`;
|
|
650
|
+
const visible = spawnSync(provider === "github" ? "gh" : "glab", ["api", project], {
|
|
651
|
+
cwd: root,
|
|
652
|
+
encoding: "utf8",
|
|
653
|
+
env: hostingCliEnv(provider, host),
|
|
654
|
+
});
|
|
655
|
+
try {
|
|
656
|
+
const record = JSON.parse(visible.stdout ?? "") as { id?: unknown };
|
|
657
|
+
if (visible.status === 0 && typeof record.id === "number") return success(null, null, null);
|
|
658
|
+
} catch {}
|
|
659
|
+
}
|
|
660
|
+
return failure("capability_unavailable", "remote branch could not be read", {
|
|
661
|
+
outcome: "not_started",
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
const parsed = JSON.parse(response.stdout ?? "") as Record<string, any>;
|
|
666
|
+
const tip = provider === "github" ? parsed.object?.sha : parsed.commit?.id;
|
|
667
|
+
return typeof tip === "string" && /^[a-f0-9]{40,64}$/.test(tip)
|
|
668
|
+
? success(null, null, tip)
|
|
669
|
+
: failure("capability_unavailable", "remote branch response is incomplete", {
|
|
670
|
+
outcome: "not_started",
|
|
671
|
+
});
|
|
672
|
+
} catch {
|
|
673
|
+
return failure("capability_unavailable", "remote branch response is invalid", {
|
|
674
|
+
outcome: "not_started",
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const mergedBranch = (root: string, branch: string): Result<Record<string, string>> => {
|
|
680
|
+
if (run(root, ["check-ref-format", "--branch", branch]).exitCode !== 0)
|
|
681
|
+
return failure("invalid_input", "branch name is invalid", { outcome: "not_started" });
|
|
682
|
+
const remote = pushRemote(root);
|
|
683
|
+
const identity = remote ? credentialFreeRemote(remote) : null;
|
|
684
|
+
const repo = remote ? remoteRepo(remote) : null;
|
|
685
|
+
const provider = remote ? hostingProvider(root, remote) : null;
|
|
686
|
+
if (!remote || !identity || !repo || !provider)
|
|
687
|
+
return failure("capability_unavailable", "branch remote is unavailable", {
|
|
688
|
+
outcome: "not_started",
|
|
689
|
+
});
|
|
690
|
+
const auth = vcsCliIdentity(root);
|
|
691
|
+
if (!auth.ok || auth.provider !== provider)
|
|
692
|
+
return failure("capability_unavailable", "hosting CLI identity is unavailable", {
|
|
693
|
+
outcome: "not_started",
|
|
694
|
+
});
|
|
695
|
+
if (!hostingApiHostMatches(remote, auth.host))
|
|
696
|
+
return failure(
|
|
697
|
+
"capability_unavailable",
|
|
698
|
+
"hosting API host does not match the push destination",
|
|
699
|
+
{
|
|
700
|
+
outcome: "not_started",
|
|
701
|
+
},
|
|
702
|
+
);
|
|
703
|
+
const live = remoteBranchTip(root, provider, auth.host, repo, branch);
|
|
704
|
+
if (!live.ok) return live as Result<never>;
|
|
705
|
+
const tip = live.data;
|
|
706
|
+
if (!tip)
|
|
707
|
+
return failure("invalid_input", "remote branch is absent or could not be read", {
|
|
708
|
+
outcome: "not_started",
|
|
709
|
+
});
|
|
710
|
+
const endpoint =
|
|
711
|
+
provider === "github"
|
|
712
|
+
? `repos/${repo}/pulls?state=closed&head=${encodeURIComponent(`${repo.split("/")[0]}:${branch}`)}&per_page=100`
|
|
713
|
+
: `projects/${encodeURIComponent(repo)}/merge_requests?state=merged&source_branch=${encodeURIComponent(branch)}&per_page=100`;
|
|
714
|
+
const response = spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
|
|
715
|
+
cwd: root,
|
|
716
|
+
encoding: "utf8",
|
|
717
|
+
env: hostingCliEnv(provider, auth.host),
|
|
718
|
+
});
|
|
719
|
+
if (response.status !== 0)
|
|
720
|
+
return failure("capability_unavailable", "merged branch evidence is unavailable", {
|
|
721
|
+
outcome: "not_started",
|
|
722
|
+
});
|
|
723
|
+
try {
|
|
724
|
+
const records = JSON.parse(response.stdout ?? "") as unknown;
|
|
725
|
+
if (!Array.isArray(records) || records.length >= 100) throw new Error("incomplete PR listing");
|
|
726
|
+
const match = records.find(
|
|
727
|
+
(record) =>
|
|
728
|
+
record &&
|
|
729
|
+
typeof record === "object" &&
|
|
730
|
+
(provider === "github" ? record.head?.sha === tip : record.sha === tip) &&
|
|
731
|
+
typeof record.merged_at === "string" &&
|
|
732
|
+
(provider === "github" ? record.head?.ref === branch : record.source_branch === branch),
|
|
733
|
+
);
|
|
734
|
+
const id = match?.number ?? match?.iid;
|
|
735
|
+
if (typeof id !== "number") throw new Error("no exact merged head");
|
|
736
|
+
return success(null, null, {
|
|
737
|
+
branch,
|
|
738
|
+
tip,
|
|
739
|
+
remote: identity,
|
|
740
|
+
provider,
|
|
741
|
+
repo,
|
|
742
|
+
pr: String(id),
|
|
743
|
+
account: String(auth.username),
|
|
744
|
+
apiHost: String(auth.host),
|
|
745
|
+
});
|
|
746
|
+
} catch {
|
|
747
|
+
return failure("capability_unavailable", "branch tip does not match a merged PR head", {
|
|
748
|
+
outcome: "not_started",
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
|
|
490
753
|
const unknownHostingEvidence = (operation: string): Result<HostingReadEvidence> =>
|
|
491
754
|
failure("external_outcome_unknown", "hosting action outcome remains unknown", {
|
|
492
755
|
operation,
|
|
@@ -499,17 +762,15 @@ export const readHostingAction = async (
|
|
|
499
762
|
resolved: ResolvedExternalAction,
|
|
500
763
|
actionRef: Ref,
|
|
501
764
|
): Promise<Result<HostingReadEvidence>> => {
|
|
502
|
-
|
|
503
|
-
|
|
765
|
+
const operation = resolved.request.operation;
|
|
766
|
+
if ((operation !== "hosting.pull_request" && operation !== "hosting.merge") || !resolved.marker)
|
|
767
|
+
return unknownHostingEvidence(operation);
|
|
504
768
|
try {
|
|
505
769
|
const cfg = vcsConfig("load", root);
|
|
506
|
-
if (
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
(cfg.provider !== "github" && cfg.provider !== "gitlab")
|
|
511
|
-
)
|
|
512
|
-
return unknownHostingEvidence("hosting.pull_request");
|
|
770
|
+
if (!cfg.ok || !cfg.provider || (cfg.provider !== "github" && cfg.provider !== "gitlab"))
|
|
771
|
+
return unknownHostingEvidence(operation);
|
|
772
|
+
const auth = vcsCliIdentity(root);
|
|
773
|
+
if (!auth.ok || auth.provider !== cfg.provider) return unknownHostingEvidence(operation);
|
|
513
774
|
const remote = pushRemote(root);
|
|
514
775
|
const repo = remote ? remoteRepo(remote) : null;
|
|
515
776
|
const approved = resolved.descriptorPayload as {
|
|
@@ -524,18 +785,6 @@ export const readHostingAction = async (
|
|
|
524
785
|
const currentIdentity = remote ? remoteIdentity(remote) : null;
|
|
525
786
|
const approvedIdentity =
|
|
526
787
|
typeof approvedRemote === "string" ? remoteIdentity(approvedRemote) : null;
|
|
527
|
-
const apiHost =
|
|
528
|
-
cfg.provider === "github"
|
|
529
|
-
? "github.com"
|
|
530
|
-
: (() => {
|
|
531
|
-
try {
|
|
532
|
-
return new URL(
|
|
533
|
-
cfg.gitlab?.apiUrl ?? "https://gitlab.com/api/v4",
|
|
534
|
-
).hostname.toLowerCase();
|
|
535
|
-
} catch {
|
|
536
|
-
return "";
|
|
537
|
-
}
|
|
538
|
-
})();
|
|
539
788
|
if (
|
|
540
789
|
!repo ||
|
|
541
790
|
typeof branch !== "string" ||
|
|
@@ -545,29 +794,29 @@ export const readHostingAction = async (
|
|
|
545
794
|
!currentIdentity ||
|
|
546
795
|
currentIdentity !== approvedIdentity ||
|
|
547
796
|
repo !== remoteRepo(approvedRemote) ||
|
|
548
|
-
(
|
|
549
|
-
|
|
550
|
-
|
|
797
|
+
hostingProvider(root, approvedRemote) !== cfg.provider ||
|
|
798
|
+
!hostingApiHostMatches(approvedRemote, auth.host) ||
|
|
799
|
+
(typeof source?.apiHost === "string" && source.apiHost !== auth.host)
|
|
551
800
|
)
|
|
552
|
-
return unknownHostingEvidence(
|
|
553
|
-
const
|
|
554
|
-
const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" };
|
|
555
|
-
const url =
|
|
801
|
+
return unknownHostingEvidence(operation);
|
|
802
|
+
const endpoint =
|
|
556
803
|
cfg.provider === "github"
|
|
557
|
-
? `
|
|
558
|
-
:
|
|
559
|
-
const
|
|
560
|
-
|
|
804
|
+
? `repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${repo.split("/")[0]}:${branch}`)}&base=${encodeURIComponent(target)}&per_page=100&page=1`
|
|
805
|
+
: `projects/${encodeURIComponent(repo)}/merge_requests?state=all&source_branch=${encodeURIComponent(branch)}&target_branch=${encodeURIComponent(target)}&per_page=100&page=1`;
|
|
806
|
+
const cli = cfg.provider === "github" ? "gh" : "glab";
|
|
807
|
+
const response = spawnSync(cli, ["api", endpoint], {
|
|
808
|
+
cwd: root,
|
|
809
|
+
encoding: "utf8",
|
|
810
|
+
env: hostingCliEnv(cfg.provider, auth.host),
|
|
561
811
|
});
|
|
562
|
-
if (
|
|
563
|
-
const values: unknown =
|
|
812
|
+
if (response.status !== 0) return unknownHostingEvidence(operation);
|
|
813
|
+
const values: unknown = JSON.parse(response.stdout ?? "");
|
|
564
814
|
// A full page is intentionally treated as truncated until pagination is added.
|
|
565
|
-
if (!Array.isArray(values) || values.length >= 100)
|
|
566
|
-
return unknownHostingEvidence("hosting.pull_request");
|
|
815
|
+
if (!Array.isArray(values) || values.length >= 100) return unknownHostingEvidence(operation);
|
|
567
816
|
const records: Record<string, unknown>[] = [];
|
|
568
817
|
for (const item of values) {
|
|
569
818
|
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
570
|
-
return unknownHostingEvidence(
|
|
819
|
+
return unknownHostingEvidence(operation);
|
|
571
820
|
const record = item as Record<string, unknown>;
|
|
572
821
|
const body = record.body ?? record.description;
|
|
573
822
|
const base = record.base;
|
|
@@ -609,35 +858,48 @@ export const readHostingAction = async (
|
|
|
609
858
|
typeof shaValue !== "string" ||
|
|
610
859
|
(typeof idValue !== "string" && typeof idValue !== "number")
|
|
611
860
|
)
|
|
612
|
-
return unknownHostingEvidence(
|
|
861
|
+
return unknownHostingEvidence(operation);
|
|
613
862
|
records.push(record);
|
|
614
863
|
}
|
|
615
|
-
const matches = records.filter((item)
|
|
616
|
-
const body = String(
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
cfg.provider === "github" ? head?.ref : (item as Record<string, unknown>).source_branch;
|
|
627
|
-
const itemSha = cfg.provider === "github" ? head?.sha : (item as Record<string, unknown>).sha;
|
|
864
|
+
const matches = records.filter((item) => {
|
|
865
|
+
const body = String(item.body ?? item.description ?? "");
|
|
866
|
+
const base = item.base as Record<string, unknown> | undefined;
|
|
867
|
+
const head = item.head as Record<string, unknown> | undefined;
|
|
868
|
+
const itemTarget = cfg.provider === "github" ? base?.ref : item.target_branch;
|
|
869
|
+
const itemSource = cfg.provider === "github" ? head?.ref : item.source_branch;
|
|
870
|
+
const itemSha = cfg.provider === "github" ? head?.sha : item.sha;
|
|
871
|
+
const merged =
|
|
872
|
+
cfg.provider === "github"
|
|
873
|
+
? typeof item.merged_at === "string"
|
|
874
|
+
: item.state === "merged" || typeof item.merged_at === "string";
|
|
628
875
|
return (
|
|
629
|
-
body.includes(resolved.marker!) &&
|
|
876
|
+
(operation === "hosting.merge" ? merged : body.includes(resolved.marker!)) &&
|
|
630
877
|
itemTarget === target &&
|
|
631
878
|
itemSource === branch &&
|
|
632
879
|
itemSha === sourceCommit
|
|
633
880
|
);
|
|
634
881
|
});
|
|
635
|
-
if (
|
|
882
|
+
if (
|
|
883
|
+
operation === "hosting.merge" &&
|
|
884
|
+
records.some((item) => {
|
|
885
|
+
const base = item.base as Record<string, unknown> | undefined;
|
|
886
|
+
const head = item.head as Record<string, unknown> | undefined;
|
|
887
|
+
return (
|
|
888
|
+
(cfg.provider === "github" ? base?.ref : item.target_branch) === target &&
|
|
889
|
+
(cfg.provider === "github" ? head?.ref : item.source_branch) === branch &&
|
|
890
|
+
(cfg.provider === "github"
|
|
891
|
+
? typeof item.merged_at !== "string"
|
|
892
|
+
: item.state !== "merged" && typeof item.merged_at !== "string")
|
|
893
|
+
);
|
|
894
|
+
})
|
|
895
|
+
)
|
|
896
|
+
return unknownHostingEvidence(operation);
|
|
897
|
+
if (matches.length !== 1) return unknownHostingEvidence(operation);
|
|
636
898
|
const item = matches[0];
|
|
637
899
|
const id = item.number ?? item.iid ?? item.id;
|
|
638
|
-
if (typeof id !== "string" && typeof id !== "number")
|
|
639
|
-
return unknownHostingEvidence("hosting.pull_request");
|
|
900
|
+
if (typeof id !== "string" && typeof id !== "number") return unknownHostingEvidence(operation);
|
|
640
901
|
const evidenceDigest = sha256({
|
|
902
|
+
operation,
|
|
641
903
|
provider: cfg.provider,
|
|
642
904
|
id,
|
|
643
905
|
marker: resolved.marker,
|
|
@@ -658,7 +920,7 @@ export const readHostingAction = async (
|
|
|
658
920
|
data: { provider: cfg.provider, id },
|
|
659
921
|
});
|
|
660
922
|
} catch {
|
|
661
|
-
return unknownHostingEvidence(
|
|
923
|
+
return unknownHostingEvidence(operation);
|
|
662
924
|
}
|
|
663
925
|
};
|
|
664
926
|
|
|
@@ -812,14 +1074,152 @@ export const readYouTrackAction = async (
|
|
|
812
1074
|
}
|
|
813
1075
|
};
|
|
814
1076
|
|
|
1077
|
+
export const readLocalAction = async (
|
|
1078
|
+
root: string,
|
|
1079
|
+
resolved: ResolvedExternalAction,
|
|
1080
|
+
actionRef: Ref,
|
|
1081
|
+
): Promise<Result<HostingReadEvidence>> => {
|
|
1082
|
+
const operation = resolved.request.operation;
|
|
1083
|
+
const target = (resolved.descriptorPayload as { resolved?: Record<string, unknown> }).resolved;
|
|
1084
|
+
if (!target) return unknownHostingEvidence(operation);
|
|
1085
|
+
let observed: Record<string, unknown> | null = null;
|
|
1086
|
+
if (operation === "git.commit") {
|
|
1087
|
+
const commit = gitValue(root, ["rev-parse", "HEAD"]);
|
|
1088
|
+
const parent = commit ? gitValue(root, ["rev-parse", `${commit}^`]) : null;
|
|
1089
|
+
const message = commit ? gitValue(root, ["show", "-s", "--format=%B", commit]) : null;
|
|
1090
|
+
const diff =
|
|
1091
|
+
commit && parent
|
|
1092
|
+
? gitRaw(root, ["diff", "--raw", "-z", "--no-ext-diff", "--no-textconv", parent, commit])
|
|
1093
|
+
: null;
|
|
1094
|
+
if (
|
|
1095
|
+
commit &&
|
|
1096
|
+
parent === target.head &&
|
|
1097
|
+
message === resolved.request.payload.message &&
|
|
1098
|
+
diff !== null &&
|
|
1099
|
+
sha256(diff) === target.staged
|
|
1100
|
+
)
|
|
1101
|
+
observed = { commit, parent, message, staged: target.staged };
|
|
1102
|
+
} else if (operation === "git.push") {
|
|
1103
|
+
const branch = target.branch;
|
|
1104
|
+
const commit = target.commit;
|
|
1105
|
+
const approvedRemote = target.remote;
|
|
1106
|
+
const currentRemote = pushRemote(root);
|
|
1107
|
+
const currentIdentity = currentRemote ? credentialFreeRemote(currentRemote) : null;
|
|
1108
|
+
if (
|
|
1109
|
+
typeof branch === "string" &&
|
|
1110
|
+
typeof commit === "string" &&
|
|
1111
|
+
typeof approvedRemote === "string" &&
|
|
1112
|
+
currentIdentity === approvedRemote
|
|
1113
|
+
) {
|
|
1114
|
+
const remote = run(root, ["ls-remote", currentRemote!, `refs/heads/${branch}`]);
|
|
1115
|
+
const remoteCommit = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : null;
|
|
1116
|
+
if (remoteCommit === commit) observed = { branch, commit, remote: approvedRemote };
|
|
1117
|
+
}
|
|
1118
|
+
} else if (operation === "git.branch_setup") {
|
|
1119
|
+
const targetBranch = resolved.request.payload.target_branch;
|
|
1120
|
+
const sdd = resolved.request.payload.sdd_dir;
|
|
1121
|
+
if (resolved.request.payload.action === "reapply_stash" && typeof sdd === "string") {
|
|
1122
|
+
try {
|
|
1123
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
|
|
1124
|
+
stash_ref?: unknown;
|
|
1125
|
+
};
|
|
1126
|
+
const stashes = gitRaw(root, ["stash", "list", "--format=%H"]);
|
|
1127
|
+
if (
|
|
1128
|
+
typeof target.stash_commit === "string" &&
|
|
1129
|
+
manifest.stash_ref === undefined &&
|
|
1130
|
+
stashes !== null &&
|
|
1131
|
+
!stashes.split("\n").includes(target.stash_commit)
|
|
1132
|
+
)
|
|
1133
|
+
observed = { stash_commit: target.stash_commit };
|
|
1134
|
+
} catch {}
|
|
1135
|
+
} else if (
|
|
1136
|
+
typeof targetBranch === "string" &&
|
|
1137
|
+
typeof sdd === "string" &&
|
|
1138
|
+
gitValue(root, ["branch", "--show-current"]) === targetBranch &&
|
|
1139
|
+
typeof target.target_head === "string" &&
|
|
1140
|
+
gitValue(root, ["rev-parse", "HEAD"]) === target.target_head
|
|
1141
|
+
) {
|
|
1142
|
+
try {
|
|
1143
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
|
|
1144
|
+
branch?: unknown;
|
|
1145
|
+
};
|
|
1146
|
+
if (manifest.branch === targetBranch) observed = { branch: targetBranch };
|
|
1147
|
+
} catch {}
|
|
1148
|
+
}
|
|
1149
|
+
} else if (operation === "changelog.apply") {
|
|
1150
|
+
const afterDigest = target.afterDigest;
|
|
1151
|
+
const preview = changelogApplyPreview({ ...resolved.request.payload, workspace_root: root });
|
|
1152
|
+
if (!preview.error && typeof afterDigest === "string" && preview.beforeDigest === afterDigest)
|
|
1153
|
+
observed = { target: target.target, afterDigest };
|
|
1154
|
+
}
|
|
1155
|
+
if (!observed) return unknownHostingEvidence(operation);
|
|
1156
|
+
const evidenceDigest = sha256({ operation, observed });
|
|
1157
|
+
return success(null, null, {
|
|
1158
|
+
outcome: "succeeded",
|
|
1159
|
+
evidenceDigest,
|
|
1160
|
+
observation: {
|
|
1161
|
+
kind: "provider_read",
|
|
1162
|
+
actionRef,
|
|
1163
|
+
outcome: "succeeded",
|
|
1164
|
+
evidenceDigest,
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
};
|
|
1168
|
+
|
|
815
1169
|
export const readExternalAction = async (
|
|
816
1170
|
root: string,
|
|
817
1171
|
resolved: ResolvedExternalAction,
|
|
818
1172
|
actionRef: Ref,
|
|
819
|
-
): Promise<Result<HostingReadEvidence>> =>
|
|
820
|
-
resolved.request
|
|
821
|
-
|
|
822
|
-
|
|
1173
|
+
): Promise<Result<HostingReadEvidence>> => {
|
|
1174
|
+
const target = actionRoot(root, resolved.request);
|
|
1175
|
+
if (!target.ok) return unknownHostingEvidence(resolved.request.operation);
|
|
1176
|
+
root = target.data;
|
|
1177
|
+
return resolved.request.operation === "git.branch_setup" ||
|
|
1178
|
+
resolved.request.operation === "git.commit" ||
|
|
1179
|
+
resolved.request.operation === "git.push" ||
|
|
1180
|
+
resolved.request.operation === "changelog.apply"
|
|
1181
|
+
? readLocalAction(root, resolved, actionRef)
|
|
1182
|
+
: resolved.request.operation === "hosting.delete_branch"
|
|
1183
|
+
? readDeletedBranch(root, resolved, actionRef)
|
|
1184
|
+
: resolved.request.operation === "hosting.pull_request" ||
|
|
1185
|
+
resolved.request.operation === "hosting.merge"
|
|
1186
|
+
? readHostingAction(root, resolved, actionRef)
|
|
1187
|
+
: readYouTrackAction(root, resolved, actionRef);
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
const readDeletedBranch = async (
|
|
1191
|
+
root: string,
|
|
1192
|
+
resolved: ResolvedExternalAction,
|
|
1193
|
+
actionRef: Ref,
|
|
1194
|
+
): Promise<Result<HostingReadEvidence>> => {
|
|
1195
|
+
const details = (resolved.descriptorPayload as { resolved?: Record<string, unknown> }).resolved;
|
|
1196
|
+
if (
|
|
1197
|
+
!details ||
|
|
1198
|
+
typeof details.branch !== "string" ||
|
|
1199
|
+
typeof details.tip !== "string" ||
|
|
1200
|
+
typeof details.remote !== "string" ||
|
|
1201
|
+
credentialFreeRemote(pushRemote(root) ?? "") !== details.remote
|
|
1202
|
+
)
|
|
1203
|
+
return unknownHostingEvidence("hosting.delete_branch");
|
|
1204
|
+
const provider = details.provider;
|
|
1205
|
+
const repo = details.repo;
|
|
1206
|
+
const apiHost = details.apiHost;
|
|
1207
|
+
if (
|
|
1208
|
+
(provider !== "github" && provider !== "gitlab") ||
|
|
1209
|
+
typeof repo !== "string" ||
|
|
1210
|
+
typeof apiHost !== "string" ||
|
|
1211
|
+
!hostingApiHostMatches(pushRemote(root) ?? "", apiHost)
|
|
1212
|
+
)
|
|
1213
|
+
return unknownHostingEvidence("hosting.delete_branch");
|
|
1214
|
+
const live = remoteBranchTip(root, provider, apiHost, repo, details.branch);
|
|
1215
|
+
if (!live.ok || live.data !== null) return unknownHostingEvidence("hosting.delete_branch");
|
|
1216
|
+
const evidenceDigest = sha256(details);
|
|
1217
|
+
return success(null, null, {
|
|
1218
|
+
outcome: "succeeded",
|
|
1219
|
+
evidenceDigest,
|
|
1220
|
+
observation: { kind: "provider_read", actionRef, outcome: "succeeded", evidenceDigest },
|
|
1221
|
+
});
|
|
1222
|
+
};
|
|
823
1223
|
|
|
824
1224
|
const youTrackBase = (): string | null => {
|
|
825
1225
|
try {
|
|
@@ -862,11 +1262,13 @@ export const upgradeBranchSetupForStash = (
|
|
|
862
1262
|
resolved: Result<ResolvedExternalAction>,
|
|
863
1263
|
): Result<ResolvedExternalAction> => {
|
|
864
1264
|
if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
|
|
1265
|
+
const target = actionRoot(root, parsed);
|
|
865
1266
|
if (
|
|
866
1267
|
(resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
|
|
867
1268
|
true ||
|
|
868
1269
|
(resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
|
|
869
|
-
|
|
1270
|
+
!target.ok ||
|
|
1271
|
+
classifyBranchDirt(target.data) !== "stash-required"
|
|
870
1272
|
)
|
|
871
1273
|
return resolved;
|
|
872
1274
|
const upgraded = resolveExternalActionRequest(root, {
|
|
@@ -876,7 +1278,7 @@ export const upgradeBranchSetupForStash = (
|
|
|
876
1278
|
return upgraded.ok ? upgraded : resolved;
|
|
877
1279
|
};
|
|
878
1280
|
|
|
879
|
-
|
|
1281
|
+
const resolveExternalActionAtRoot = (
|
|
880
1282
|
root: string,
|
|
881
1283
|
request: ExternalActionRequest,
|
|
882
1284
|
preservedDateMs?: number,
|
|
@@ -887,7 +1289,8 @@ export const resolveExternalActionRequest = (
|
|
|
887
1289
|
const sdd_dir = safePath(root, request.payload.sdd_dir ?? "docs");
|
|
888
1290
|
if (!sdd_dir) return failure("invalid_input", "sdd_dir must stay inside the workspace");
|
|
889
1291
|
const action = request.payload.action ?? "setup";
|
|
890
|
-
const
|
|
1292
|
+
const policySnapshot = branchPolicySnapshotFor(root);
|
|
1293
|
+
const policy = policySnapshot.branchPolicy;
|
|
891
1294
|
const target_branch = request.payload.target_branch;
|
|
892
1295
|
if (action !== "reapply_stash" && !target_branch)
|
|
893
1296
|
return failure(
|
|
@@ -898,6 +1301,18 @@ export const resolveExternalActionRequest = (
|
|
|
898
1301
|
fields: [{ path: "target_branch", reason: "required for action=setup" }],
|
|
899
1302
|
},
|
|
900
1303
|
);
|
|
1304
|
+
if (target_branch) {
|
|
1305
|
+
const check = validateBranchNamePolicy(
|
|
1306
|
+
target_branch,
|
|
1307
|
+
policySnapshot.namePolicy,
|
|
1308
|
+
policySnapshot.source,
|
|
1309
|
+
);
|
|
1310
|
+
if (!check.ok)
|
|
1311
|
+
return failure("permission_denied", check.error, {
|
|
1312
|
+
outcome: "not_started",
|
|
1313
|
+
fields: [{ path: "target_branch", reason: check.rule }],
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
901
1316
|
const head = gitValue(root, ["rev-parse", "HEAD"]);
|
|
902
1317
|
if (!head) return failure("storage_error", "current Git commit could not be resolved");
|
|
903
1318
|
const dirty = Boolean((gitRaw(root, ["status", "--short"]) ?? "").trim());
|
|
@@ -905,6 +1320,17 @@ export const resolveExternalActionRequest = (
|
|
|
905
1320
|
action === "reapply_stash" || !target_branch
|
|
906
1321
|
? { base_branch: policy.defaultTargetBranch }
|
|
907
1322
|
: branchCreationBaseline(root, target_branch, policy.defaultTargetBranch);
|
|
1323
|
+
let stashTarget: { stash_ref: string; stash_commit: string } | undefined;
|
|
1324
|
+
if (action === "reapply_stash") {
|
|
1325
|
+
try {
|
|
1326
|
+
const manifest = JSON.parse(
|
|
1327
|
+
fs.readFileSync(path.join(sdd_dir, "manifest.json"), "utf8"),
|
|
1328
|
+
) as { stash_ref?: unknown };
|
|
1329
|
+
const stash_ref = typeof manifest.stash_ref === "string" ? manifest.stash_ref : "";
|
|
1330
|
+
const stash_commit = stash_ref ? gitValue(root, ["rev-parse", stash_ref]) : null;
|
|
1331
|
+
if (stash_ref && stash_commit) stashTarget = { stash_ref, stash_commit };
|
|
1332
|
+
} catch {}
|
|
1333
|
+
}
|
|
908
1334
|
const normalized = {
|
|
909
1335
|
operation: request.operation,
|
|
910
1336
|
payload: {
|
|
@@ -919,24 +1345,52 @@ export const resolveExternalActionRequest = (
|
|
|
919
1345
|
request: normalized,
|
|
920
1346
|
descriptorPayload: {
|
|
921
1347
|
...normalized.payload,
|
|
922
|
-
resolved: {
|
|
1348
|
+
resolved: {
|
|
1349
|
+
head,
|
|
1350
|
+
dirty,
|
|
1351
|
+
...creation,
|
|
1352
|
+
...stashTarget,
|
|
1353
|
+
...(target_branch ? { branchPolicyFingerprint: policySnapshot.fingerprint } : {}),
|
|
1354
|
+
},
|
|
923
1355
|
},
|
|
924
1356
|
});
|
|
925
1357
|
}
|
|
926
1358
|
case "git.push": {
|
|
927
1359
|
const branch = request.payload.branch ?? gitValue(root, ["branch", "--show-current"]);
|
|
928
|
-
if (branch && isProtectedBranch(root, branch))
|
|
929
|
-
return failure("invalid_input", `cannot push protected branch ${branch}`, {
|
|
930
|
-
outcome: "not_started",
|
|
931
|
-
fields: [{ path: "branch", reason: "protected branch" }],
|
|
932
|
-
});
|
|
933
1360
|
const commit = branch ? gitValue(root, ["rev-parse", branch]) : null;
|
|
934
1361
|
const remote = pushRemote(root);
|
|
935
1362
|
const identity = remote ? credentialFreeRemote(remote) : null;
|
|
1363
|
+
const provider = remote ? hostingProvider(root, remote) : null;
|
|
1364
|
+
const auth = provider ? vcsCliIdentity(root) : null;
|
|
1365
|
+
if (provider && (!auth?.ok || auth.provider !== provider))
|
|
1366
|
+
return failure("capability_unavailable", "hosting CLI identity is unavailable", {
|
|
1367
|
+
outcome: "not_started",
|
|
1368
|
+
});
|
|
1369
|
+
if (provider && auth?.ok && remote && !hostingApiHostMatches(remote, auth.host))
|
|
1370
|
+
return failure(
|
|
1371
|
+
"capability_unavailable",
|
|
1372
|
+
"hosting API host does not match the push destination",
|
|
1373
|
+
{
|
|
1374
|
+
outcome: "not_started",
|
|
1375
|
+
},
|
|
1376
|
+
);
|
|
936
1377
|
return branch && commit && identity
|
|
937
1378
|
? success(null, null, {
|
|
938
|
-
request: {
|
|
939
|
-
|
|
1379
|
+
request: {
|
|
1380
|
+
operation: request.operation,
|
|
1381
|
+
payload: { branch, ...(request.payload.cwd ? { cwd: request.payload.cwd } : {}) },
|
|
1382
|
+
},
|
|
1383
|
+
descriptorPayload: {
|
|
1384
|
+
branch,
|
|
1385
|
+
...(request.payload.cwd ? { cwd: request.payload.cwd } : {}),
|
|
1386
|
+
resolved: {
|
|
1387
|
+
branch,
|
|
1388
|
+
commit,
|
|
1389
|
+
remote: identity,
|
|
1390
|
+
...(auth?.ok ? { account: auth.username } : {}),
|
|
1391
|
+
...(auth?.ok ? { apiHost: auth.host } : {}),
|
|
1392
|
+
},
|
|
1393
|
+
},
|
|
940
1394
|
})
|
|
941
1395
|
: failure("invalid_input", "git push requires a resolvable current branch");
|
|
942
1396
|
}
|
|
@@ -957,6 +1411,36 @@ export const resolveExternalActionRequest = (
|
|
|
957
1411
|
fields: [{ path: "plan_steps", reason: "unique steps" }],
|
|
958
1412
|
},
|
|
959
1413
|
);
|
|
1414
|
+
const commitSteps = chain.filter((step) => step.kind === "commit");
|
|
1415
|
+
const branchSteps = chain.filter((step) => step.kind === "branch");
|
|
1416
|
+
const commitPolicy = commitSteps.length ? commitPolicySnapshotFor(root) : null;
|
|
1417
|
+
const branchPolicy = branchSteps.length ? branchPolicySnapshotFor(root) : null;
|
|
1418
|
+
for (const step of commitSteps) {
|
|
1419
|
+
if (step.kind !== "commit" || !commitPolicy) continue;
|
|
1420
|
+
const check = validateCommitMessagePolicy(
|
|
1421
|
+
step.message,
|
|
1422
|
+
commitPolicy.policy,
|
|
1423
|
+
commitPolicy.source,
|
|
1424
|
+
);
|
|
1425
|
+
if (!check.ok)
|
|
1426
|
+
return failure("permission_denied", check.error, {
|
|
1427
|
+
outcome: "not_started",
|
|
1428
|
+
fields: [{ path: "plan_steps", reason: check.rule }],
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
for (const step of branchSteps) {
|
|
1432
|
+
if (step.kind !== "branch" || !branchPolicy) continue;
|
|
1433
|
+
const check = validateBranchNamePolicy(
|
|
1434
|
+
step.target,
|
|
1435
|
+
branchPolicy.namePolicy,
|
|
1436
|
+
branchPolicy.source,
|
|
1437
|
+
);
|
|
1438
|
+
if (!check.ok)
|
|
1439
|
+
return failure("permission_denied", check.error, {
|
|
1440
|
+
outcome: "not_started",
|
|
1441
|
+
fields: [{ path: "plan_steps", reason: check.rule }],
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
960
1444
|
if (typeof planBranch !== "string" || !planBranch.trim())
|
|
961
1445
|
return failure("invalid_input", "plan_branch must name the working branch", {
|
|
962
1446
|
outcome: "not_started",
|
|
@@ -982,17 +1466,18 @@ export const resolveExternalActionRequest = (
|
|
|
982
1466
|
],
|
|
983
1467
|
},
|
|
984
1468
|
);
|
|
985
|
-
if (isProtectedBranch(root, planBranch))
|
|
986
|
-
return failure("invalid_input", "plan_branch must not be a protected branch", {
|
|
987
|
-
outcome: "not_started",
|
|
988
|
-
fields: [{ path: "plan_branch", reason: "protected branch" }],
|
|
989
|
-
});
|
|
990
1469
|
return success(null, null, {
|
|
991
1470
|
request,
|
|
992
1471
|
descriptorPayload: {
|
|
993
1472
|
plan_steps: planSteps,
|
|
994
1473
|
plan_branch: planBranch,
|
|
995
|
-
resolved: {
|
|
1474
|
+
resolved: {
|
|
1475
|
+
head,
|
|
1476
|
+
branch,
|
|
1477
|
+
steps: planSteps,
|
|
1478
|
+
...(commitPolicy ? { commitPolicyFingerprint: commitPolicy.fingerprint } : {}),
|
|
1479
|
+
...(branchPolicy ? { branchPolicyFingerprint: branchPolicy.fingerprint } : {}),
|
|
1480
|
+
},
|
|
996
1481
|
},
|
|
997
1482
|
});
|
|
998
1483
|
}
|
|
@@ -1014,31 +1499,28 @@ export const resolveExternalActionRequest = (
|
|
|
1014
1499
|
});
|
|
1015
1500
|
if (staged === null || paths === null)
|
|
1016
1501
|
return failure("storage_error", "Git state could not be resolved");
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
const log = gitRaw(root, ["log", "--format=%s", "-30", "HEAD"]);
|
|
1029
|
-
const detected = detectCommitFlavor((log ?? "").split("\n").filter(Boolean));
|
|
1030
|
-
flavor = detected.flavor ?? "conventional";
|
|
1031
|
-
}
|
|
1032
|
-
if (!matchCommitFlavor(message, flavor as CommitFlavor, policy.pattern))
|
|
1033
|
-
return failure(
|
|
1034
|
-
"invalid_input",
|
|
1035
|
-
`Commit message does not match the ${flavor} flavor (commitPolicy.preset)`,
|
|
1036
|
-
);
|
|
1502
|
+
const commitPolicy = commitPolicySnapshotFor(root);
|
|
1503
|
+
const commitCheck = validateCommitMessagePolicy(
|
|
1504
|
+
message,
|
|
1505
|
+
commitPolicy.policy,
|
|
1506
|
+
commitPolicy.source,
|
|
1507
|
+
);
|
|
1508
|
+
if (!commitCheck.ok)
|
|
1509
|
+
return failure("permission_denied", commitCheck.error, {
|
|
1510
|
+
outcome: "not_started",
|
|
1511
|
+
fields: [{ path: "message", reason: commitCheck.rule }],
|
|
1512
|
+
});
|
|
1037
1513
|
return success(null, null, {
|
|
1038
1514
|
request,
|
|
1039
1515
|
descriptorPayload: {
|
|
1040
1516
|
...request.payload,
|
|
1041
|
-
resolved: {
|
|
1517
|
+
resolved: {
|
|
1518
|
+
head,
|
|
1519
|
+
branch,
|
|
1520
|
+
staged: sha256(staged),
|
|
1521
|
+
paths,
|
|
1522
|
+
commitPolicyFingerprint: commitPolicy.fingerprint,
|
|
1523
|
+
},
|
|
1042
1524
|
},
|
|
1043
1525
|
});
|
|
1044
1526
|
}
|
|
@@ -1046,45 +1528,45 @@ export const resolveExternalActionRequest = (
|
|
|
1046
1528
|
const target_branch =
|
|
1047
1529
|
request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
|
|
1048
1530
|
const source_branch = gitValue(root, ["branch", "--show-current"]);
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
`cannot open a PR from protected branch ${source_branch}`,
|
|
1053
|
-
{
|
|
1054
|
-
outcome: "not_started",
|
|
1055
|
-
fields: [{ path: "source_branch", reason: "protected branch" }],
|
|
1056
|
-
},
|
|
1057
|
-
);
|
|
1058
|
-
const source_commit = gitValue(root, ["rev-parse", "HEAD"]);
|
|
1531
|
+
const source_commit = source_branch
|
|
1532
|
+
? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
|
|
1533
|
+
: null;
|
|
1059
1534
|
const remote = pushRemote(root);
|
|
1060
1535
|
const identity = remote ? credentialFreeRemote(remote) : null;
|
|
1536
|
+
const vcs = vcsConfig("load", root);
|
|
1537
|
+
const provider = remote ? hostingProvider(root, remote) : null;
|
|
1061
1538
|
if (
|
|
1062
1539
|
!source_branch ||
|
|
1063
1540
|
!source_commit ||
|
|
1064
1541
|
!identity ||
|
|
1065
|
-
|
|
1066
|
-
|
|
1542
|
+
!vcs.ok ||
|
|
1543
|
+
!provider ||
|
|
1544
|
+
provider !== vcs.provider
|
|
1067
1545
|
)
|
|
1068
1546
|
return failure(
|
|
1069
1547
|
"capability_unavailable",
|
|
1070
1548
|
"hosting target is not bound to the configured remote provider",
|
|
1071
|
-
{ capability: "hosting.pull_request" },
|
|
1549
|
+
{ capability: "hosting.pull_request", outcome: "not_started" },
|
|
1072
1550
|
);
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
return failure("capability_unavailable", "hosting credentials are unavailable", {
|
|
1077
|
-
capability: "hosting.pull_request",
|
|
1078
|
-
outcome: "not_started",
|
|
1079
|
-
});
|
|
1080
|
-
if (policy.integration !== "merge" && !hostingCliAvailable(String(vcs.provider)))
|
|
1081
|
-
return failure("capability_unavailable", "hosting CLI is unavailable", {
|
|
1551
|
+
const auth = vcsCliIdentity(root);
|
|
1552
|
+
if (!auth.ok || auth.provider !== provider || !hostingCliAvailable(provider))
|
|
1553
|
+
return failure("capability_unavailable", "hosting CLI identity is unavailable", {
|
|
1082
1554
|
capability: "hosting.pull_request",
|
|
1083
1555
|
outcome: "not_started",
|
|
1084
1556
|
});
|
|
1557
|
+
if (!remote || !hostingApiHostMatches(remote, auth.host))
|
|
1558
|
+
return failure(
|
|
1559
|
+
"capability_unavailable",
|
|
1560
|
+
"hosting API host does not match the push destination",
|
|
1561
|
+
{ capability: "hosting.pull_request", outcome: "not_started" },
|
|
1562
|
+
);
|
|
1563
|
+
const merge_base_commit =
|
|
1564
|
+
resolveBranchPolicyFor(root).integration === "merge"
|
|
1565
|
+
? gitValue(root, ["rev-parse", "--verify", `refs/heads/${target_branch}`])
|
|
1566
|
+
: null;
|
|
1085
1567
|
const normalized = {
|
|
1086
1568
|
operation: request.operation,
|
|
1087
|
-
payload: { ...request.payload, target_branch
|
|
1569
|
+
payload: { ...request.payload, target_branch },
|
|
1088
1570
|
} as ExternalActionRequest;
|
|
1089
1571
|
const marker = `<!-- workit-action:${sha256({ operation: request.operation, payload: normalized.payload, source_commit, remote: identity })} -->`;
|
|
1090
1572
|
return success(null, null, {
|
|
@@ -1092,7 +1574,16 @@ export const resolveExternalActionRequest = (
|
|
|
1092
1574
|
marker,
|
|
1093
1575
|
descriptorPayload: {
|
|
1094
1576
|
...normalized.payload,
|
|
1095
|
-
resolved: {
|
|
1577
|
+
resolved: {
|
|
1578
|
+
source_branch,
|
|
1579
|
+
source_commit,
|
|
1580
|
+
remote: identity,
|
|
1581
|
+
target_branch,
|
|
1582
|
+
marker,
|
|
1583
|
+
account: auth.username,
|
|
1584
|
+
apiHost: auth.host,
|
|
1585
|
+
...(merge_base_commit ? { merge_base_commit } : {}),
|
|
1586
|
+
},
|
|
1096
1587
|
},
|
|
1097
1588
|
});
|
|
1098
1589
|
}
|
|
@@ -1101,32 +1592,34 @@ export const resolveExternalActionRequest = (
|
|
|
1101
1592
|
request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
|
|
1102
1593
|
const source_branch =
|
|
1103
1594
|
request.payload.source_branch ?? gitValue(root, ["branch", "--show-current"]);
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
fields: [{ path: "source_branch", reason: "protected branch" }],
|
|
1108
|
-
});
|
|
1109
|
-
const source_commit = gitValue(root, ["rev-parse", "HEAD"]);
|
|
1595
|
+
const source_commit = source_branch
|
|
1596
|
+
? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
|
|
1597
|
+
: null;
|
|
1110
1598
|
const remote = pushRemote(root);
|
|
1111
1599
|
const identity = remote ? credentialFreeRemote(remote) : null;
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
!identity ||
|
|
1116
|
-
(remoteProvider(identity) !== null &&
|
|
1117
|
-
remoteProvider(identity) !== String(vcsConfig("load", root).provider).toLowerCase())
|
|
1118
|
-
)
|
|
1600
|
+
const vcs = vcsConfig("load", root);
|
|
1601
|
+
const provider = remote ? hostingProvider(root, remote) : null;
|
|
1602
|
+
if (!source_branch || !source_commit || !identity || !vcs.ok || provider !== vcs.provider)
|
|
1119
1603
|
return failure(
|
|
1120
1604
|
"capability_unavailable",
|
|
1121
1605
|
"hosting target is not bound to the configured remote provider",
|
|
1122
1606
|
{ capability: "hosting.merge" },
|
|
1123
1607
|
);
|
|
1124
|
-
const
|
|
1125
|
-
if (!vcs.ok || !
|
|
1608
|
+
const auth = vcsCliIdentity(root);
|
|
1609
|
+
if (!vcs.ok || !auth.ok)
|
|
1126
1610
|
return failure("capability_unavailable", "hosting credentials are unavailable", {
|
|
1127
1611
|
capability: "hosting.merge",
|
|
1128
1612
|
outcome: "not_started",
|
|
1129
1613
|
});
|
|
1614
|
+
if (!remote || !hostingApiHostMatches(remote, auth.host))
|
|
1615
|
+
return failure(
|
|
1616
|
+
"capability_unavailable",
|
|
1617
|
+
"hosting API host does not match the push destination",
|
|
1618
|
+
{
|
|
1619
|
+
capability: "hosting.merge",
|
|
1620
|
+
outcome: "not_started",
|
|
1621
|
+
},
|
|
1622
|
+
);
|
|
1130
1623
|
if (!hostingCliAvailable(String(vcs.provider)))
|
|
1131
1624
|
return failure("capability_unavailable", "hosting CLI is unavailable", {
|
|
1132
1625
|
capability: "hosting.merge",
|
|
@@ -1142,10 +1635,27 @@ export const resolveExternalActionRequest = (
|
|
|
1142
1635
|
marker,
|
|
1143
1636
|
descriptorPayload: {
|
|
1144
1637
|
...normalized.payload,
|
|
1145
|
-
resolved: {
|
|
1638
|
+
resolved: {
|
|
1639
|
+
source_branch,
|
|
1640
|
+
source_commit,
|
|
1641
|
+
remote: identity,
|
|
1642
|
+
target_branch,
|
|
1643
|
+
marker,
|
|
1644
|
+
account: auth.username,
|
|
1645
|
+
apiHost: auth.host,
|
|
1646
|
+
},
|
|
1146
1647
|
},
|
|
1147
1648
|
});
|
|
1148
1649
|
}
|
|
1650
|
+
case "hosting.delete_branch": {
|
|
1651
|
+
const checked = mergedBranch(root, request.payload.branch);
|
|
1652
|
+
return checked.ok
|
|
1653
|
+
? success(null, null, {
|
|
1654
|
+
request,
|
|
1655
|
+
descriptorPayload: { ...request.payload, resolved: checked.data },
|
|
1656
|
+
})
|
|
1657
|
+
: (checked as Result<never>);
|
|
1658
|
+
}
|
|
1149
1659
|
case "changelog.apply": {
|
|
1150
1660
|
const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
|
|
1151
1661
|
if (preview.error) return failure("invalid_input", preview.error);
|
|
@@ -1268,8 +1778,34 @@ export const resolveExternalActionRequest = (
|
|
|
1268
1778
|
}
|
|
1269
1779
|
};
|
|
1270
1780
|
|
|
1781
|
+
export const resolveExternalActionRequest = (
|
|
1782
|
+
taskRoot: string,
|
|
1783
|
+
request: ExternalActionRequest,
|
|
1784
|
+
preservedDateMs?: number,
|
|
1785
|
+
): Result<ResolvedExternalAction> => {
|
|
1786
|
+
const target = actionRoot(taskRoot, request);
|
|
1787
|
+
if (!target.ok) return target as Result<never>;
|
|
1788
|
+
const bindTarget = bindsActionTarget(request.operation);
|
|
1789
|
+
const normalized = bindTarget
|
|
1790
|
+
? ({ ...request, payload: { ...request.payload, cwd: target.data } } as ExternalActionRequest)
|
|
1791
|
+
: request;
|
|
1792
|
+
const resolved = resolveExternalActionAtRoot(target.data, normalized, preservedDateMs);
|
|
1793
|
+
if (!resolved.ok || !bindTarget) return resolved;
|
|
1794
|
+
return success(null, null, {
|
|
1795
|
+
...resolved.data,
|
|
1796
|
+
request: {
|
|
1797
|
+
...resolved.data.request,
|
|
1798
|
+
payload: { ...resolved.data.request.payload, cwd: target.data },
|
|
1799
|
+
} as ExternalActionRequest,
|
|
1800
|
+
descriptorPayload: {
|
|
1801
|
+
...(resolved.data.descriptorPayload as Record<string, unknown>),
|
|
1802
|
+
cwd: target.data,
|
|
1803
|
+
},
|
|
1804
|
+
});
|
|
1805
|
+
};
|
|
1806
|
+
|
|
1271
1807
|
export const prBabysitNext = (output: string): string =>
|
|
1272
|
-
`
|
|
1808
|
+
`PR ready: ${output}. Babysitting was explicitly requested. Follow workit-babysit to resolve conflicts, review threads, and get checks green. Stop at PR-ready; a PR or babysit request does not authorize merge or release.`;
|
|
1273
1809
|
|
|
1274
1810
|
export const executeConcreteExternalAction = async (
|
|
1275
1811
|
request: ExternalActionRequest,
|
|
@@ -1281,6 +1817,19 @@ export const executeConcreteExternalAction = async (
|
|
|
1281
1817
|
caller?: { host: Caller["host"]; actor: string },
|
|
1282
1818
|
approvedBeforeDigest?: string,
|
|
1283
1819
|
approvedBeforeExists?: boolean,
|
|
1820
|
+
coordinationRoot: string = root,
|
|
1821
|
+
approvedTip?: string,
|
|
1822
|
+
approvedRemote?: string,
|
|
1823
|
+
approvedAccount?: string,
|
|
1824
|
+
approvedApiHost?: string,
|
|
1825
|
+
approvedSourceCommit?: string,
|
|
1826
|
+
approvedCommit?: string,
|
|
1827
|
+
approvedRemoteBase?: string,
|
|
1828
|
+
approvedLocalBase?: string | null,
|
|
1829
|
+
writerActionLease?: symbol,
|
|
1830
|
+
approvedSourceBranch?: string,
|
|
1831
|
+
approvedMergeBaseCommit?: string,
|
|
1832
|
+
expectedWorkspaceRevision?: string,
|
|
1284
1833
|
): Promise<Result<unknown>> => {
|
|
1285
1834
|
if (localAction(request.operation)) {
|
|
1286
1835
|
if (!caller)
|
|
@@ -1301,13 +1850,60 @@ export const executeConcreteExternalAction = async (
|
|
|
1301
1850
|
"local external action staged paths are unavailable",
|
|
1302
1851
|
{ outcome: "not_started" },
|
|
1303
1852
|
);
|
|
1304
|
-
const writer = assertLocalExternalActionWriter(
|
|
1853
|
+
const writer = assertLocalExternalActionWriter(
|
|
1854
|
+
coordinationRoot,
|
|
1855
|
+
caller,
|
|
1856
|
+
paths,
|
|
1857
|
+
expectedWorkspaceRevision,
|
|
1858
|
+
);
|
|
1305
1859
|
if (!writer.ok)
|
|
1306
1860
|
return failure(
|
|
1307
1861
|
"capability_unavailable",
|
|
1308
1862
|
"local external action writer authority is unavailable",
|
|
1309
1863
|
{ outcome: "not_started" },
|
|
1310
1864
|
);
|
|
1865
|
+
if (writerActionLease !== targetWriterActionLease)
|
|
1866
|
+
return new TaskStore(root).withExternalActionLock(() =>
|
|
1867
|
+
executeConcreteExternalAction(
|
|
1868
|
+
request,
|
|
1869
|
+
root,
|
|
1870
|
+
marker,
|
|
1871
|
+
dateMs,
|
|
1872
|
+
step,
|
|
1873
|
+
workText,
|
|
1874
|
+
caller,
|
|
1875
|
+
approvedBeforeDigest,
|
|
1876
|
+
approvedBeforeExists,
|
|
1877
|
+
coordinationRoot,
|
|
1878
|
+
approvedTip,
|
|
1879
|
+
approvedRemote,
|
|
1880
|
+
approvedAccount,
|
|
1881
|
+
approvedApiHost,
|
|
1882
|
+
approvedSourceCommit,
|
|
1883
|
+
approvedCommit,
|
|
1884
|
+
approvedRemoteBase,
|
|
1885
|
+
approvedLocalBase,
|
|
1886
|
+
targetWriterActionLease,
|
|
1887
|
+
approvedSourceBranch,
|
|
1888
|
+
approvedMergeBaseCommit,
|
|
1889
|
+
expectedWorkspaceRevision,
|
|
1890
|
+
),
|
|
1891
|
+
);
|
|
1892
|
+
if (!sameDirectoryIdentity(root, coordinationRoot)) {
|
|
1893
|
+
const targetWorkspace = new TaskStore(root).readWorkspace();
|
|
1894
|
+
if (!targetWorkspace.ok) return targetWorkspace as Result<never>;
|
|
1895
|
+
const owner = targetWorkspace.data?.writer?.owner;
|
|
1896
|
+
if (
|
|
1897
|
+
owner &&
|
|
1898
|
+
(owner.taskId !== writer.data.taskId ||
|
|
1899
|
+
owner.workerId !== writer.data.workerId ||
|
|
1900
|
+
owner.session.host !== caller.host ||
|
|
1901
|
+
owner.session.handle !== caller.actor)
|
|
1902
|
+
)
|
|
1903
|
+
return failure("writer_conflict", "target checkout is owned by another writer", {
|
|
1904
|
+
outcome: "not_started",
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1311
1907
|
}
|
|
1312
1908
|
switch (request.operation) {
|
|
1313
1909
|
case "git.branch_setup": {
|
|
@@ -1315,6 +1911,8 @@ export const executeConcreteExternalAction = async (
|
|
|
1315
1911
|
...request.payload,
|
|
1316
1912
|
sdd_dir: request.payload.sdd_dir ?? "docs",
|
|
1317
1913
|
workspace_root: root,
|
|
1914
|
+
expected_remote_base: approvedRemoteBase,
|
|
1915
|
+
expected_local_base: approvedLocalBase,
|
|
1318
1916
|
});
|
|
1319
1917
|
if ("error" in result) {
|
|
1320
1918
|
if (result.phase === "preflight")
|
|
@@ -1339,13 +1937,76 @@ export const executeConcreteExternalAction = async (
|
|
|
1339
1937
|
: unknown(request.operation);
|
|
1340
1938
|
}
|
|
1341
1939
|
case "git.push": {
|
|
1342
|
-
const
|
|
1940
|
+
const remote = pushRemote(root);
|
|
1941
|
+
const identity = remote ? pushRemoteIdentity(remote) : null;
|
|
1942
|
+
const target = remote ? safePushUrl(remote) : null;
|
|
1943
|
+
const provider = remote ? hostingProvider(root, remote) : null;
|
|
1944
|
+
const auth = provider ? vcsCliIdentity(root) : null;
|
|
1945
|
+
if (
|
|
1946
|
+
!remote ||
|
|
1947
|
+
!identity ||
|
|
1948
|
+
!target ||
|
|
1949
|
+
!pushTargetIsStable(root, remote) ||
|
|
1950
|
+
(approvedRemote && identity !== approvedRemote)
|
|
1951
|
+
)
|
|
1952
|
+
return failure(
|
|
1953
|
+
"capability_unavailable",
|
|
1954
|
+
"push destination is ambiguous, rewritten, or changed after approval",
|
|
1955
|
+
{
|
|
1956
|
+
outcome: "not_started",
|
|
1957
|
+
},
|
|
1958
|
+
);
|
|
1959
|
+
if (provider && (!auth?.ok || !hostingApiHostMatches(remote, auth.host)))
|
|
1960
|
+
return failure(
|
|
1961
|
+
"capability_unavailable",
|
|
1962
|
+
"hosting API host does not match the push destination",
|
|
1963
|
+
{
|
|
1964
|
+
outcome: "not_started",
|
|
1965
|
+
},
|
|
1966
|
+
);
|
|
1967
|
+
if (
|
|
1968
|
+
approvedAccount &&
|
|
1969
|
+
(!auth?.ok || auth.username?.toLowerCase() !== approvedAccount.toLowerCase())
|
|
1970
|
+
)
|
|
1971
|
+
return failure(
|
|
1972
|
+
"capability_unavailable",
|
|
1973
|
+
"approved hosting account changed before execution",
|
|
1974
|
+
{
|
|
1975
|
+
outcome: "not_started",
|
|
1976
|
+
},
|
|
1977
|
+
);
|
|
1978
|
+
if (approvedApiHost && (!auth?.ok || auth.host !== approvedApiHost))
|
|
1979
|
+
return failure("capability_unavailable", "approved hosting host changed before execution", {
|
|
1980
|
+
outcome: "not_started",
|
|
1981
|
+
});
|
|
1982
|
+
const branch = request.payload.branch!;
|
|
1983
|
+
if (!approvedCommit || !/^[a-f0-9]{40,64}$/i.test(approvedCommit))
|
|
1984
|
+
return failure("capability_unavailable", "approved push commit is unavailable", {
|
|
1985
|
+
outcome: "not_started",
|
|
1986
|
+
});
|
|
1987
|
+
if (run(root, ["check-ref-format", "--branch", branch]).exitCode !== 0)
|
|
1988
|
+
return failure("invalid_input", "push branch name is invalid", { outcome: "not_started" });
|
|
1989
|
+
if (gitValue(root, ["rev-parse", "--verify", `refs/heads/${branch}`]) !== approvedCommit)
|
|
1990
|
+
return failure("capability_unavailable", "approved push branch changed before execution", {
|
|
1991
|
+
outcome: "not_started",
|
|
1992
|
+
});
|
|
1993
|
+
const result = run(root, ["push", target, `${approvedCommit}:refs/heads/${branch}`]);
|
|
1343
1994
|
return result.exitCode === 0
|
|
1344
1995
|
? success(null, null, { stdout: result.stdout.trim() })
|
|
1345
1996
|
: unknown(request.operation);
|
|
1346
1997
|
}
|
|
1347
1998
|
case "hosting.pull_request": {
|
|
1348
|
-
|
|
1999
|
+
if (
|
|
2000
|
+
!approvedSourceCommit ||
|
|
2001
|
+
!approvedSourceBranch ||
|
|
2002
|
+
!approvedRemote ||
|
|
2003
|
+
!approvedAccount ||
|
|
2004
|
+
!approvedApiHost
|
|
2005
|
+
)
|
|
2006
|
+
return failure("capability_unavailable", "approved PR source or target is incomplete", {
|
|
2007
|
+
outcome: "not_started",
|
|
2008
|
+
});
|
|
2009
|
+
const body = appendMarker(request.payload.body ?? "", marker ?? "");
|
|
1349
2010
|
const result = prCreate(
|
|
1350
2011
|
{
|
|
1351
2012
|
...process.env,
|
|
@@ -1354,19 +2015,29 @@ export const executeConcreteExternalAction = async (
|
|
|
1354
2015
|
WF_PR_CONFIRMED: "true",
|
|
1355
2016
|
WF_PR_DRAFT: request.payload.draft ? "true" : "false",
|
|
1356
2017
|
WF_PR_TARGET: request.payload.target_branch ?? "",
|
|
2018
|
+
WORKIT_EXPECTED_SOURCE_BRANCH: approvedSourceBranch,
|
|
2019
|
+
WORKIT_EXPECTED_SOURCE_COMMIT: approvedSourceCommit,
|
|
2020
|
+
WORKIT_EXPECTED_REMOTE: approvedRemote,
|
|
2021
|
+
WORKIT_EXPECTED_ACCOUNT: approvedAccount,
|
|
2022
|
+
WORKIT_EXPECTED_API_HOST: approvedApiHost,
|
|
2023
|
+
...(approvedMergeBaseCommit
|
|
2024
|
+
? { WORKIT_EXPECTED_MERGE_BASE_COMMIT: approvedMergeBaseCommit }
|
|
2025
|
+
: {}),
|
|
1357
2026
|
},
|
|
1358
2027
|
root,
|
|
1359
2028
|
);
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
:
|
|
1369
|
-
|
|
2029
|
+
if (result.error || result.ok === false) return unknown(request.operation);
|
|
2030
|
+
const babysit = request.payload.babysit === true;
|
|
2031
|
+
return success(null, null, {
|
|
2032
|
+
...result,
|
|
2033
|
+
...(request.payload.babysit !== undefined ? { babysit: request.payload.babysit } : {}),
|
|
2034
|
+
...(babysit
|
|
2035
|
+
? {
|
|
2036
|
+
babysitSkill: "workit-babysit",
|
|
2037
|
+
next: prBabysitNext(String(result.output ?? "")),
|
|
2038
|
+
}
|
|
2039
|
+
: {}),
|
|
2040
|
+
});
|
|
1370
2041
|
}
|
|
1371
2042
|
case "hosting.merge": {
|
|
1372
2043
|
const target = String(request.payload.target_branch ?? "");
|
|
@@ -1376,11 +2047,83 @@ export const executeConcreteExternalAction = async (
|
|
|
1376
2047
|
outcome: "not_started",
|
|
1377
2048
|
fields: [{ path: "target_branch", reason: "required" }],
|
|
1378
2049
|
});
|
|
1379
|
-
|
|
2050
|
+
if (!approvedSourceCommit || !approvedRemote || !approvedAccount || !approvedApiHost)
|
|
2051
|
+
return failure("capability_unavailable", "approved merge target is incomplete", {
|
|
2052
|
+
outcome: "not_started",
|
|
2053
|
+
});
|
|
2054
|
+
const result = mergePr(root, {
|
|
2055
|
+
target,
|
|
2056
|
+
source,
|
|
2057
|
+
sourceCommit: approvedSourceCommit,
|
|
2058
|
+
remote: approvedRemote,
|
|
2059
|
+
account: approvedAccount,
|
|
2060
|
+
apiHost: approvedApiHost,
|
|
2061
|
+
});
|
|
1380
2062
|
return result.error || result.ok === false
|
|
1381
2063
|
? unknown(request.operation)
|
|
1382
2064
|
: success(null, null, result);
|
|
1383
2065
|
}
|
|
2066
|
+
case "hosting.delete_branch": {
|
|
2067
|
+
const remote = pushRemote(root);
|
|
2068
|
+
const identity = remote ? pushRemoteIdentity(remote) : null;
|
|
2069
|
+
const target = remote ? safePushUrl(remote) : null;
|
|
2070
|
+
const auth = vcsCliIdentity(root);
|
|
2071
|
+
if (!approvedTip || !/^[a-f0-9]{40,64}$/.test(approvedTip))
|
|
2072
|
+
return failure("invalid_input", "approved branch tip is unavailable", {
|
|
2073
|
+
outcome: "not_started",
|
|
2074
|
+
});
|
|
2075
|
+
if (
|
|
2076
|
+
!remote ||
|
|
2077
|
+
!identity ||
|
|
2078
|
+
!target ||
|
|
2079
|
+
!pushTargetIsStable(root, remote) ||
|
|
2080
|
+
(approvedRemote && identity !== approvedRemote)
|
|
2081
|
+
)
|
|
2082
|
+
return failure(
|
|
2083
|
+
"capability_unavailable",
|
|
2084
|
+
"deletion destination is ambiguous, rewritten, or changed after approval",
|
|
2085
|
+
{
|
|
2086
|
+
outcome: "not_started",
|
|
2087
|
+
},
|
|
2088
|
+
);
|
|
2089
|
+
if (!auth.ok || !hostingApiHostMatches(remote, auth.host))
|
|
2090
|
+
return failure(
|
|
2091
|
+
"capability_unavailable",
|
|
2092
|
+
"hosting API host does not match the deletion destination",
|
|
2093
|
+
{
|
|
2094
|
+
outcome: "not_started",
|
|
2095
|
+
},
|
|
2096
|
+
);
|
|
2097
|
+
if (
|
|
2098
|
+
approvedAccount &&
|
|
2099
|
+
(!auth.ok || auth.username?.toLowerCase() !== approvedAccount.toLowerCase())
|
|
2100
|
+
)
|
|
2101
|
+
return failure(
|
|
2102
|
+
"capability_unavailable",
|
|
2103
|
+
"approved hosting account changed before execution",
|
|
2104
|
+
{
|
|
2105
|
+
outcome: "not_started",
|
|
2106
|
+
},
|
|
2107
|
+
);
|
|
2108
|
+
if (approvedApiHost && (!auth.ok || auth.host !== approvedApiHost))
|
|
2109
|
+
return failure("capability_unavailable", "approved hosting host changed before execution", {
|
|
2110
|
+
outcome: "not_started",
|
|
2111
|
+
});
|
|
2112
|
+
const branch = request.payload.branch;
|
|
2113
|
+
const result = run(root, [
|
|
2114
|
+
"push",
|
|
2115
|
+
`--force-with-lease=refs/heads/${branch}:${approvedTip}`,
|
|
2116
|
+
"--delete",
|
|
2117
|
+
target,
|
|
2118
|
+
branch,
|
|
2119
|
+
]);
|
|
2120
|
+
if (result.exitCode === 0) return success(null, null, { branch, deletedTip: approvedTip });
|
|
2121
|
+
return /stale info|cannot lock ref|fetch first|non-fast-forward/i.test(result.stderr)
|
|
2122
|
+
? failure("capability_unavailable", "remote branch tip changed before deletion", {
|
|
2123
|
+
outcome: "not_started",
|
|
2124
|
+
})
|
|
2125
|
+
: unknown(request.operation);
|
|
2126
|
+
}
|
|
1384
2127
|
case "changelog.apply": {
|
|
1385
2128
|
const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
|
|
1386
2129
|
if (
|
|
@@ -1473,12 +2216,19 @@ export const executeResolvedExternalAction = async (
|
|
|
1473
2216
|
root: string,
|
|
1474
2217
|
step?: string,
|
|
1475
2218
|
caller?: { host: Caller["host"]; actor: string },
|
|
2219
|
+
expectedWorkspaceRevision?: string,
|
|
1476
2220
|
): Promise<Result<unknown>> => {
|
|
1477
2221
|
const approvedDetails =
|
|
1478
2222
|
resolved.descriptorPayload && typeof resolved.descriptorPayload === "object"
|
|
1479
2223
|
? (
|
|
1480
2224
|
resolved.descriptorPayload as {
|
|
1481
|
-
resolved?: {
|
|
2225
|
+
resolved?: {
|
|
2226
|
+
dateMs?: unknown;
|
|
2227
|
+
beforeDigest?: unknown;
|
|
2228
|
+
beforeExists?: unknown;
|
|
2229
|
+
branchPolicyFingerprint?: unknown;
|
|
2230
|
+
commitPolicyFingerprint?: unknown;
|
|
2231
|
+
};
|
|
1482
2232
|
}
|
|
1483
2233
|
).resolved
|
|
1484
2234
|
: undefined;
|
|
@@ -1489,23 +2239,79 @@ export const executeResolvedExternalAction = async (
|
|
|
1489
2239
|
? approvedDetails.dateMs
|
|
1490
2240
|
: undefined;
|
|
1491
2241
|
const fresh = resolveExternalActionRequest(root, resolved.request, preservedDateMs);
|
|
1492
|
-
if (!fresh.ok)
|
|
2242
|
+
if (!fresh.ok) {
|
|
2243
|
+
if (fresh.code === "permission_denied") return fresh;
|
|
1493
2244
|
return failure(
|
|
1494
2245
|
"capability_unavailable",
|
|
1495
2246
|
"approved external action target changed before execution",
|
|
1496
2247
|
{ outcome: "not_started" },
|
|
1497
2248
|
);
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
2249
|
+
}
|
|
2250
|
+
const currentResolved = (
|
|
2251
|
+
fresh.data.descriptorPayload as {
|
|
2252
|
+
resolved?: {
|
|
2253
|
+
branchPolicyFingerprint?: unknown;
|
|
2254
|
+
commitPolicyFingerprint?: unknown;
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
).resolved;
|
|
2258
|
+
for (const [key, label] of [
|
|
2259
|
+
["branchPolicyFingerprint", "branch naming policy"],
|
|
2260
|
+
["commitPolicyFingerprint", "commit style policy"],
|
|
2261
|
+
] as const) {
|
|
2262
|
+
const approvedFingerprint = approvedDetails?.[key];
|
|
2263
|
+
const currentFingerprint = currentResolved?.[key];
|
|
2264
|
+
if (
|
|
2265
|
+
(typeof approvedFingerprint === "string" || typeof currentFingerprint === "string") &&
|
|
2266
|
+
approvedFingerprint !== currentFingerprint
|
|
2267
|
+
)
|
|
2268
|
+
return failure(
|
|
2269
|
+
"permission_denied",
|
|
2270
|
+
`${label} changed since approval; approve the operation again`,
|
|
2271
|
+
{ outcome: "not_started" },
|
|
2272
|
+
);
|
|
2273
|
+
}
|
|
2274
|
+
const freshDescriptor = externalActionDescriptor(
|
|
2275
|
+
resolved.request.operation,
|
|
2276
|
+
fresh.data.descriptorPayload,
|
|
2277
|
+
);
|
|
2278
|
+
const approvedDescriptor = externalActionDescriptor(
|
|
2279
|
+
resolved.request.operation,
|
|
2280
|
+
resolved.descriptorPayload,
|
|
2281
|
+
);
|
|
2282
|
+
const freshBranchIntent =
|
|
2283
|
+
resolved.request.operation === "git.branch_setup" ? branchSetupIntent(freshDescriptor) : null;
|
|
2284
|
+
const approvedBranchIntent =
|
|
2285
|
+
resolved.request.operation === "git.branch_setup"
|
|
2286
|
+
? branchSetupIntent(approvedDescriptor)
|
|
2287
|
+
: null;
|
|
2288
|
+
const unchangedBranchIntent =
|
|
2289
|
+
freshBranchIntent !== null &&
|
|
2290
|
+
approvedBranchIntent !== null &&
|
|
2291
|
+
canonicalJson(freshBranchIntent) === canonicalJson(approvedBranchIntent);
|
|
2292
|
+
if (freshDescriptor !== approvedDescriptor && !unchangedBranchIntent)
|
|
1502
2293
|
return failure(
|
|
1503
2294
|
"capability_unavailable",
|
|
1504
2295
|
"approved external action target changed before execution",
|
|
1505
2296
|
{ outcome: "not_started" },
|
|
1506
2297
|
);
|
|
1507
2298
|
const resolvedDetails = fresh.data.descriptorPayload as {
|
|
1508
|
-
resolved?: {
|
|
2299
|
+
resolved?: {
|
|
2300
|
+
dateMs?: unknown;
|
|
2301
|
+
beforeDigest?: unknown;
|
|
2302
|
+
beforeExists?: unknown;
|
|
2303
|
+
remote_base?: unknown;
|
|
2304
|
+
local_base?: unknown;
|
|
2305
|
+
target_exists?: unknown;
|
|
2306
|
+
tip?: unknown;
|
|
2307
|
+
remote?: unknown;
|
|
2308
|
+
account?: unknown;
|
|
2309
|
+
apiHost?: unknown;
|
|
2310
|
+
source_commit?: unknown;
|
|
2311
|
+
commit?: unknown;
|
|
2312
|
+
source_branch?: unknown;
|
|
2313
|
+
merge_base_commit?: unknown;
|
|
2314
|
+
};
|
|
1509
2315
|
};
|
|
1510
2316
|
const resolvedDate =
|
|
1511
2317
|
typeof resolvedDetails.resolved?.dateMs === "number"
|
|
@@ -1521,9 +2327,60 @@ export const executeResolvedExternalAction = async (
|
|
|
1521
2327
|
typeof resolvedDetails.resolved?.beforeExists === "boolean"
|
|
1522
2328
|
? resolvedDetails.resolved.beforeExists
|
|
1523
2329
|
: undefined;
|
|
1524
|
-
|
|
2330
|
+
const approvedTip =
|
|
2331
|
+
typeof resolvedDetails.resolved?.tip === "string" ? resolvedDetails.resolved.tip : undefined;
|
|
2332
|
+
const approvedRemote =
|
|
2333
|
+
typeof resolvedDetails.resolved?.remote === "string"
|
|
2334
|
+
? resolvedDetails.resolved.remote
|
|
2335
|
+
: undefined;
|
|
2336
|
+
const approvedAccount =
|
|
2337
|
+
typeof resolvedDetails.resolved?.account === "string"
|
|
2338
|
+
? resolvedDetails.resolved.account
|
|
2339
|
+
: undefined;
|
|
2340
|
+
const approvedApiHost =
|
|
2341
|
+
typeof resolvedDetails.resolved?.apiHost === "string"
|
|
2342
|
+
? resolvedDetails.resolved.apiHost
|
|
2343
|
+
: undefined;
|
|
2344
|
+
const approvedSourceCommit =
|
|
2345
|
+
typeof resolvedDetails.resolved?.source_commit === "string"
|
|
2346
|
+
? resolvedDetails.resolved.source_commit
|
|
2347
|
+
: undefined;
|
|
2348
|
+
const approvedCommit =
|
|
2349
|
+
typeof resolvedDetails.resolved?.commit === "string"
|
|
2350
|
+
? resolvedDetails.resolved.commit
|
|
2351
|
+
: undefined;
|
|
2352
|
+
const approvedRemoteBase =
|
|
2353
|
+
typeof resolvedDetails.resolved?.remote_base === "string"
|
|
2354
|
+
? resolvedDetails.resolved.remote_base
|
|
2355
|
+
: undefined;
|
|
2356
|
+
const approvedLocalBase =
|
|
2357
|
+
typeof resolvedDetails.resolved?.local_base === "string"
|
|
2358
|
+
? resolvedDetails.resolved.local_base
|
|
2359
|
+
: resolvedDetails.resolved?.local_base === null
|
|
2360
|
+
? null
|
|
2361
|
+
: undefined;
|
|
2362
|
+
const approvedSourceBranch =
|
|
2363
|
+
typeof resolvedDetails.resolved?.source_branch === "string"
|
|
2364
|
+
? resolvedDetails.resolved.source_branch
|
|
2365
|
+
: undefined;
|
|
2366
|
+
const approvedMergeBaseCommit =
|
|
2367
|
+
typeof resolvedDetails.resolved?.merge_base_commit === "string"
|
|
2368
|
+
? resolvedDetails.resolved.merge_base_commit
|
|
2369
|
+
: undefined;
|
|
2370
|
+
if (
|
|
2371
|
+
fresh.data.request.operation === "git.branch_setup" &&
|
|
2372
|
+
fresh.data.request.payload.action !== "reapply_stash" &&
|
|
2373
|
+
resolvedDetails.resolved?.target_exists === false &&
|
|
2374
|
+
!approvedRemoteBase
|
|
2375
|
+
)
|
|
2376
|
+
return failure("capability_unavailable", "approved remote base SHA could not be resolved", {
|
|
2377
|
+
outcome: "not_started",
|
|
2378
|
+
});
|
|
2379
|
+
const target = actionRoot(root, fresh.data.request);
|
|
2380
|
+
if (!target.ok) return target;
|
|
2381
|
+
const result = await executeConcreteExternalAction(
|
|
1525
2382
|
fresh.data.request,
|
|
1526
|
-
|
|
2383
|
+
target.data,
|
|
1527
2384
|
fresh.data.marker,
|
|
1528
2385
|
resolvedDate,
|
|
1529
2386
|
step,
|
|
@@ -1531,5 +2388,41 @@ export const executeResolvedExternalAction = async (
|
|
|
1531
2388
|
caller,
|
|
1532
2389
|
beforeDigest,
|
|
1533
2390
|
beforeExists,
|
|
2391
|
+
root,
|
|
2392
|
+
approvedTip,
|
|
2393
|
+
approvedRemote,
|
|
2394
|
+
approvedAccount,
|
|
2395
|
+
approvedApiHost,
|
|
2396
|
+
approvedSourceCommit,
|
|
2397
|
+
approvedCommit,
|
|
2398
|
+
approvedRemoteBase,
|
|
2399
|
+
approvedLocalBase,
|
|
2400
|
+
undefined,
|
|
2401
|
+
approvedSourceBranch,
|
|
2402
|
+
approvedMergeBaseCommit,
|
|
2403
|
+
expectedWorkspaceRevision,
|
|
1534
2404
|
);
|
|
2405
|
+
if (result.ok && fresh.data.request.operation === "hosting.pull_request") {
|
|
2406
|
+
const observed = await readHostingAction(target.data, fresh.data, {
|
|
2407
|
+
kind: "host",
|
|
2408
|
+
host: caller?.host ?? "workit_cli",
|
|
2409
|
+
handle: `post-create:${fresh.data.marker ?? "unknown"}`,
|
|
2410
|
+
});
|
|
2411
|
+
const output = (result.data as { output?: unknown }).output;
|
|
2412
|
+
const id =
|
|
2413
|
+
typeof output === "string"
|
|
2414
|
+
? /\/(?:pull|merge_requests)\/(\d+)(?:[/?#]|$)/.exec(output)?.[1]
|
|
2415
|
+
: null;
|
|
2416
|
+
if (!observed.ok || !id || String(observed.data.data?.id) !== id)
|
|
2417
|
+
return unknown("hosting.pull_request");
|
|
2418
|
+
}
|
|
2419
|
+
if (!result.ok && result.code === "external_outcome_unknown" && caller) {
|
|
2420
|
+
const observed = await readExternalAction(root, fresh.data, {
|
|
2421
|
+
kind: "host",
|
|
2422
|
+
host: caller.host,
|
|
2423
|
+
handle: `local-recovery:${fresh.data.request.operation}`,
|
|
2424
|
+
});
|
|
2425
|
+
if (observed.ok) return success(null, null, { recovered: true });
|
|
2426
|
+
}
|
|
2427
|
+
return result;
|
|
1535
2428
|
};
|