@brainervirus/workit-core 1.2.1 → 1.3.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/package.json +1 -1
- package/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +6 -1
- package/src/core/auto-approval.ts +34 -14
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +886 -172
- package/src/core/external-action.ts +222 -64
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +65 -52
- package/src/core/policy-resolver.ts +13 -5
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +66 -6
- package/src/core/task-engine.ts +47 -2
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workspaces.ts +554 -46
- package/src/core.ts +3 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
package/src/core/workspaces.ts
CHANGED
|
@@ -1,35 +1,209 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import {
|
|
6
|
+
COMMIT_PRESETS,
|
|
7
|
+
PRESETS,
|
|
8
|
+
configDir,
|
|
9
|
+
isConfigObject,
|
|
10
|
+
resolveBranchPolicy as resolveConfiguredBranchPolicy,
|
|
11
|
+
resolveCommitPolicy as resolveConfiguredCommitPolicy,
|
|
12
|
+
type BranchPreset,
|
|
13
|
+
type ToolkitConfig,
|
|
14
|
+
} from "./config";
|
|
4
15
|
import type { CommitFlavorPreset } from "./commit-flavors";
|
|
5
16
|
|
|
6
17
|
export type VcsProvider = "gitlab" | "github";
|
|
7
18
|
|
|
8
19
|
export type IntegrationMode = "pr" | "merge";
|
|
9
20
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
const nonBlank = z.string().refine((value) => value.trim().length > 0, "must not be blank");
|
|
22
|
+
const branchPreset = z.custom<BranchPreset>(
|
|
23
|
+
(value) => typeof value === "string" && Object.hasOwn(PRESETS, value),
|
|
24
|
+
"unsupported branch preset",
|
|
25
|
+
);
|
|
26
|
+
const commitPreset = z.custom<CommitFlavorPreset>(
|
|
27
|
+
(value) => typeof value === "string" && COMMIT_PRESETS.includes(value),
|
|
28
|
+
"unsupported commit preset",
|
|
29
|
+
);
|
|
30
|
+
const workspaceBranchPolicySchema = z
|
|
31
|
+
.object({
|
|
32
|
+
preset: branchPreset,
|
|
33
|
+
developBranch: nonBlank.optional(),
|
|
34
|
+
prefixes: z
|
|
35
|
+
.object({
|
|
36
|
+
feature: nonBlank,
|
|
37
|
+
bugfix: nonBlank,
|
|
38
|
+
release: nonBlank,
|
|
39
|
+
hotfix: nonBlank,
|
|
40
|
+
})
|
|
41
|
+
.passthrough()
|
|
42
|
+
.optional(),
|
|
43
|
+
allowed: z.array(nonBlank).optional(),
|
|
44
|
+
protected: z.array(nonBlank).optional(),
|
|
45
|
+
integration: z.enum(["pr", "merge"]).optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
const workspaceCommitPolicySchema = z
|
|
49
|
+
.object({ preset: commitPreset, pattern: z.string().optional() })
|
|
50
|
+
.strict();
|
|
51
|
+
const workspaceProfileSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
branchPolicy: workspaceBranchPolicySchema.optional(),
|
|
54
|
+
commitPolicy: workspaceCommitPolicySchema.optional(),
|
|
55
|
+
})
|
|
56
|
+
.strict()
|
|
57
|
+
.refine((profile) => profile.branchPolicy !== undefined || profile.commitPolicy !== undefined, {
|
|
58
|
+
message: "a profile must define branchPolicy or commitPolicy",
|
|
59
|
+
});
|
|
60
|
+
const versionSourceSchema = z.discriminatedUnion("kind", [
|
|
61
|
+
z
|
|
62
|
+
.object({
|
|
63
|
+
kind: z.literal("package-json"),
|
|
64
|
+
path: nonBlank,
|
|
65
|
+
field: nonBlank,
|
|
66
|
+
})
|
|
67
|
+
.strict(),
|
|
68
|
+
z.object({ kind: z.literal("git-tag") }).strict(),
|
|
69
|
+
z.object({ kind: z.literal("manual") }).strict(),
|
|
70
|
+
]);
|
|
71
|
+
const releaseTrackSchema = z
|
|
72
|
+
.object({
|
|
73
|
+
strategy: branchPreset,
|
|
74
|
+
productionBranch: nonBlank,
|
|
75
|
+
integrationBranch: nonBlank,
|
|
76
|
+
naming: z.object({ feature: nonBlank, release: nonBlank, hotfix: nonBlank }).strict(),
|
|
77
|
+
baseBranch: nonBlank,
|
|
78
|
+
mergeBackBranches: z.array(nonBlank),
|
|
79
|
+
pullRequestTarget: nonBlank,
|
|
80
|
+
tagNamespace: z.string(),
|
|
81
|
+
versionSource: versionSourceSchema,
|
|
82
|
+
requiredChecks: z.array(nonBlank),
|
|
83
|
+
})
|
|
84
|
+
.strict()
|
|
85
|
+
.superRefine((track, context) => {
|
|
86
|
+
for (const [field, values] of [
|
|
87
|
+
["mergeBackBranches", track.mergeBackBranches],
|
|
88
|
+
["requiredChecks", track.requiredChecks],
|
|
89
|
+
] as const) {
|
|
90
|
+
if (new Set(values).size !== values.length) {
|
|
91
|
+
context.addIssue({ code: "custom", path: [field], message: "entries must be unique" });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
track.versionSource.kind === "package-json" &&
|
|
96
|
+
(path.posix.isAbsolute(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
97
|
+
/^[A-Za-z]:\//u.test(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
98
|
+
track.versionSource.path.replaceAll("\\", "/").split("/").includes(".."))
|
|
99
|
+
) {
|
|
100
|
+
context.addIssue({
|
|
101
|
+
code: "custom",
|
|
102
|
+
path: ["versionSource", "path"],
|
|
103
|
+
message: "must stay inside the repository",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
const workspaceConfigSchema = z
|
|
108
|
+
.object({
|
|
109
|
+
name: nonBlank,
|
|
110
|
+
glob: nonBlank,
|
|
111
|
+
vcs: z
|
|
112
|
+
.object({
|
|
113
|
+
provider: z.enum(["gitlab", "github"]),
|
|
114
|
+
defaultTargetBranch: nonBlank.optional(),
|
|
115
|
+
tokenFile: nonBlank.optional(),
|
|
116
|
+
account: nonBlank.optional(),
|
|
117
|
+
})
|
|
118
|
+
.passthrough()
|
|
119
|
+
.optional(),
|
|
120
|
+
autoApprove: z.union([z.boolean(), z.array(nonBlank)]).optional(),
|
|
121
|
+
youtrack: z
|
|
122
|
+
.object({ baseUrl: nonBlank.optional(), link_issues: z.boolean().optional() })
|
|
123
|
+
.passthrough()
|
|
124
|
+
.optional(),
|
|
125
|
+
issues: z
|
|
126
|
+
.object({ provider: z.literal("github").optional(), link_on_pr: z.boolean().optional() })
|
|
127
|
+
.passthrough()
|
|
128
|
+
.optional(),
|
|
129
|
+
branchPolicy: workspaceBranchPolicySchema.optional(),
|
|
130
|
+
commitPolicy: workspaceCommitPolicySchema.optional(),
|
|
131
|
+
defaultProfile: nonBlank.optional(),
|
|
132
|
+
profiles: z.record(nonBlank, workspaceProfileSchema).optional(),
|
|
133
|
+
releaseTracks: z.record(nonBlank, releaseTrackSchema).optional(),
|
|
134
|
+
})
|
|
135
|
+
.passthrough()
|
|
136
|
+
.superRefine((workspace, context) => {
|
|
137
|
+
if (workspace.youtrack && workspace.vcs?.provider !== "gitlab") {
|
|
138
|
+
context.addIssue({
|
|
139
|
+
code: "custom",
|
|
140
|
+
path: ["youtrack"],
|
|
141
|
+
message: `YouTrack issue linking requires the gitlab provider, got ${workspace.vcs?.provider ?? "unset"}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (workspace.issues && workspace.vcs?.provider !== "github") {
|
|
145
|
+
context.addIssue({
|
|
146
|
+
code: "custom",
|
|
147
|
+
path: ["issues"],
|
|
148
|
+
message: `GitHub issue linking requires the github provider, got ${workspace.vcs?.provider ?? "unset"}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
workspace.defaultProfile !== undefined &&
|
|
153
|
+
!Object.hasOwn(workspace.profiles ?? {}, workspace.defaultProfile)
|
|
154
|
+
) {
|
|
155
|
+
context.addIssue({
|
|
156
|
+
code: "custom",
|
|
157
|
+
path: ["defaultProfile"],
|
|
158
|
+
message: `does not name a configured profile`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
const workspacesFileSchema = z
|
|
163
|
+
.object({ workspaces: z.array(workspaceConfigSchema).optional() })
|
|
164
|
+
.passthrough()
|
|
165
|
+
.superRefine((file, context) => {
|
|
166
|
+
const names = new Set<string>();
|
|
167
|
+
for (const [index, workspace] of (file.workspaces ?? []).entries()) {
|
|
168
|
+
if (names.has(workspace.name)) {
|
|
169
|
+
context.addIssue({
|
|
170
|
+
code: "custom",
|
|
171
|
+
path: ["workspaces", index, "name"],
|
|
172
|
+
message: `duplicate workspace name ${JSON.stringify(workspace.name)}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
names.add(workspace.name);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
18
178
|
|
|
19
|
-
export type
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
179
|
+
export type WorkspaceBranchPolicy = z.infer<typeof workspaceBranchPolicySchema>;
|
|
180
|
+
export type WorkspaceCommitPolicy = z.infer<typeof workspaceCommitPolicySchema>;
|
|
181
|
+
export type WorkspaceProfile = z.infer<typeof workspaceProfileSchema>;
|
|
182
|
+
export type ReleaseTrack = z.infer<typeof releaseTrackSchema>;
|
|
183
|
+
export type WorkspaceConfig = z.infer<typeof workspaceConfigSchema>;
|
|
184
|
+
|
|
185
|
+
export type WorkspaceProfileResolution =
|
|
186
|
+
| {
|
|
187
|
+
status: "resolved";
|
|
188
|
+
profileName: string | null;
|
|
189
|
+
workspace: WorkspaceConfig;
|
|
190
|
+
provenance: { branchPolicy: string; commitPolicy: string };
|
|
191
|
+
}
|
|
192
|
+
| { status: "invalid"; error: string };
|
|
193
|
+
|
|
194
|
+
export type ReleaseTrackSelection =
|
|
195
|
+
| { status: "selected"; name: string; track: ReleaseTrack }
|
|
196
|
+
| { status: "not_configured" }
|
|
197
|
+
| { status: "choice_required"; choices: string[] }
|
|
198
|
+
| { status: "invalid"; error: string };
|
|
199
|
+
|
|
200
|
+
export type WorkspacePolicyResolution = {
|
|
201
|
+
status: "resolved";
|
|
202
|
+
workspace: WorkspaceConfig | null;
|
|
203
|
+
profileName: string | null;
|
|
204
|
+
branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
|
|
205
|
+
commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
|
|
206
|
+
provenance: { branchPolicy: string; commitPolicy: string };
|
|
33
207
|
};
|
|
34
208
|
|
|
35
209
|
export const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
|
|
@@ -38,35 +212,89 @@ export const workspacesPath = (): string => path.join(configDir(), "workspaces.j
|
|
|
38
212
|
// (empty list); malformed JSON is reported with the exact path so risky
|
|
39
213
|
// consumers (wizard/installer/doctor) can block instead of silently resetting.
|
|
40
214
|
export type WorkspacesResult = {
|
|
41
|
-
status: "missing" | "valid" | "malformed";
|
|
215
|
+
status: "missing" | "valid" | "malformed" | "invalid";
|
|
42
216
|
path: string;
|
|
43
217
|
entries: WorkspaceConfig[];
|
|
218
|
+
document?: Record<string, unknown>;
|
|
219
|
+
revision?: string;
|
|
44
220
|
error?: string;
|
|
45
221
|
};
|
|
46
222
|
|
|
223
|
+
export const workspacesRevision = (contents: string | null): string =>
|
|
224
|
+
createHash("sha256")
|
|
225
|
+
.update(contents ?? "<missing>")
|
|
226
|
+
.digest("hex");
|
|
227
|
+
|
|
228
|
+
export const validateWorkspacesDocument = (parsed: unknown, file: string): WorkspacesResult => {
|
|
229
|
+
if (!isConfigObject(parsed)) {
|
|
230
|
+
return { status: "malformed", path: file, entries: [], error: `${file} is not a JSON object` };
|
|
231
|
+
}
|
|
232
|
+
const validated = workspacesFileSchema.safeParse(parsed, { reportInput: true });
|
|
233
|
+
if (!validated.success) {
|
|
234
|
+
const detail = validated.error.issues
|
|
235
|
+
.map((issue) => {
|
|
236
|
+
const field = String(issue.path[issue.path.length - 1] ?? "");
|
|
237
|
+
const attempted =
|
|
238
|
+
["provider", "preset", "strategy"].includes(field) && typeof issue.input === "string"
|
|
239
|
+
? ` (${JSON.stringify(issue.input)})`
|
|
240
|
+
: "";
|
|
241
|
+
return `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}${attempted}`;
|
|
242
|
+
})
|
|
243
|
+
.join("; ");
|
|
244
|
+
return {
|
|
245
|
+
status: "invalid",
|
|
246
|
+
path: file,
|
|
247
|
+
entries: [],
|
|
248
|
+
error: `${file} has invalid workspace configuration: ${detail}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
for (const [index, workspace] of (validated.data.workspaces ?? []).entries()) {
|
|
252
|
+
const glob = validateWorkspaceGlob(workspace.glob);
|
|
253
|
+
if (!glob.ok) {
|
|
254
|
+
return {
|
|
255
|
+
status: "invalid",
|
|
256
|
+
path: file,
|
|
257
|
+
entries: [],
|
|
258
|
+
error: `${file} workspaces.${index}.glob: ${glob.error}`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
status: "valid",
|
|
264
|
+
path: file,
|
|
265
|
+
entries: validated.data.workspaces ?? [],
|
|
266
|
+
document: parsed as Record<string, unknown>,
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
47
270
|
export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResult => {
|
|
48
271
|
const file = path.join(dir, "workspaces.json");
|
|
49
272
|
let raw: string;
|
|
50
273
|
try {
|
|
51
274
|
raw = readFileSync(file, "utf8");
|
|
52
|
-
} catch {
|
|
53
|
-
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
277
|
+
return { status: "missing", path: file, entries: [], revision: workspacesRevision(null) };
|
|
278
|
+
return {
|
|
279
|
+
status: "malformed",
|
|
280
|
+
path: file,
|
|
281
|
+
entries: [],
|
|
282
|
+
error: `${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
283
|
+
};
|
|
54
284
|
}
|
|
55
285
|
let parsed: unknown;
|
|
56
286
|
try {
|
|
57
287
|
parsed = JSON.parse(raw);
|
|
58
288
|
} catch {
|
|
59
|
-
return {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
289
|
+
return {
|
|
290
|
+
status: "malformed",
|
|
291
|
+
path: file,
|
|
292
|
+
entries: [],
|
|
293
|
+
revision: workspacesRevision(raw),
|
|
294
|
+
error: `${file} is not valid JSON`,
|
|
295
|
+
};
|
|
63
296
|
}
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
status: "valid",
|
|
67
|
-
path: file,
|
|
68
|
-
entries: Array.isArray(list) ? (list as WorkspaceConfig[]) : [],
|
|
69
|
-
};
|
|
297
|
+
return { ...validateWorkspacesDocument(parsed, file), revision: workspacesRevision(raw) };
|
|
70
298
|
};
|
|
71
299
|
|
|
72
300
|
/** Parse the workspaces.json list under an explicit config dir; [] when missing/malformed. */
|
|
@@ -181,28 +409,308 @@ const canonicalGlob = (glob: string): string => {
|
|
|
181
409
|
return real + (prefix.endsWith("/") ? "/" : "") + rest;
|
|
182
410
|
};
|
|
183
411
|
|
|
412
|
+
const globSpecificity = (glob: string): number =>
|
|
413
|
+
glob
|
|
414
|
+
.replaceAll("\\", "/")
|
|
415
|
+
.split("/")
|
|
416
|
+
.filter((segment) => segment.length > 0 && !/[*?[\]{]/.test(segment)).length;
|
|
417
|
+
|
|
418
|
+
const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
|
|
419
|
+
matches: T[],
|
|
420
|
+
cwd: string,
|
|
421
|
+
workspaceName?: string,
|
|
422
|
+
): T | undefined => {
|
|
423
|
+
if (workspaceName !== undefined) {
|
|
424
|
+
const named = matches.filter((entry) => entry.name === workspaceName);
|
|
425
|
+
if (named.length === 1) return named[0];
|
|
426
|
+
if (named.length > 1)
|
|
427
|
+
throw new Error(`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`);
|
|
428
|
+
if (matches.length > 0) {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (matches.length < 2) return matches[0];
|
|
437
|
+
const highestSpecificity = Math.max(...matches.map((entry) => globSpecificity(entry.glob)));
|
|
438
|
+
const mostSpecific = matches.filter(
|
|
439
|
+
(entry) => globSpecificity(entry.glob) === highestSpecificity,
|
|
440
|
+
);
|
|
441
|
+
if (mostSpecific.length > 1) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`ambiguous workspace for ${cwd}; choose one of: ${mostSpecific.map((entry) => entry.name).join(", ")}`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return mostSpecific[0];
|
|
447
|
+
};
|
|
448
|
+
|
|
184
449
|
/** Match a cwd against the workspaces.json under an explicit config dir. */
|
|
185
|
-
export const resolveWorkspaceFrom = (
|
|
450
|
+
export const resolveWorkspaceFrom = (
|
|
451
|
+
cwd: string,
|
|
452
|
+
dir: string,
|
|
453
|
+
workspaceName?: string,
|
|
454
|
+
): WorkspaceConfig | null => {
|
|
186
455
|
// macOS/Windows tmpdir symlinks (/var -> /private/var): git's
|
|
187
456
|
// --show-toplevel returns the realpath while config globs are usually
|
|
188
457
|
// written with the logical path, so a workspace would silently stop
|
|
189
458
|
// matching on macOS. Match both forms on each side — same class as the
|
|
190
459
|
// docs-migration escape-guard realpath comparison; on Linux both forms
|
|
191
460
|
// are identical so behavior is unchanged.
|
|
461
|
+
const result = readWorkspacesResult(dir);
|
|
462
|
+
if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
|
|
192
463
|
const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const ws = entry as WorkspaceConfig;
|
|
196
|
-
if (typeof ws.glob !== "string" || !ws.glob) continue;
|
|
464
|
+
const matches: WorkspaceConfig[] = [];
|
|
465
|
+
for (const ws of result.entries) {
|
|
197
466
|
const glob = ws.glob.replaceAll("\\", "/");
|
|
198
467
|
const canonical = canonicalGlob(glob);
|
|
468
|
+
let matched = false;
|
|
199
469
|
for (const target of targets) {
|
|
200
|
-
if (
|
|
201
|
-
|
|
470
|
+
if (
|
|
471
|
+
matchWorkspace(glob, target) ||
|
|
472
|
+
(canonical !== glob && matchWorkspace(canonical, target))
|
|
473
|
+
) {
|
|
474
|
+
matched = true;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
202
477
|
}
|
|
478
|
+
if (matched) matches.push(ws);
|
|
203
479
|
}
|
|
204
|
-
return null;
|
|
480
|
+
return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
|
|
205
481
|
};
|
|
206
482
|
|
|
207
|
-
export const resolveWorkspace = (
|
|
208
|
-
|
|
483
|
+
export const resolveWorkspace = (
|
|
484
|
+
cwd: string,
|
|
485
|
+
workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,
|
|
486
|
+
): WorkspaceConfig | null => resolveWorkspaceFrom(cwd, configDir(), workspaceName);
|
|
487
|
+
|
|
488
|
+
type RuntimeWorkspacePolicy = "branch" | "commit" | "vcs";
|
|
489
|
+
type RuntimeWorkspaceCandidate = {
|
|
490
|
+
name: string;
|
|
491
|
+
glob: string;
|
|
492
|
+
defaultProfile?: string;
|
|
493
|
+
profiles?: Record<string, Record<string, unknown>>;
|
|
494
|
+
branchPolicy?: WorkspaceBranchPolicy;
|
|
495
|
+
commitPolicy?: WorkspaceCommitPolicy;
|
|
496
|
+
vcs?: WorkspaceConfig["vcs"];
|
|
497
|
+
youtrack?: unknown;
|
|
498
|
+
issues?: unknown;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const runtimeWorkspacePolicySchema = (kind: RuntimeWorkspacePolicy) => {
|
|
502
|
+
const key = kind === "branch" ? "branchPolicy" : kind === "commit" ? "commitPolicy" : "vcs";
|
|
503
|
+
const policySchema =
|
|
504
|
+
kind === "branch"
|
|
505
|
+
? workspaceBranchPolicySchema
|
|
506
|
+
: kind === "commit"
|
|
507
|
+
? workspaceCommitPolicySchema
|
|
508
|
+
: z
|
|
509
|
+
.object({
|
|
510
|
+
provider: z.enum(["gitlab", "github"]),
|
|
511
|
+
defaultTargetBranch: nonBlank.optional(),
|
|
512
|
+
account: nonBlank.optional(),
|
|
513
|
+
})
|
|
514
|
+
.passthrough();
|
|
515
|
+
const profileSchema = z.object({ [key]: policySchema.optional() }).passthrough();
|
|
516
|
+
const entrySchema = z
|
|
517
|
+
.object({
|
|
518
|
+
name: nonBlank,
|
|
519
|
+
glob: nonBlank,
|
|
520
|
+
[key]: policySchema.optional(),
|
|
521
|
+
defaultProfile: nonBlank.optional(),
|
|
522
|
+
profiles: z.record(nonBlank, profileSchema).optional(),
|
|
523
|
+
})
|
|
524
|
+
.passthrough();
|
|
525
|
+
return entrySchema;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
export type RuntimeWorkspaceVcs = Pick<
|
|
529
|
+
RuntimeWorkspaceCandidate,
|
|
530
|
+
"name" | "branchPolicy" | "vcs" | "youtrack" | "issues"
|
|
531
|
+
>;
|
|
532
|
+
|
|
533
|
+
export const resolveRuntimeWorkspaceVcs = (cwd: string): RuntimeWorkspaceVcs | null =>
|
|
534
|
+
resolveRuntimeWorkspaceCandidate(cwd, "vcs");
|
|
535
|
+
|
|
536
|
+
const runtimeWorkspaceIndexSchema = z
|
|
537
|
+
.object({
|
|
538
|
+
workspaces: z.array(z.object({ name: nonBlank, glob: nonBlank }).passthrough()).optional(),
|
|
539
|
+
})
|
|
540
|
+
.passthrough();
|
|
541
|
+
|
|
542
|
+
const resolveRuntimeWorkspaceCandidate = (
|
|
543
|
+
cwd: string,
|
|
544
|
+
kind: RuntimeWorkspacePolicy,
|
|
545
|
+
): RuntimeWorkspaceCandidate | null => {
|
|
546
|
+
const file = path.join(configDir(), "workspaces.json");
|
|
547
|
+
let raw: string;
|
|
548
|
+
try {
|
|
549
|
+
raw = readFileSync(file, "utf8");
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
552
|
+
throw new Error(
|
|
553
|
+
`${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
let parsed: unknown;
|
|
557
|
+
try {
|
|
558
|
+
parsed = JSON.parse(raw);
|
|
559
|
+
} catch {
|
|
560
|
+
throw new Error(`${file} is not valid JSON`);
|
|
561
|
+
}
|
|
562
|
+
const validated = runtimeWorkspaceIndexSchema.safeParse(parsed);
|
|
563
|
+
if (!validated.success) {
|
|
564
|
+
const detail = validated.error.issues
|
|
565
|
+
.map((issue) => `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}`)
|
|
566
|
+
.join("; ");
|
|
567
|
+
throw new Error(`${file} has invalid workspace matching data: ${detail}`);
|
|
568
|
+
}
|
|
569
|
+
const entries = validated.data.workspaces as RuntimeWorkspaceCandidate[] | undefined;
|
|
570
|
+
for (const entry of entries ?? []) {
|
|
571
|
+
const glob = validateWorkspaceGlob(entry.glob);
|
|
572
|
+
if (!glob.ok) throw new Error(`${file} workspace ${entry.name}: ${glob.error}`);
|
|
573
|
+
}
|
|
574
|
+
const targets = [cwd, realpathOf(cwd)].map((value) => value.replaceAll("\\", "/"));
|
|
575
|
+
const matches = (entries ?? []).filter((entry) => {
|
|
576
|
+
const glob = entry.glob.replaceAll("\\", "/");
|
|
577
|
+
const canonical = canonicalGlob(glob);
|
|
578
|
+
return targets.some(
|
|
579
|
+
(target) =>
|
|
580
|
+
matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
|
|
584
|
+
const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
|
|
585
|
+
if (!selected) return null;
|
|
586
|
+
const policy = runtimeWorkspacePolicySchema(kind).safeParse(selected);
|
|
587
|
+
if (!policy.success) {
|
|
588
|
+
const detail = policy.error.issues
|
|
589
|
+
.map((issue) => `${issue.path.map(String).join(".") || "workspace"}: ${issue.message}`)
|
|
590
|
+
.join("; ");
|
|
591
|
+
throw new Error(`${file} has invalid ${kind} policy configuration: ${detail}`);
|
|
592
|
+
}
|
|
593
|
+
return policy.data as RuntimeWorkspaceCandidate;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
export const resolveRuntimeWorkspacePolicy = (
|
|
597
|
+
cwd: string,
|
|
598
|
+
kind: RuntimeWorkspacePolicy,
|
|
599
|
+
requestedProfile?: string,
|
|
600
|
+
): {
|
|
601
|
+
policy: WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
|
|
602
|
+
source: string;
|
|
603
|
+
} => {
|
|
604
|
+
const candidate = resolveRuntimeWorkspaceCandidate(cwd, kind);
|
|
605
|
+
if (!candidate) return { policy: undefined, source: "user-default" };
|
|
606
|
+
const key = kind === "branch" ? "branchPolicy" : "commitPolicy";
|
|
607
|
+
const profileName = requestedProfile?.trim() || candidate.defaultProfile;
|
|
608
|
+
if (
|
|
609
|
+
candidate.defaultProfile &&
|
|
610
|
+
!Object.hasOwn(candidate.profiles ?? {}, candidate.defaultProfile)
|
|
611
|
+
)
|
|
612
|
+
throw new Error(
|
|
613
|
+
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(candidate.defaultProfile)}`,
|
|
614
|
+
);
|
|
615
|
+
const profile = profileName ? candidate.profiles?.[profileName] : undefined;
|
|
616
|
+
if (profileName && !profile)
|
|
617
|
+
throw new Error(
|
|
618
|
+
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(profileName)}`,
|
|
619
|
+
);
|
|
620
|
+
const profilePolicy = profile?.[key] as WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
|
|
621
|
+
const workspacePolicy = candidate[key] as
|
|
622
|
+
| WorkspaceBranchPolicy
|
|
623
|
+
| WorkspaceCommitPolicy
|
|
624
|
+
| undefined;
|
|
625
|
+
return {
|
|
626
|
+
policy: profilePolicy ?? workspacePolicy,
|
|
627
|
+
source: profilePolicy
|
|
628
|
+
? `profile:${profileName}`
|
|
629
|
+
: workspacePolicy
|
|
630
|
+
? `workspace:${candidate.name}`
|
|
631
|
+
: "user-default",
|
|
632
|
+
};
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
export const resolveWorkspaceProfile = (
|
|
636
|
+
workspace: WorkspaceConfig,
|
|
637
|
+
requestedProfile?: string,
|
|
638
|
+
): WorkspaceProfileResolution => {
|
|
639
|
+
const profileName = requestedProfile ?? workspace.defaultProfile ?? null;
|
|
640
|
+
const profile = profileName ? workspace.profiles?.[profileName] : undefined;
|
|
641
|
+
if (profileName && !profile) {
|
|
642
|
+
return {
|
|
643
|
+
status: "invalid",
|
|
644
|
+
error: `workspace ${JSON.stringify(workspace.name)} has no profile ${JSON.stringify(profileName)}`,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const effectiveWorkspace = profile
|
|
648
|
+
? {
|
|
649
|
+
...workspace,
|
|
650
|
+
...(profile.branchPolicy ? { branchPolicy: profile.branchPolicy } : {}),
|
|
651
|
+
...(profile.commitPolicy ? { commitPolicy: profile.commitPolicy } : {}),
|
|
652
|
+
}
|
|
653
|
+
: workspace;
|
|
654
|
+
return {
|
|
655
|
+
status: "resolved",
|
|
656
|
+
profileName,
|
|
657
|
+
workspace: effectiveWorkspace,
|
|
658
|
+
provenance: {
|
|
659
|
+
branchPolicy: profile?.branchPolicy
|
|
660
|
+
? `profile:${profileName}`
|
|
661
|
+
: workspace.branchPolicy
|
|
662
|
+
? `workspace:${workspace.name}`
|
|
663
|
+
: "user-default",
|
|
664
|
+
commitPolicy: profile?.commitPolicy
|
|
665
|
+
? `profile:${profileName}`
|
|
666
|
+
: workspace.commitPolicy
|
|
667
|
+
? `workspace:${workspace.name}`
|
|
668
|
+
: "user-default",
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
export const resolveWorkspacePolicy = (
|
|
674
|
+
config: ToolkitConfig,
|
|
675
|
+
workspace: WorkspaceConfig | null,
|
|
676
|
+
profileName?: string,
|
|
677
|
+
): WorkspacePolicyResolution | { status: "invalid"; error: string } => {
|
|
678
|
+
const selected = workspace
|
|
679
|
+
? resolveWorkspaceProfile(workspace, profileName)
|
|
680
|
+
: {
|
|
681
|
+
status: "resolved" as const,
|
|
682
|
+
profileName: null,
|
|
683
|
+
workspace: null,
|
|
684
|
+
provenance: { branchPolicy: "user-default", commitPolicy: "user-default" },
|
|
685
|
+
};
|
|
686
|
+
if (selected.status === "invalid") return selected;
|
|
687
|
+
return {
|
|
688
|
+
status: "resolved",
|
|
689
|
+
workspace: selected.workspace,
|
|
690
|
+
profileName: selected.profileName,
|
|
691
|
+
branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace),
|
|
692
|
+
commitPolicy: resolveConfiguredCommitPolicy(config, selected.workspace),
|
|
693
|
+
provenance: selected.provenance,
|
|
694
|
+
};
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
export const selectReleaseTrack = (
|
|
698
|
+
workspace: WorkspaceConfig,
|
|
699
|
+
requestedTrack?: string,
|
|
700
|
+
): ReleaseTrackSelection => {
|
|
701
|
+
const tracks = workspace.releaseTracks ?? {};
|
|
702
|
+
const choices = Object.keys(tracks).sort();
|
|
703
|
+
if (requestedTrack === undefined) {
|
|
704
|
+
if (choices.length === 0) return { status: "not_configured" };
|
|
705
|
+
if (choices.length > 1) return { status: "choice_required", choices };
|
|
706
|
+
const name = choices[0];
|
|
707
|
+
return { status: "selected", name, track: tracks[name] };
|
|
708
|
+
}
|
|
709
|
+
if (!Object.hasOwn(tracks, requestedTrack)) {
|
|
710
|
+
return {
|
|
711
|
+
status: "invalid",
|
|
712
|
+
error: `workspace ${JSON.stringify(workspace.name)} has no release track ${JSON.stringify(requestedTrack)}`,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
return { status: "selected", name: requestedTrack, track: tracks[requestedTrack] };
|
|
716
|
+
};
|
package/src/core.ts
CHANGED
|
@@ -70,7 +70,7 @@ export type {
|
|
|
70
70
|
} from "./core/task-context";
|
|
71
71
|
export { METHODS, invariantBootstrap, selectMethods } from "./core/methods";
|
|
72
72
|
export type { MethodId, SelectedMethod } from "./core/methods";
|
|
73
|
-
export {
|
|
73
|
+
export { shellBranchPolicyViolation, shellBranchTarget } from "./core/route-intent";
|
|
74
74
|
export { classifyBranchDirt } from "./core/branch";
|
|
75
75
|
export type { BranchDirt } from "./core/branch";
|
|
76
76
|
export {
|
|
@@ -91,7 +91,6 @@ export type {
|
|
|
91
91
|
StandingReceipt,
|
|
92
92
|
} from "./core/auto-approval";
|
|
93
93
|
export { bundleHashOfFile, isEphemeralCachePath, sha256Hex } from "./core/runtime-identity";
|
|
94
|
-
export type { ShellRouteIntent } from "./core/route-intent";
|
|
95
94
|
export { applicableDecision, reserveAction, settleAction, reconcileAction } from "./core/authority";
|
|
96
95
|
export { normalizeChainSteps, chainStepKey } from "./core/authority";
|
|
97
96
|
export type { ChainStep } from "./core/authority";
|
|
@@ -125,6 +124,8 @@ export {
|
|
|
125
124
|
externalActionRef,
|
|
126
125
|
externalActionDescriptor,
|
|
127
126
|
externalActionHelp,
|
|
127
|
+
externalActionJsonSchema,
|
|
128
|
+
externalActionSchema,
|
|
128
129
|
planReservationLength,
|
|
129
130
|
externalActionRequest,
|
|
130
131
|
matchesNativeExternalAction,
|