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