@brainervirus/workit-core 1.2.1 → 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 +6 -1
- package/src/core/auto-approval.ts +34 -14
- 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 +886 -172
- package/src/core/external-action.ts +214 -63
- 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 +7 -1
- package/src/core/task-engine.ts +47 -2
- 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/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,7 +36,9 @@ import {
|
|
|
25
36
|
} from "./youtrack";
|
|
26
37
|
import fs from "node:fs";
|
|
27
38
|
import path from "node:path";
|
|
39
|
+
import { spawnSync } from "node:child_process";
|
|
28
40
|
import {
|
|
41
|
+
canonicalJson,
|
|
29
42
|
failure,
|
|
30
43
|
success,
|
|
31
44
|
sha256,
|
|
@@ -36,13 +49,16 @@ import {
|
|
|
36
49
|
} from "./task-contract";
|
|
37
50
|
import { changelogApply, changelogApplyPreview } from "./changelog";
|
|
38
51
|
import type { ExternalActionRequest } from "./external-action";
|
|
39
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
branchSetupIntent,
|
|
54
|
+
externalActionDescriptor,
|
|
55
|
+
externalActionRequest,
|
|
56
|
+
} from "./external-action";
|
|
40
57
|
import { normalizeChainSteps } from "./authority";
|
|
41
58
|
import { resolveInside, run as coreRun } from "../core";
|
|
42
|
-
import { vcsConfig } from "./vcs-config";
|
|
43
|
-
import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
|
|
59
|
+
import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
|
|
44
60
|
import { assertProductWriteAllowed, currentWriterOwnsTask } from "./workers";
|
|
45
|
-
import { TaskStore } from "./task-store";
|
|
61
|
+
import { sameDirectoryIdentity, TaskStore } from "./task-store";
|
|
46
62
|
|
|
47
63
|
const run = (root: string, args: string[]) => {
|
|
48
64
|
return coreRun(root, "git", args);
|
|
@@ -78,10 +94,6 @@ const stagedPaths = (root: string): string[] | null => {
|
|
|
78
94
|
return [...new Set(paths)];
|
|
79
95
|
};
|
|
80
96
|
|
|
81
|
-
const pushRemote = (root: string): string | null =>
|
|
82
|
-
gitValue(root, ["remote", "get-url", "--push", "origin"]) ??
|
|
83
|
-
gitValue(root, ["remote", "get-url", "origin"]);
|
|
84
|
-
|
|
85
97
|
type RemoteParts = { host: string; path: string; protocol: string };
|
|
86
98
|
|
|
87
99
|
const remoteParts = (value: string): RemoteParts | null => {
|
|
@@ -108,11 +120,33 @@ const remoteParts = (value: string): RemoteParts | null => {
|
|
|
108
120
|
}
|
|
109
121
|
};
|
|
110
122
|
|
|
111
|
-
const credentialFreeRemote = (value: string): string | null =>
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|
|
114
145
|
};
|
|
115
146
|
|
|
147
|
+
const bindsActionTarget = (operation: ExternalActionRequest["operation"]): boolean =>
|
|
148
|
+
operation === "context.read" || operation.startsWith("git.") || operation.startsWith("hosting.");
|
|
149
|
+
|
|
116
150
|
const unknown = (operation: string) =>
|
|
117
151
|
failure("external_outcome_unknown", "external action outcome is unknown", { operation });
|
|
118
152
|
|
|
@@ -210,12 +244,16 @@ const localAction = (operation: ExternalActionRequest["operation"]): boolean =>
|
|
|
210
244
|
operation === "git.push" ||
|
|
211
245
|
operation === "hosting.pull_request" ||
|
|
212
246
|
operation === "hosting.merge" ||
|
|
247
|
+
operation === "hosting.delete_branch" ||
|
|
213
248
|
operation === "changelog.apply";
|
|
214
249
|
|
|
250
|
+
const targetWriterActionLease = Symbol("target-writer-action-lease");
|
|
251
|
+
|
|
215
252
|
export const assertLocalExternalActionWriter = (
|
|
216
253
|
root: string,
|
|
217
254
|
caller: { host: Caller["host"]; actor: string },
|
|
218
255
|
paths: string[] = ["."],
|
|
256
|
+
expectedWorkspaceRevision?: string,
|
|
219
257
|
): Result<Owner> => {
|
|
220
258
|
const store = new TaskStore(root);
|
|
221
259
|
const listed = store.listTasks();
|
|
@@ -223,6 +261,14 @@ export const assertLocalExternalActionWriter = (
|
|
|
223
261
|
if (!listed.ok) return listed as Result<never>;
|
|
224
262
|
if (!workspace.ok) return workspace as Result<never>;
|
|
225
263
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
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
|
+
});
|
|
226
272
|
const candidates = listed.data.filter((task) =>
|
|
227
273
|
currentWriterOwnsTask(task, workspace.data!, caller),
|
|
228
274
|
);
|
|
@@ -255,6 +301,7 @@ const branchCreationBaseline = (
|
|
|
255
301
|
): {
|
|
256
302
|
base_branch: string;
|
|
257
303
|
target_exists: boolean;
|
|
304
|
+
local_base: string | null;
|
|
258
305
|
remote_base: string | null;
|
|
259
306
|
target_head: string | null;
|
|
260
307
|
} => {
|
|
@@ -264,6 +311,7 @@ const branchCreationBaseline = (
|
|
|
264
311
|
return {
|
|
265
312
|
base_branch: base,
|
|
266
313
|
target_exists: true,
|
|
314
|
+
local_base: null,
|
|
267
315
|
remote_base: null,
|
|
268
316
|
target_head: gitValue(root, ["rev-parse", `refs/heads/${target}`]),
|
|
269
317
|
};
|
|
@@ -273,6 +321,7 @@ const branchCreationBaseline = (
|
|
|
273
321
|
return {
|
|
274
322
|
base_branch: base,
|
|
275
323
|
target_exists: false,
|
|
324
|
+
local_base: gitValue(root, ["rev-parse", "--verify", `refs/heads/${base}`]),
|
|
276
325
|
remote_base: remoteBase,
|
|
277
326
|
target_head: remoteBase,
|
|
278
327
|
};
|
|
@@ -281,12 +330,9 @@ const branchCreationBaseline = (
|
|
|
281
330
|
const shortSha = (value: unknown): string =>
|
|
282
331
|
typeof value === "string" && value ? value.slice(0, 8) : "unknown";
|
|
283
332
|
|
|
284
|
-
const isProtectedBranch = (root: string, branch: string): boolean =>
|
|
285
|
-
resolveBranchPolicyFor(root).protected.has(branch.toLowerCase());
|
|
286
|
-
|
|
287
333
|
/** Concise, user-facing approval text for one resolved action. The exact
|
|
288
334
|
* descriptor stays machine-facing; this is what the native question shows. */
|
|
289
|
-
|
|
335
|
+
const actionProposalAtRoot = (
|
|
290
336
|
request: ExternalActionRequest,
|
|
291
337
|
descriptorPayload: unknown,
|
|
292
338
|
): { presented: string; approvedText: string } => {
|
|
@@ -346,14 +392,19 @@ export const actionProposalQuestion = (
|
|
|
346
392
|
approvedText: `Switch to \`${target}\`.`,
|
|
347
393
|
};
|
|
348
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);
|
|
349
400
|
return stashes
|
|
350
401
|
? {
|
|
351
|
-
presented: `Workit decision: action — Stash
|
|
352
|
-
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}).`,
|
|
353
404
|
}
|
|
354
405
|
: {
|
|
355
|
-
presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (
|
|
356
|
-
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}).`,
|
|
357
408
|
};
|
|
358
409
|
}
|
|
359
410
|
case "git.commit": {
|
|
@@ -365,18 +416,29 @@ export const actionProposalQuestion = (
|
|
|
365
416
|
}
|
|
366
417
|
case "git.push":
|
|
367
418
|
return {
|
|
368
|
-
presented: `Workit decision: action — Push branch \`${String(payload.branch ?? "")}\` to
|
|
369
|
-
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)}` : ""}.`,
|
|
370
421
|
};
|
|
371
|
-
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
|
+
: "";
|
|
372
428
|
return {
|
|
373
|
-
presented: `Workit decision: action — Open a PR from \`${String(resolved.source_branch ?? "")}
|
|
374
|
-
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)}".`,
|
|
375
431
|
};
|
|
432
|
+
}
|
|
376
433
|
case "hosting.merge":
|
|
377
434
|
return {
|
|
378
|
-
presented: `Workit decision: action — Merge \`${String(resolved.source_branch ?? "")}\` into \`${payloadTarget}\` (squash, delete branch)?`,
|
|
379
|
-
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")}.`,
|
|
380
442
|
};
|
|
381
443
|
case "changelog.apply":
|
|
382
444
|
return {
|
|
@@ -406,6 +468,17 @@ export const actionProposalQuestion = (
|
|
|
406
468
|
}
|
|
407
469
|
};
|
|
408
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
|
+
|
|
409
482
|
export type ResolvedExternalAction = {
|
|
410
483
|
request: ExternalActionRequest;
|
|
411
484
|
descriptorPayload: unknown;
|
|
@@ -430,6 +503,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
430
503
|
value.operation !== "git.push" &&
|
|
431
504
|
value.operation !== "hosting.pull_request" &&
|
|
432
505
|
value.operation !== "hosting.merge" &&
|
|
506
|
+
value.operation !== "hosting.delete_branch" &&
|
|
433
507
|
value.operation !== "changelog.apply" &&
|
|
434
508
|
value.operation !== "youtrack.update" &&
|
|
435
509
|
value.operation !== "youtrack.time" &&
|
|
@@ -485,6 +559,13 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
485
559
|
typeof payload.target_branch !== "string")
|
|
486
560
|
)
|
|
487
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");
|
|
488
569
|
if (
|
|
489
570
|
value.operation === "changelog.apply" &&
|
|
490
571
|
(typeof target.target !== "string" ||
|
|
@@ -499,6 +580,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
499
580
|
value.operation !== "git.push" &&
|
|
500
581
|
value.operation !== "hosting.pull_request" &&
|
|
501
582
|
value.operation !== "hosting.merge" &&
|
|
583
|
+
value.operation !== "hosting.delete_branch" &&
|
|
502
584
|
value.operation !== "changelog.apply" &&
|
|
503
585
|
(typeof target.marker !== "string" ||
|
|
504
586
|
typeof target.baseUrl !== "string" ||
|
|
@@ -523,16 +605,151 @@ const remoteRepo = (value: string): string | null => {
|
|
|
523
605
|
return repo.includes("/") ? repo : null;
|
|
524
606
|
};
|
|
525
607
|
|
|
526
|
-
const remoteIdentity = (value: string): string | null =>
|
|
527
|
-
const parts = remoteParts(value);
|
|
528
|
-
return parts ? `${parts.host}${parts.path.replace(/\.git$/, "")}` : null;
|
|
529
|
-
};
|
|
608
|
+
const remoteIdentity = (value: string): string | null => pushRemoteIdentity(value);
|
|
530
609
|
|
|
531
610
|
const remoteProvider = (value: string): "github" | "gitlab" | null => {
|
|
532
611
|
const host = remoteParts(value)?.host;
|
|
533
612
|
return host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null;
|
|
534
613
|
};
|
|
535
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
|
+
|
|
536
753
|
const unknownHostingEvidence = (operation: string): Result<HostingReadEvidence> =>
|
|
537
754
|
failure("external_outcome_unknown", "hosting action outcome remains unknown", {
|
|
538
755
|
operation,
|
|
@@ -550,13 +767,10 @@ export const readHostingAction = async (
|
|
|
550
767
|
return unknownHostingEvidence(operation);
|
|
551
768
|
try {
|
|
552
769
|
const cfg = vcsConfig("load", root);
|
|
553
|
-
if (
|
|
554
|
-
!cfg.ok ||
|
|
555
|
-
!cfg.tokenPath ||
|
|
556
|
-
!cfg.provider ||
|
|
557
|
-
(cfg.provider !== "github" && cfg.provider !== "gitlab")
|
|
558
|
-
)
|
|
770
|
+
if (!cfg.ok || !cfg.provider || (cfg.provider !== "github" && cfg.provider !== "gitlab"))
|
|
559
771
|
return unknownHostingEvidence(operation);
|
|
772
|
+
const auth = vcsCliIdentity(root);
|
|
773
|
+
if (!auth.ok || auth.provider !== cfg.provider) return unknownHostingEvidence(operation);
|
|
560
774
|
const remote = pushRemote(root);
|
|
561
775
|
const repo = remote ? remoteRepo(remote) : null;
|
|
562
776
|
const approved = resolved.descriptorPayload as {
|
|
@@ -571,18 +785,6 @@ export const readHostingAction = async (
|
|
|
571
785
|
const currentIdentity = remote ? remoteIdentity(remote) : null;
|
|
572
786
|
const approvedIdentity =
|
|
573
787
|
typeof approvedRemote === "string" ? remoteIdentity(approvedRemote) : null;
|
|
574
|
-
const apiHost =
|
|
575
|
-
cfg.provider === "github"
|
|
576
|
-
? "github.com"
|
|
577
|
-
: (() => {
|
|
578
|
-
try {
|
|
579
|
-
return new URL(
|
|
580
|
-
cfg.gitlab?.apiUrl ?? "https://gitlab.com/api/v4",
|
|
581
|
-
).hostname.toLowerCase();
|
|
582
|
-
} catch {
|
|
583
|
-
return "";
|
|
584
|
-
}
|
|
585
|
-
})();
|
|
586
788
|
if (
|
|
587
789
|
!repo ||
|
|
588
790
|
typeof branch !== "string" ||
|
|
@@ -592,22 +794,23 @@ export const readHostingAction = async (
|
|
|
592
794
|
!currentIdentity ||
|
|
593
795
|
currentIdentity !== approvedIdentity ||
|
|
594
796
|
repo !== remoteRepo(approvedRemote) ||
|
|
595
|
-
(
|
|
596
|
-
|
|
597
|
-
|
|
797
|
+
hostingProvider(root, approvedRemote) !== cfg.provider ||
|
|
798
|
+
!hostingApiHostMatches(approvedRemote, auth.host) ||
|
|
799
|
+
(typeof source?.apiHost === "string" && source.apiHost !== auth.host)
|
|
598
800
|
)
|
|
599
801
|
return unknownHostingEvidence(operation);
|
|
600
|
-
const
|
|
601
|
-
const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" };
|
|
602
|
-
const url =
|
|
802
|
+
const endpoint =
|
|
603
803
|
cfg.provider === "github"
|
|
604
|
-
? `
|
|
605
|
-
:
|
|
606
|
-
const
|
|
607
|
-
|
|
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),
|
|
608
811
|
});
|
|
609
|
-
if (
|
|
610
|
-
const values: unknown =
|
|
812
|
+
if (response.status !== 0) return unknownHostingEvidence(operation);
|
|
813
|
+
const values: unknown = JSON.parse(response.stdout ?? "");
|
|
611
814
|
// A full page is intentionally treated as truncated until pagination is added.
|
|
612
815
|
if (!Array.isArray(values) || values.length >= 100) return unknownHostingEvidence(operation);
|
|
613
816
|
const records: Record<string, unknown>[] = [];
|
|
@@ -967,16 +1170,56 @@ export const readExternalAction = async (
|
|
|
967
1170
|
root: string,
|
|
968
1171
|
resolved: ResolvedExternalAction,
|
|
969
1172
|
actionRef: Ref,
|
|
970
|
-
): Promise<Result<HostingReadEvidence>> =>
|
|
971
|
-
resolved.request
|
|
972
|
-
resolved.request.operation
|
|
973
|
-
|
|
974
|
-
resolved.request.operation === "
|
|
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"
|
|
975
1181
|
? readLocalAction(root, resolved, actionRef)
|
|
976
|
-
: resolved.request.operation === "hosting.
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
+
};
|
|
980
1223
|
|
|
981
1224
|
const youTrackBase = (): string | null => {
|
|
982
1225
|
try {
|
|
@@ -1019,11 +1262,13 @@ export const upgradeBranchSetupForStash = (
|
|
|
1019
1262
|
resolved: Result<ResolvedExternalAction>,
|
|
1020
1263
|
): Result<ResolvedExternalAction> => {
|
|
1021
1264
|
if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
|
|
1265
|
+
const target = actionRoot(root, parsed);
|
|
1022
1266
|
if (
|
|
1023
1267
|
(resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
|
|
1024
1268
|
true ||
|
|
1025
1269
|
(resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
|
|
1026
|
-
|
|
1270
|
+
!target.ok ||
|
|
1271
|
+
classifyBranchDirt(target.data) !== "stash-required"
|
|
1027
1272
|
)
|
|
1028
1273
|
return resolved;
|
|
1029
1274
|
const upgraded = resolveExternalActionRequest(root, {
|
|
@@ -1033,7 +1278,7 @@ export const upgradeBranchSetupForStash = (
|
|
|
1033
1278
|
return upgraded.ok ? upgraded : resolved;
|
|
1034
1279
|
};
|
|
1035
1280
|
|
|
1036
|
-
|
|
1281
|
+
const resolveExternalActionAtRoot = (
|
|
1037
1282
|
root: string,
|
|
1038
1283
|
request: ExternalActionRequest,
|
|
1039
1284
|
preservedDateMs?: number,
|
|
@@ -1044,7 +1289,8 @@ export const resolveExternalActionRequest = (
|
|
|
1044
1289
|
const sdd_dir = safePath(root, request.payload.sdd_dir ?? "docs");
|
|
1045
1290
|
if (!sdd_dir) return failure("invalid_input", "sdd_dir must stay inside the workspace");
|
|
1046
1291
|
const action = request.payload.action ?? "setup";
|
|
1047
|
-
const
|
|
1292
|
+
const policySnapshot = branchPolicySnapshotFor(root);
|
|
1293
|
+
const policy = policySnapshot.branchPolicy;
|
|
1048
1294
|
const target_branch = request.payload.target_branch;
|
|
1049
1295
|
if (action !== "reapply_stash" && !target_branch)
|
|
1050
1296
|
return failure(
|
|
@@ -1055,6 +1301,18 @@ export const resolveExternalActionRequest = (
|
|
|
1055
1301
|
fields: [{ path: "target_branch", reason: "required for action=setup" }],
|
|
1056
1302
|
},
|
|
1057
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
|
+
}
|
|
1058
1316
|
const head = gitValue(root, ["rev-parse", "HEAD"]);
|
|
1059
1317
|
if (!head) return failure("storage_error", "current Git commit could not be resolved");
|
|
1060
1318
|
const dirty = Boolean((gitRaw(root, ["status", "--short"]) ?? "").trim());
|
|
@@ -1087,24 +1345,52 @@ export const resolveExternalActionRequest = (
|
|
|
1087
1345
|
request: normalized,
|
|
1088
1346
|
descriptorPayload: {
|
|
1089
1347
|
...normalized.payload,
|
|
1090
|
-
resolved: {
|
|
1348
|
+
resolved: {
|
|
1349
|
+
head,
|
|
1350
|
+
dirty,
|
|
1351
|
+
...creation,
|
|
1352
|
+
...stashTarget,
|
|
1353
|
+
...(target_branch ? { branchPolicyFingerprint: policySnapshot.fingerprint } : {}),
|
|
1354
|
+
},
|
|
1091
1355
|
},
|
|
1092
1356
|
});
|
|
1093
1357
|
}
|
|
1094
1358
|
case "git.push": {
|
|
1095
1359
|
const branch = request.payload.branch ?? gitValue(root, ["branch", "--show-current"]);
|
|
1096
|
-
if (branch && isProtectedBranch(root, branch))
|
|
1097
|
-
return failure("invalid_input", `cannot push protected branch ${branch}`, {
|
|
1098
|
-
outcome: "not_started",
|
|
1099
|
-
fields: [{ path: "branch", reason: "protected branch" }],
|
|
1100
|
-
});
|
|
1101
1360
|
const commit = branch ? gitValue(root, ["rev-parse", branch]) : null;
|
|
1102
1361
|
const remote = pushRemote(root);
|
|
1103
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
|
+
);
|
|
1104
1377
|
return branch && commit && identity
|
|
1105
1378
|
? success(null, null, {
|
|
1106
|
-
request: {
|
|
1107
|
-
|
|
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
|
+
},
|
|
1108
1394
|
})
|
|
1109
1395
|
: failure("invalid_input", "git push requires a resolvable current branch");
|
|
1110
1396
|
}
|
|
@@ -1125,6 +1411,36 @@ export const resolveExternalActionRequest = (
|
|
|
1125
1411
|
fields: [{ path: "plan_steps", reason: "unique steps" }],
|
|
1126
1412
|
},
|
|
1127
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
|
+
}
|
|
1128
1444
|
if (typeof planBranch !== "string" || !planBranch.trim())
|
|
1129
1445
|
return failure("invalid_input", "plan_branch must name the working branch", {
|
|
1130
1446
|
outcome: "not_started",
|
|
@@ -1150,17 +1466,18 @@ export const resolveExternalActionRequest = (
|
|
|
1150
1466
|
],
|
|
1151
1467
|
},
|
|
1152
1468
|
);
|
|
1153
|
-
if (isProtectedBranch(root, planBranch))
|
|
1154
|
-
return failure("invalid_input", "plan_branch must not be a protected branch", {
|
|
1155
|
-
outcome: "not_started",
|
|
1156
|
-
fields: [{ path: "plan_branch", reason: "protected branch" }],
|
|
1157
|
-
});
|
|
1158
1469
|
return success(null, null, {
|
|
1159
1470
|
request,
|
|
1160
1471
|
descriptorPayload: {
|
|
1161
1472
|
plan_steps: planSteps,
|
|
1162
1473
|
plan_branch: planBranch,
|
|
1163
|
-
resolved: {
|
|
1474
|
+
resolved: {
|
|
1475
|
+
head,
|
|
1476
|
+
branch,
|
|
1477
|
+
steps: planSteps,
|
|
1478
|
+
...(commitPolicy ? { commitPolicyFingerprint: commitPolicy.fingerprint } : {}),
|
|
1479
|
+
...(branchPolicy ? { branchPolicyFingerprint: branchPolicy.fingerprint } : {}),
|
|
1480
|
+
},
|
|
1164
1481
|
},
|
|
1165
1482
|
});
|
|
1166
1483
|
}
|
|
@@ -1182,31 +1499,28 @@ export const resolveExternalActionRequest = (
|
|
|
1182
1499
|
});
|
|
1183
1500
|
if (staged === null || paths === null)
|
|
1184
1501
|
return failure("storage_error", "Git state could not be resolved");
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
const log = gitRaw(root, ["log", "--format=%s", "-30", "HEAD"]);
|
|
1197
|
-
const detected = detectCommitFlavor((log ?? "").split("\n").filter(Boolean));
|
|
1198
|
-
flavor = detected.flavor ?? "conventional";
|
|
1199
|
-
}
|
|
1200
|
-
if (!matchCommitFlavor(message, flavor as CommitFlavor, policy.pattern))
|
|
1201
|
-
return failure(
|
|
1202
|
-
"invalid_input",
|
|
1203
|
-
`Commit message does not match the ${flavor} flavor (commitPolicy.preset)`,
|
|
1204
|
-
);
|
|
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
|
+
});
|
|
1205
1513
|
return success(null, null, {
|
|
1206
1514
|
request,
|
|
1207
1515
|
descriptorPayload: {
|
|
1208
1516
|
...request.payload,
|
|
1209
|
-
resolved: {
|
|
1517
|
+
resolved: {
|
|
1518
|
+
head,
|
|
1519
|
+
branch,
|
|
1520
|
+
staged: sha256(staged),
|
|
1521
|
+
paths,
|
|
1522
|
+
commitPolicyFingerprint: commitPolicy.fingerprint,
|
|
1523
|
+
},
|
|
1210
1524
|
},
|
|
1211
1525
|
});
|
|
1212
1526
|
}
|
|
@@ -1214,45 +1528,45 @@ export const resolveExternalActionRequest = (
|
|
|
1214
1528
|
const target_branch =
|
|
1215
1529
|
request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
|
|
1216
1530
|
const source_branch = gitValue(root, ["branch", "--show-current"]);
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
`cannot open a PR from protected branch ${source_branch}`,
|
|
1221
|
-
{
|
|
1222
|
-
outcome: "not_started",
|
|
1223
|
-
fields: [{ path: "source_branch", reason: "protected branch" }],
|
|
1224
|
-
},
|
|
1225
|
-
);
|
|
1226
|
-
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;
|
|
1227
1534
|
const remote = pushRemote(root);
|
|
1228
1535
|
const identity = remote ? credentialFreeRemote(remote) : null;
|
|
1536
|
+
const vcs = vcsConfig("load", root);
|
|
1537
|
+
const provider = remote ? hostingProvider(root, remote) : null;
|
|
1229
1538
|
if (
|
|
1230
1539
|
!source_branch ||
|
|
1231
1540
|
!source_commit ||
|
|
1232
1541
|
!identity ||
|
|
1233
|
-
|
|
1234
|
-
|
|
1542
|
+
!vcs.ok ||
|
|
1543
|
+
!provider ||
|
|
1544
|
+
provider !== vcs.provider
|
|
1235
1545
|
)
|
|
1236
1546
|
return failure(
|
|
1237
1547
|
"capability_unavailable",
|
|
1238
1548
|
"hosting target is not bound to the configured remote provider",
|
|
1239
|
-
{ capability: "hosting.pull_request" },
|
|
1549
|
+
{ capability: "hosting.pull_request", outcome: "not_started" },
|
|
1240
1550
|
);
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
return failure("capability_unavailable", "hosting credentials are unavailable", {
|
|
1245
|
-
capability: "hosting.pull_request",
|
|
1246
|
-
outcome: "not_started",
|
|
1247
|
-
});
|
|
1248
|
-
if (policy.integration !== "merge" && !hostingCliAvailable(String(vcs.provider)))
|
|
1249
|
-
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", {
|
|
1250
1554
|
capability: "hosting.pull_request",
|
|
1251
1555
|
outcome: "not_started",
|
|
1252
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;
|
|
1253
1567
|
const normalized = {
|
|
1254
1568
|
operation: request.operation,
|
|
1255
|
-
payload: { ...request.payload, target_branch
|
|
1569
|
+
payload: { ...request.payload, target_branch },
|
|
1256
1570
|
} as ExternalActionRequest;
|
|
1257
1571
|
const marker = `<!-- workit-action:${sha256({ operation: request.operation, payload: normalized.payload, source_commit, remote: identity })} -->`;
|
|
1258
1572
|
return success(null, null, {
|
|
@@ -1260,7 +1574,16 @@ export const resolveExternalActionRequest = (
|
|
|
1260
1574
|
marker,
|
|
1261
1575
|
descriptorPayload: {
|
|
1262
1576
|
...normalized.payload,
|
|
1263
|
-
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
|
+
},
|
|
1264
1587
|
},
|
|
1265
1588
|
});
|
|
1266
1589
|
}
|
|
@@ -1269,34 +1592,34 @@ export const resolveExternalActionRequest = (
|
|
|
1269
1592
|
request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
|
|
1270
1593
|
const source_branch =
|
|
1271
1594
|
request.payload.source_branch ?? gitValue(root, ["branch", "--show-current"]);
|
|
1272
|
-
if (source_branch && isProtectedBranch(root, source_branch))
|
|
1273
|
-
return failure("invalid_input", `cannot merge protected branch ${source_branch}`, {
|
|
1274
|
-
outcome: "not_started",
|
|
1275
|
-
fields: [{ path: "source_branch", reason: "protected branch" }],
|
|
1276
|
-
});
|
|
1277
1595
|
const source_commit = source_branch
|
|
1278
1596
|
? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
|
|
1279
1597
|
: null;
|
|
1280
1598
|
const remote = pushRemote(root);
|
|
1281
1599
|
const identity = remote ? credentialFreeRemote(remote) : null;
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
!identity ||
|
|
1286
|
-
(remoteProvider(identity) !== null &&
|
|
1287
|
-
remoteProvider(identity) !== String(vcsConfig("load", root).provider).toLowerCase())
|
|
1288
|
-
)
|
|
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)
|
|
1289
1603
|
return failure(
|
|
1290
1604
|
"capability_unavailable",
|
|
1291
1605
|
"hosting target is not bound to the configured remote provider",
|
|
1292
1606
|
{ capability: "hosting.merge" },
|
|
1293
1607
|
);
|
|
1294
|
-
const
|
|
1295
|
-
if (!vcs.ok || !
|
|
1608
|
+
const auth = vcsCliIdentity(root);
|
|
1609
|
+
if (!vcs.ok || !auth.ok)
|
|
1296
1610
|
return failure("capability_unavailable", "hosting credentials are unavailable", {
|
|
1297
1611
|
capability: "hosting.merge",
|
|
1298
1612
|
outcome: "not_started",
|
|
1299
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
|
+
);
|
|
1300
1623
|
if (!hostingCliAvailable(String(vcs.provider)))
|
|
1301
1624
|
return failure("capability_unavailable", "hosting CLI is unavailable", {
|
|
1302
1625
|
capability: "hosting.merge",
|
|
@@ -1312,10 +1635,27 @@ export const resolveExternalActionRequest = (
|
|
|
1312
1635
|
marker,
|
|
1313
1636
|
descriptorPayload: {
|
|
1314
1637
|
...normalized.payload,
|
|
1315
|
-
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
|
+
},
|
|
1316
1647
|
},
|
|
1317
1648
|
});
|
|
1318
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
|
+
}
|
|
1319
1659
|
case "changelog.apply": {
|
|
1320
1660
|
const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
|
|
1321
1661
|
if (preview.error) return failure("invalid_input", preview.error);
|
|
@@ -1438,8 +1778,34 @@ export const resolveExternalActionRequest = (
|
|
|
1438
1778
|
}
|
|
1439
1779
|
};
|
|
1440
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
|
+
|
|
1441
1807
|
export const prBabysitNext = (output: string): string =>
|
|
1442
|
-
`
|
|
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.`;
|
|
1443
1809
|
|
|
1444
1810
|
export const executeConcreteExternalAction = async (
|
|
1445
1811
|
request: ExternalActionRequest,
|
|
@@ -1451,6 +1817,19 @@ export const executeConcreteExternalAction = async (
|
|
|
1451
1817
|
caller?: { host: Caller["host"]; actor: string },
|
|
1452
1818
|
approvedBeforeDigest?: string,
|
|
1453
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,
|
|
1454
1833
|
): Promise<Result<unknown>> => {
|
|
1455
1834
|
if (localAction(request.operation)) {
|
|
1456
1835
|
if (!caller)
|
|
@@ -1471,13 +1850,60 @@ export const executeConcreteExternalAction = async (
|
|
|
1471
1850
|
"local external action staged paths are unavailable",
|
|
1472
1851
|
{ outcome: "not_started" },
|
|
1473
1852
|
);
|
|
1474
|
-
const writer = assertLocalExternalActionWriter(
|
|
1853
|
+
const writer = assertLocalExternalActionWriter(
|
|
1854
|
+
coordinationRoot,
|
|
1855
|
+
caller,
|
|
1856
|
+
paths,
|
|
1857
|
+
expectedWorkspaceRevision,
|
|
1858
|
+
);
|
|
1475
1859
|
if (!writer.ok)
|
|
1476
1860
|
return failure(
|
|
1477
1861
|
"capability_unavailable",
|
|
1478
1862
|
"local external action writer authority is unavailable",
|
|
1479
1863
|
{ outcome: "not_started" },
|
|
1480
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
|
+
}
|
|
1481
1907
|
}
|
|
1482
1908
|
switch (request.operation) {
|
|
1483
1909
|
case "git.branch_setup": {
|
|
@@ -1485,6 +1911,8 @@ export const executeConcreteExternalAction = async (
|
|
|
1485
1911
|
...request.payload,
|
|
1486
1912
|
sdd_dir: request.payload.sdd_dir ?? "docs",
|
|
1487
1913
|
workspace_root: root,
|
|
1914
|
+
expected_remote_base: approvedRemoteBase,
|
|
1915
|
+
expected_local_base: approvedLocalBase,
|
|
1488
1916
|
});
|
|
1489
1917
|
if ("error" in result) {
|
|
1490
1918
|
if (result.phase === "preflight")
|
|
@@ -1509,13 +1937,76 @@ export const executeConcreteExternalAction = async (
|
|
|
1509
1937
|
: unknown(request.operation);
|
|
1510
1938
|
}
|
|
1511
1939
|
case "git.push": {
|
|
1512
|
-
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}`]);
|
|
1513
1994
|
return result.exitCode === 0
|
|
1514
1995
|
? success(null, null, { stdout: result.stdout.trim() })
|
|
1515
1996
|
: unknown(request.operation);
|
|
1516
1997
|
}
|
|
1517
1998
|
case "hosting.pull_request": {
|
|
1518
|
-
|
|
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 ?? "");
|
|
1519
2010
|
const result = prCreate(
|
|
1520
2011
|
{
|
|
1521
2012
|
...process.env,
|
|
@@ -1524,19 +2015,29 @@ export const executeConcreteExternalAction = async (
|
|
|
1524
2015
|
WF_PR_CONFIRMED: "true",
|
|
1525
2016
|
WF_PR_DRAFT: request.payload.draft ? "true" : "false",
|
|
1526
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
|
+
: {}),
|
|
1527
2026
|
},
|
|
1528
2027
|
root,
|
|
1529
2028
|
);
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
:
|
|
1539
|
-
|
|
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
|
+
});
|
|
1540
2041
|
}
|
|
1541
2042
|
case "hosting.merge": {
|
|
1542
2043
|
const target = String(request.payload.target_branch ?? "");
|
|
@@ -1546,11 +2047,83 @@ export const executeConcreteExternalAction = async (
|
|
|
1546
2047
|
outcome: "not_started",
|
|
1547
2048
|
fields: [{ path: "target_branch", reason: "required" }],
|
|
1548
2049
|
});
|
|
1549
|
-
|
|
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
|
+
});
|
|
1550
2062
|
return result.error || result.ok === false
|
|
1551
2063
|
? unknown(request.operation)
|
|
1552
2064
|
: success(null, null, result);
|
|
1553
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
|
+
}
|
|
1554
2127
|
case "changelog.apply": {
|
|
1555
2128
|
const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
|
|
1556
2129
|
if (
|
|
@@ -1643,12 +2216,19 @@ export const executeResolvedExternalAction = async (
|
|
|
1643
2216
|
root: string,
|
|
1644
2217
|
step?: string,
|
|
1645
2218
|
caller?: { host: Caller["host"]; actor: string },
|
|
2219
|
+
expectedWorkspaceRevision?: string,
|
|
1646
2220
|
): Promise<Result<unknown>> => {
|
|
1647
2221
|
const approvedDetails =
|
|
1648
2222
|
resolved.descriptorPayload && typeof resolved.descriptorPayload === "object"
|
|
1649
2223
|
? (
|
|
1650
2224
|
resolved.descriptorPayload as {
|
|
1651
|
-
resolved?: {
|
|
2225
|
+
resolved?: {
|
|
2226
|
+
dateMs?: unknown;
|
|
2227
|
+
beforeDigest?: unknown;
|
|
2228
|
+
beforeExists?: unknown;
|
|
2229
|
+
branchPolicyFingerprint?: unknown;
|
|
2230
|
+
commitPolicyFingerprint?: unknown;
|
|
2231
|
+
};
|
|
1652
2232
|
}
|
|
1653
2233
|
).resolved
|
|
1654
2234
|
: undefined;
|
|
@@ -1659,23 +2239,79 @@ export const executeResolvedExternalAction = async (
|
|
|
1659
2239
|
? approvedDetails.dateMs
|
|
1660
2240
|
: undefined;
|
|
1661
2241
|
const fresh = resolveExternalActionRequest(root, resolved.request, preservedDateMs);
|
|
1662
|
-
if (!fresh.ok)
|
|
2242
|
+
if (!fresh.ok) {
|
|
2243
|
+
if (fresh.code === "permission_denied") return fresh;
|
|
1663
2244
|
return failure(
|
|
1664
2245
|
"capability_unavailable",
|
|
1665
2246
|
"approved external action target changed before execution",
|
|
1666
2247
|
{ outcome: "not_started" },
|
|
1667
2248
|
);
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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)
|
|
1672
2293
|
return failure(
|
|
1673
2294
|
"capability_unavailable",
|
|
1674
2295
|
"approved external action target changed before execution",
|
|
1675
2296
|
{ outcome: "not_started" },
|
|
1676
2297
|
);
|
|
1677
2298
|
const resolvedDetails = fresh.data.descriptorPayload as {
|
|
1678
|
-
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
|
+
};
|
|
1679
2315
|
};
|
|
1680
2316
|
const resolvedDate =
|
|
1681
2317
|
typeof resolvedDetails.resolved?.dateMs === "number"
|
|
@@ -1691,9 +2327,60 @@ export const executeResolvedExternalAction = async (
|
|
|
1691
2327
|
typeof resolvedDetails.resolved?.beforeExists === "boolean"
|
|
1692
2328
|
? resolvedDetails.resolved.beforeExists
|
|
1693
2329
|
: undefined;
|
|
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;
|
|
1694
2381
|
const result = await executeConcreteExternalAction(
|
|
1695
2382
|
fresh.data.request,
|
|
1696
|
-
|
|
2383
|
+
target.data,
|
|
1697
2384
|
fresh.data.marker,
|
|
1698
2385
|
resolvedDate,
|
|
1699
2386
|
step,
|
|
@@ -1701,9 +2388,36 @@ export const executeResolvedExternalAction = async (
|
|
|
1701
2388
|
caller,
|
|
1702
2389
|
beforeDigest,
|
|
1703
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,
|
|
1704
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
|
+
}
|
|
1705
2419
|
if (!result.ok && result.code === "external_outcome_unknown" && caller) {
|
|
1706
|
-
const observed = await
|
|
2420
|
+
const observed = await readExternalAction(root, fresh.data, {
|
|
1707
2421
|
kind: "host",
|
|
1708
2422
|
host: caller.host,
|
|
1709
2423
|
handle: `local-recovery:${fresh.data.request.operation}`,
|