@brainervirus/workit-core 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +6 -1
- package/src/core/auto-approval.ts +34 -14
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +886 -172
- package/src/core/external-action.ts +214 -63
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +56 -52
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +7 -1
- package/src/core/task-engine.ts +47 -2
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workspaces.ts +548 -46
- package/src/core.ts +1 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
package/src/core/pr-create.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import {
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
|
|
6
|
+
export { hostingApiHostMatches } from "./vcs-config";
|
|
5
7
|
import { resolveBranchPolicyFor } from "./branch";
|
|
6
8
|
|
|
7
9
|
// Port of scripts/pr-create.sh — build MR/PR body issue linking + create via glab/gh.
|
|
8
|
-
|
|
10
|
+
// Hosted creation is enabled (decision ae03c569): every create path pre-binds
|
|
11
|
+
// the approved source SHA through WORKIT_EXPECTED_* and the shared executor
|
|
12
|
+
// post-verifies the provider PR head before reporting success. The residual
|
|
13
|
+
// non-atomic source-SHA race is accepted.
|
|
9
14
|
function parseGhRepo(remote: string): string | null {
|
|
10
15
|
remote = (remote || "").trim().replace(/\/+$/, "");
|
|
11
16
|
if (!remote) return null;
|
|
@@ -15,6 +20,49 @@ function parseGhRepo(remote: string): string | null {
|
|
|
15
20
|
return parts.length >= 2 ? parts.slice(-2).join("/") : null;
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
const remoteRepoPath = (
|
|
24
|
+
remote: string,
|
|
25
|
+
): { host: string; path: string; protocol: string } | null => {
|
|
26
|
+
const raw = remote.trim();
|
|
27
|
+
if (!raw) return null;
|
|
28
|
+
if (!raw.includes("://")) {
|
|
29
|
+
const scp = /^(?:[^@\s]+@)?([^:]+):(.+)$/u.exec(raw);
|
|
30
|
+
if (scp)
|
|
31
|
+
return {
|
|
32
|
+
host: scp[1].toLowerCase(),
|
|
33
|
+
path: scp[2].replace(/^\//u, "").replace(/\.git$/u, ""),
|
|
34
|
+
protocol: "ssh:",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(raw);
|
|
39
|
+
return {
|
|
40
|
+
host: url.hostname.toLowerCase(),
|
|
41
|
+
path: url.pathname
|
|
42
|
+
.replace(/^\//u, "")
|
|
43
|
+
.replace(/\.git$/u, "")
|
|
44
|
+
.replace(/\/$/u, ""),
|
|
45
|
+
protocol: url.protocol,
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const cliRepository = (
|
|
53
|
+
remote: string,
|
|
54
|
+
provider: "github" | "gitlab",
|
|
55
|
+
cfg: Record<string, any>,
|
|
56
|
+
): string | null => {
|
|
57
|
+
const parsed = remoteRepoPath(remote);
|
|
58
|
+
if (!parsed?.path) return null;
|
|
59
|
+
const host = String(
|
|
60
|
+
provider === "github" ? (cfg.github?.host ?? "github.com") : (cfg.gitlab?.host ?? "gitlab.com"),
|
|
61
|
+
).toLowerCase();
|
|
62
|
+
if (provider === "github") return `${host}/${parsed.path}`;
|
|
63
|
+
return host === "gitlab.com" ? parsed.path : `https://${host}/${parsed.path}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
18
66
|
function parseGhIssue(value: string): string {
|
|
19
67
|
const m = /issues\/(\d+)/.exec(value);
|
|
20
68
|
return m ? m[1] : String(value).trim().replace(/^#/, "");
|
|
@@ -109,6 +157,79 @@ function repoRoot(cwd: string): string {
|
|
|
109
157
|
return result.status === 0 ? (result.stdout ?? "").trim() : cwd;
|
|
110
158
|
}
|
|
111
159
|
|
|
160
|
+
/** Git pushes to every configured push URL; authorization binds only one target. */
|
|
161
|
+
export const pushRemote = (cwd: string): string | null => {
|
|
162
|
+
const result = spawnSync("git", ["remote", "get-url", "--push", "--all", "origin"], {
|
|
163
|
+
cwd,
|
|
164
|
+
encoding: "utf8",
|
|
165
|
+
});
|
|
166
|
+
if (result.status !== 0) return null;
|
|
167
|
+
const urls = (result.stdout ?? "")
|
|
168
|
+
.split(/\r?\n/u)
|
|
169
|
+
.map((url) => url.trim())
|
|
170
|
+
.filter(Boolean);
|
|
171
|
+
return urls.length === 1 ? urls[0] : null;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const safePushUrl = (remote: string): string | null => {
|
|
175
|
+
const raw = remote.trim();
|
|
176
|
+
if (path.isAbsolute(raw)) return pathToFileURL(raw).href;
|
|
177
|
+
if (!raw.includes("://")) {
|
|
178
|
+
const scp = /^(?:([^@\s]+)@)?([^:]+):(.+)$/u.exec(raw);
|
|
179
|
+
if (scp) return `${scp[1] || "git"}@${scp[2]}:${scp[3]}`;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const url = new URL(raw);
|
|
183
|
+
if (!["file:", "git:", "http:", "https:", "ssh:"].includes(url.protocol)) return null;
|
|
184
|
+
url.password = "";
|
|
185
|
+
if (url.protocol !== "ssh:") url.username = "";
|
|
186
|
+
url.search = "";
|
|
187
|
+
url.hash = "";
|
|
188
|
+
return url.toString();
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const pushRemoteIdentity = (remote: string): string | null => {
|
|
195
|
+
const raw = remote.trim();
|
|
196
|
+
if (!raw) return null;
|
|
197
|
+
if (path.isAbsolute(raw)) return pathToFileURL(raw).href;
|
|
198
|
+
if (!raw.includes("://")) {
|
|
199
|
+
const scp = /^(?:([^@\s]+)@)?([^:]+):(.+)$/u.exec(raw);
|
|
200
|
+
if (scp)
|
|
201
|
+
return `ssh://${scp[1] || "git"}@${scp[2].toLowerCase()}/${scp[3].replace(/^\//u, "")}`;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const url = new URL(raw);
|
|
205
|
+
url.password = "";
|
|
206
|
+
if (url.protocol !== "ssh:") url.username = "";
|
|
207
|
+
url.hostname = url.hostname.toLowerCase();
|
|
208
|
+
url.search = "";
|
|
209
|
+
url.hash = "";
|
|
210
|
+
return url.toString().replace(/\/$/u, "");
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** Git's URL rewrite rules also apply to explicit push URLs. */
|
|
217
|
+
export const pushTargetIsStable = (cwd: string, remote: string): boolean => {
|
|
218
|
+
const target = safePushUrl(remote);
|
|
219
|
+
if (!target) return false;
|
|
220
|
+
const result = spawnSync(
|
|
221
|
+
"git",
|
|
222
|
+
["config", "--get-regexp", "^url\\..*\\.(insteadof|pushinsteadof)$"],
|
|
223
|
+
{ cwd, encoding: "utf8" },
|
|
224
|
+
);
|
|
225
|
+
if (result.status !== 0 && result.status !== 1) return false;
|
|
226
|
+
const prefixes = (result.stdout ?? "")
|
|
227
|
+
.split(/\r?\n/u)
|
|
228
|
+
.map((line) => line.trim().slice(line.trim().indexOf(" ") + 1))
|
|
229
|
+
.filter(Boolean);
|
|
230
|
+
return !prefixes.some((prefix) => target.startsWith(prefix));
|
|
231
|
+
};
|
|
232
|
+
|
|
112
233
|
/** Port of pr-create.sh --build-body — pure body builder, no network. */
|
|
113
234
|
export function prBuildBody(env: NodeJS.ProcessEnv, cwd?: string): string {
|
|
114
235
|
const ghLinkOnPr = truthy(env.GH_LINK_ON_PR);
|
|
@@ -138,51 +259,103 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
138
259
|
const policy = resolveBranchPolicyFor(root);
|
|
139
260
|
const cfg = vcsConfig("load", root);
|
|
140
261
|
if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
|
|
141
|
-
// Merge mode finishes the feature locally (git merge + push) — no glab/gh,
|
|
142
|
-
// no API token, so token readiness is only required for the PR path.
|
|
143
|
-
if (policy.integration !== "merge" && !cfg.tokenReady)
|
|
144
|
-
return { error: "VCS token not ready — run /wk-init and edit token file locally" };
|
|
145
262
|
|
|
146
263
|
const provider = cfg.provider as string;
|
|
147
264
|
if (provider !== "gitlab" && provider !== "github")
|
|
148
265
|
return { error: `unsupported provider: ${provider}` };
|
|
266
|
+
const cli = provider === "gitlab" ? "glab" : "gh";
|
|
267
|
+
const installUrl =
|
|
268
|
+
provider === "gitlab" ? "https://gitlab.com/gitlab-org/cli" : "https://cli.github.com";
|
|
269
|
+
if (whichOnPath(cli) === null)
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
cli_missing: true,
|
|
273
|
+
error: `workflow CLI missing: ${cli} (required for ${provider}). Install: ${installUrl}`,
|
|
274
|
+
install_url: installUrl,
|
|
275
|
+
};
|
|
276
|
+
const identity = vcsCliIdentity(root);
|
|
277
|
+
if (!identity.ok) return { error: identity.error };
|
|
278
|
+
if (env.WORKIT_EXPECTED_API_HOST && env.WORKIT_EXPECTED_API_HOST !== identity.host)
|
|
279
|
+
return { error: "approved hosting API host changed before execution" };
|
|
280
|
+
const pushUrl = pushRemote(root);
|
|
281
|
+
const targetUrl = pushUrl ? safePushUrl(pushUrl) : null;
|
|
282
|
+
const targetIdentity = pushUrl ? pushRemoteIdentity(pushUrl) : null;
|
|
283
|
+
const targetRepo = pushUrl ? cliRepository(pushUrl, provider, cfg) : null;
|
|
284
|
+
if (!pushUrl || !targetUrl || !targetIdentity || !pushTargetIsStable(root, pushUrl))
|
|
285
|
+
return {
|
|
286
|
+
error: "origin must have exactly one supported push URL for a reserved hosting action",
|
|
287
|
+
};
|
|
288
|
+
const localOnlyTarget = path.isAbsolute(pushUrl) || pushUrl.startsWith("file://");
|
|
289
|
+
if (
|
|
290
|
+
(!localOnlyTarget || env.WORKIT_EXPECTED_API_HOST) &&
|
|
291
|
+
!hostingApiHostMatches(pushUrl, identity.host)
|
|
292
|
+
)
|
|
293
|
+
return { error: "hosting API host does not match the push destination" };
|
|
294
|
+
if (env.WORKIT_EXPECTED_REMOTE && env.WORKIT_EXPECTED_REMOTE !== targetIdentity)
|
|
295
|
+
return { error: "approved push destination changed before execution" };
|
|
296
|
+
if (
|
|
297
|
+
env.WORKIT_EXPECTED_ACCOUNT &&
|
|
298
|
+
identity.username?.toLowerCase() !== env.WORKIT_EXPECTED_ACCOUNT.toLowerCase()
|
|
299
|
+
)
|
|
300
|
+
return { error: "approved hosting account changed before execution" };
|
|
149
301
|
|
|
150
|
-
//
|
|
151
|
-
// surface (resolvePrBranchContext/docsBranch have none). Unlike the config
|
|
152
|
-
// default — which is authoritative by construction — a caller-supplied
|
|
153
|
-
// target is validated against the resolved branch policy so a PR can never
|
|
154
|
-
// be aimed at a protected or disallowed branch.
|
|
302
|
+
// The caller selects the target; GitHub/GitLab enforce their own protections.
|
|
155
303
|
const targetOverride = env.WF_PR_TARGET;
|
|
156
304
|
const resolvedDefault = String(
|
|
157
305
|
cfg.defaultTargetBranch ?? policy.defaultTargetBranch ?? "develop",
|
|
158
306
|
);
|
|
159
307
|
const target = targetOverride || resolvedDefault;
|
|
160
|
-
// CA-06: an explicit override equal to the resolved default (e.g. WF_PR_TARGET
|
|
161
|
-
// "main" under github-flow) is authoritative — the same value flows
|
|
162
|
-
// unvalidated from config, so it must not be rejected as a protected
|
|
163
|
-
// override. Genuine differing overrides keep the strict validation.
|
|
164
|
-
if (targetOverride && targetOverride !== resolvedDefault) {
|
|
165
|
-
const { allowed, protected: protectedTargets } = policy;
|
|
166
|
-
if (protectedTargets.has(targetOverride.toLowerCase()))
|
|
167
|
-
return {
|
|
168
|
-
error: `PR target ${JSON.stringify(targetOverride)} is a protected branch — override must be an allowed non-protected target`,
|
|
169
|
-
};
|
|
170
|
-
if (!allowed.some((r) => r.test(targetOverride)))
|
|
171
|
-
return {
|
|
172
|
-
error: `PR target ${JSON.stringify(targetOverride)} is not allowed by the branch policy`,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
308
|
const title = String(env.WF_PR_TITLE ?? "");
|
|
177
309
|
const br = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
178
310
|
cwd: root,
|
|
179
311
|
encoding: "utf8",
|
|
180
312
|
});
|
|
181
313
|
const branch = br.status === 0 ? (br.stdout ?? "").trim() : "";
|
|
314
|
+
const sourceResult = branch
|
|
315
|
+
? spawnSync("git", ["rev-parse", "--verify", `refs/heads/${branch}`], {
|
|
316
|
+
cwd: root,
|
|
317
|
+
encoding: "utf8",
|
|
318
|
+
})
|
|
319
|
+
: null;
|
|
320
|
+
const sourceCommit = sourceResult?.status === 0 ? (sourceResult.stdout ?? "").trim() : "";
|
|
321
|
+
if (
|
|
322
|
+
env.WORKIT_EXPECTED_REMOTE &&
|
|
323
|
+
(!env.WORKIT_EXPECTED_SOURCE_BRANCH || !env.WORKIT_EXPECTED_SOURCE_COMMIT)
|
|
324
|
+
)
|
|
325
|
+
return { error: "approved PR source branch and commit are incomplete" };
|
|
326
|
+
if (
|
|
327
|
+
(env.WORKIT_EXPECTED_SOURCE_BRANCH && env.WORKIT_EXPECTED_SOURCE_BRANCH !== branch) ||
|
|
328
|
+
(env.WORKIT_EXPECTED_SOURCE_COMMIT && env.WORKIT_EXPECTED_SOURCE_COMMIT !== sourceCommit)
|
|
329
|
+
)
|
|
330
|
+
return { error: "approved PR source branch or commit changed before execution" };
|
|
331
|
+
const mergeBaseResult =
|
|
332
|
+
policy.integration === "merge"
|
|
333
|
+
? spawnSync("git", ["rev-parse", "--verify", `refs/heads/${target}`], {
|
|
334
|
+
cwd: root,
|
|
335
|
+
encoding: "utf8",
|
|
336
|
+
})
|
|
337
|
+
: null;
|
|
338
|
+
const mergeBaseCommit =
|
|
339
|
+
mergeBaseResult?.status === 0 ? (mergeBaseResult.stdout ?? "").trim() : "";
|
|
340
|
+
if (
|
|
341
|
+
policy.integration === "merge" &&
|
|
342
|
+
((env.WORKIT_EXPECTED_REMOTE && !env.WORKIT_EXPECTED_MERGE_BASE_COMMIT) ||
|
|
343
|
+
!mergeBaseCommit ||
|
|
344
|
+
(env.WORKIT_EXPECTED_MERGE_BASE_COMMIT &&
|
|
345
|
+
env.WORKIT_EXPECTED_MERGE_BASE_COMMIT !== mergeBaseCommit))
|
|
346
|
+
)
|
|
347
|
+
return { error: "approved merge target branch changed before execution" };
|
|
182
348
|
|
|
183
349
|
if (policy.integration === "merge") {
|
|
184
350
|
const finish = (): Record<string, any> => {
|
|
185
|
-
|
|
351
|
+
if (!sourceCommit || !mergeBaseCommit)
|
|
352
|
+
return {
|
|
353
|
+
error: "merge failed",
|
|
354
|
+
mode: "merge",
|
|
355
|
+
targetBranch: target,
|
|
356
|
+
stderr: "source or target branch commit could not be resolved",
|
|
357
|
+
};
|
|
358
|
+
const merge = spawnSync("git", ["merge", "--no-ff", sourceCommit, "-m", title], {
|
|
186
359
|
cwd: root,
|
|
187
360
|
encoding: "utf8",
|
|
188
361
|
});
|
|
@@ -193,7 +366,24 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
193
366
|
targetBranch: target,
|
|
194
367
|
stderr: (merge.stderr ?? "").slice(0, 800),
|
|
195
368
|
};
|
|
196
|
-
const
|
|
369
|
+
const mergedCommit = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
370
|
+
cwd: root,
|
|
371
|
+
encoding: "utf8",
|
|
372
|
+
});
|
|
373
|
+
if (mergedCommit.status !== 0)
|
|
374
|
+
return { error: "merge commit could not be resolved", mode: "merge" };
|
|
375
|
+
const updateTarget = spawnSync(
|
|
376
|
+
"git",
|
|
377
|
+
["update-ref", `refs/heads/${target}`, mergedCommit.stdout.trim(), mergeBaseCommit],
|
|
378
|
+
{ cwd: root, encoding: "utf8" },
|
|
379
|
+
);
|
|
380
|
+
if (updateTarget.status !== 0)
|
|
381
|
+
return { error: "merge target branch changed before update", mode: "merge" };
|
|
382
|
+
const push = spawnSync(
|
|
383
|
+
"git",
|
|
384
|
+
["push", targetUrl, `${mergedCommit.stdout.trim()}:refs/heads/${target}`],
|
|
385
|
+
{ cwd: root, encoding: "utf8", env: { ...process.env, PATH: process.env.PATH ?? "" } },
|
|
386
|
+
);
|
|
197
387
|
if (push.status !== 0)
|
|
198
388
|
return {
|
|
199
389
|
error: "push failed",
|
|
@@ -210,7 +400,10 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
210
400
|
output: (push.stdout ?? "").trim(),
|
|
211
401
|
};
|
|
212
402
|
};
|
|
213
|
-
const co = spawnSync("git", ["checkout",
|
|
403
|
+
const co = spawnSync("git", ["checkout", "--detach", mergeBaseCommit], {
|
|
404
|
+
cwd: root,
|
|
405
|
+
encoding: "utf8",
|
|
406
|
+
});
|
|
214
407
|
if (co.status !== 0)
|
|
215
408
|
return {
|
|
216
409
|
error: `cannot checkout target ${target}`,
|
|
@@ -228,20 +421,7 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
228
421
|
return result;
|
|
229
422
|
}
|
|
230
423
|
|
|
231
|
-
const cli = provider === "gitlab" ? "glab" : "gh";
|
|
232
|
-
const installUrl =
|
|
233
|
-
provider === "gitlab" ? "https://gitlab.com/gitlab-org/cli" : "https://cli.github.com";
|
|
234
|
-
if (whichOnPath(cli) === null) {
|
|
235
|
-
return {
|
|
236
|
-
ok: false,
|
|
237
|
-
cli_missing: true,
|
|
238
|
-
error: `workflow CLI missing: ${cli} (required for ${provider}). Install: ${installUrl}`,
|
|
239
|
-
install_url: installUrl,
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
424
|
const pr = (cfg.pr ?? {}) as Record<string, any>;
|
|
244
|
-
const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
|
|
245
425
|
const body = env.WF_PR_BODY ?? "";
|
|
246
426
|
const draft = String(env.WF_PR_DRAFT ?? "false").toLowerCase() === "true";
|
|
247
427
|
|
|
@@ -260,8 +440,7 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
260
440
|
const ghLinkOnPr = cfg.issues_provider === "github" && cfg.link_on_pr === true;
|
|
261
441
|
let ghRepo: string | null = null;
|
|
262
442
|
if (ghLinkOnPr) {
|
|
263
|
-
|
|
264
|
-
if (rr.status === 0) ghRepo = parseGhRepo(rr.stdout ?? "");
|
|
443
|
+
ghRepo = remoteRepoPath(pushUrl)?.path ?? null;
|
|
265
444
|
}
|
|
266
445
|
const finalBody = buildBody(
|
|
267
446
|
body,
|
|
@@ -279,51 +458,74 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
279
458
|
const removeBranch = pr.removeSourceBranch !== false;
|
|
280
459
|
const push = pr.pushBranch !== false;
|
|
281
460
|
const skipConfirm = pr.confirmSkip !== false;
|
|
461
|
+
const remoteBranchTip = (): string | null => {
|
|
462
|
+
const read = spawnSync("git", ["ls-remote", targetUrl, `refs/heads/${branch}`], {
|
|
463
|
+
cwd: root,
|
|
464
|
+
encoding: "utf8",
|
|
465
|
+
env: { ...process.env, PATH: process.env.PATH ?? "" },
|
|
466
|
+
});
|
|
467
|
+
return read.status === 0 ? (read.stdout ?? "").trim().split(/\s+/u)[0] || null : null;
|
|
468
|
+
};
|
|
469
|
+
if (!branch || !sourceCommit)
|
|
470
|
+
return {
|
|
471
|
+
error: "push failed",
|
|
472
|
+
provider,
|
|
473
|
+
mode: "push",
|
|
474
|
+
targetBranch: target,
|
|
475
|
+
stderr: branch
|
|
476
|
+
? "source branch commit could not be resolved"
|
|
477
|
+
: "empty current branch (detached HEAD or unborn HEAD)",
|
|
478
|
+
};
|
|
479
|
+
if (push) {
|
|
480
|
+
const pushed = spawnSync(
|
|
481
|
+
"git",
|
|
482
|
+
["push", "-u", targetUrl, `${sourceCommit}:refs/heads/${branch}`],
|
|
483
|
+
{ cwd: root, encoding: "utf8", env: { ...process.env, PATH: process.env.PATH ?? "" } },
|
|
484
|
+
);
|
|
485
|
+
if (pushed.status !== 0)
|
|
486
|
+
return { error: "push failed", provider, stderr: (pushed.stderr ?? "").slice(0, 800) };
|
|
487
|
+
}
|
|
488
|
+
if (remoteBranchTip() !== sourceCommit)
|
|
489
|
+
return { error: "push destination branch does not match the approved source commit" };
|
|
282
490
|
|
|
283
491
|
let cmd: string[];
|
|
284
492
|
let cmdEnv: NodeJS.ProcessEnv;
|
|
285
493
|
if (provider === "gitlab") {
|
|
286
494
|
// glab non-interactive mode requires BOTH title and description flags (issue #652).
|
|
287
|
-
cmd = [
|
|
495
|
+
cmd = [
|
|
496
|
+
"glab",
|
|
497
|
+
"mr",
|
|
498
|
+
"create",
|
|
499
|
+
...(targetRepo ? ["-R", targetRepo] : []),
|
|
500
|
+
"-t",
|
|
501
|
+
title,
|
|
502
|
+
"-d",
|
|
503
|
+
finalBody || "",
|
|
504
|
+
"-b",
|
|
505
|
+
target,
|
|
506
|
+
];
|
|
288
507
|
cmd.push(squash ? "--squash-before-merge" : "--squash-before-merge=false");
|
|
289
508
|
cmd.push(removeBranch ? "--remove-source-branch" : "--remove-source-branch=false");
|
|
290
509
|
if (draft) cmd.push("--draft");
|
|
291
|
-
if (push) cmd.push("--push");
|
|
292
510
|
if (skipConfirm) cmd.push("--yes");
|
|
293
511
|
// bun's Windows spawn only consults PATH when the env object carries an
|
|
294
512
|
// explicit PATH key — a spread-only PATH is invisible to the lookup and
|
|
295
513
|
// uv_spawn fails with ENOENT even though the CLI is on PATH.
|
|
296
|
-
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "",
|
|
514
|
+
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_HOST: identity.host };
|
|
297
515
|
} else {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
const pushRes = spawnSync("git", ["push", "-u", "origin", branch], {
|
|
311
|
-
cwd: root,
|
|
312
|
-
encoding: "utf8",
|
|
313
|
-
});
|
|
314
|
-
if (pushRes.status !== 0) {
|
|
315
|
-
return {
|
|
316
|
-
error: "push failed",
|
|
317
|
-
provider,
|
|
318
|
-
targetBranch: target,
|
|
319
|
-
stderr: (pushRes.stderr ?? "").slice(0, 800),
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
cmd = ["gh", "pr", "create", "--title", title, "--base", target];
|
|
516
|
+
cmd = [
|
|
517
|
+
"gh",
|
|
518
|
+
"pr",
|
|
519
|
+
"create",
|
|
520
|
+
...(targetRepo ? ["--repo", targetRepo] : []),
|
|
521
|
+
"--title",
|
|
522
|
+
title,
|
|
523
|
+
"--base",
|
|
524
|
+
target,
|
|
525
|
+
];
|
|
324
526
|
if (finalBody) cmd.push("--body", finalBody);
|
|
325
527
|
if (draft) cmd.push("--draft");
|
|
326
|
-
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "",
|
|
528
|
+
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_HOST: identity.host };
|
|
327
529
|
}
|
|
328
530
|
|
|
329
531
|
const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });
|
|
@@ -373,40 +575,128 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
373
575
|
};
|
|
374
576
|
}
|
|
375
577
|
|
|
376
|
-
/** Merge the open PR/MR
|
|
578
|
+
/** Merge the exact approved open PR/MR (squash + delete per pr settings). */
|
|
377
579
|
export function mergePr(
|
|
378
580
|
cwd: string,
|
|
379
|
-
opts: {
|
|
581
|
+
opts: {
|
|
582
|
+
target: string;
|
|
583
|
+
source: string;
|
|
584
|
+
sourceCommit: string;
|
|
585
|
+
remote: string;
|
|
586
|
+
account: string;
|
|
587
|
+
apiHost: string;
|
|
588
|
+
},
|
|
380
589
|
): Record<string, any> {
|
|
381
590
|
const root = repoRoot(cwd);
|
|
382
591
|
const cfg = vcsConfig("load", root);
|
|
383
592
|
if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
|
|
384
|
-
if (!cfg.tokenReady)
|
|
385
|
-
return { error: "VCS token not ready — run /wk-init and edit token file locally" };
|
|
386
593
|
const provider = cfg.provider as string;
|
|
387
594
|
if (provider !== "gitlab" && provider !== "github")
|
|
388
595
|
return { error: `unsupported provider: ${provider}` };
|
|
596
|
+
const identity = vcsCliIdentity(root);
|
|
597
|
+
if (!identity.ok) return { error: identity.error };
|
|
598
|
+
if (opts.apiHost !== identity.host)
|
|
599
|
+
return { error: "approved hosting API host changed before execution" };
|
|
600
|
+
const remote = pushRemote(root);
|
|
601
|
+
const remoteIdentity = remote ? pushRemoteIdentity(remote) : null;
|
|
602
|
+
if (
|
|
603
|
+
!remote ||
|
|
604
|
+
!remoteIdentity ||
|
|
605
|
+
!pushTargetIsStable(root, remote) ||
|
|
606
|
+
opts.remote !== remoteIdentity
|
|
607
|
+
)
|
|
608
|
+
return { error: "approved merge destination changed before execution" };
|
|
609
|
+
if (!hostingApiHostMatches(remote, identity.host))
|
|
610
|
+
return { error: "hosting API host does not match the merge destination" };
|
|
611
|
+
if (identity.username?.toLowerCase() !== opts.account.toLowerCase())
|
|
612
|
+
return { error: "approved hosting account changed before execution" };
|
|
613
|
+
if (!/^[a-f0-9]{40,64}$/i.test(opts.sourceCommit))
|
|
614
|
+
return { error: "approved merge source commit is invalid" };
|
|
615
|
+
const currentCommit = spawnSync("git", ["rev-parse", "--verify", `refs/heads/${opts.source}`], {
|
|
616
|
+
cwd: root,
|
|
617
|
+
encoding: "utf8",
|
|
618
|
+
});
|
|
619
|
+
if (currentCommit.status !== 0 || currentCommit.stdout.trim() !== opts.sourceCommit)
|
|
620
|
+
return { error: "approved merge source commit changed before execution" };
|
|
621
|
+
const repository = cliRepository(remote, provider, cfg);
|
|
622
|
+
const repoPath = remoteRepoPath(remote)?.path;
|
|
623
|
+
if (!repository || !repoPath?.includes("/"))
|
|
624
|
+
return { error: "hosting repository could not be resolved" };
|
|
389
625
|
if (!hostingCliAvailable(provider))
|
|
390
626
|
return { error: `workflow CLI missing (required for ${provider})` };
|
|
627
|
+
const owner = repoPath.split("/")[0];
|
|
628
|
+
const endpoint =
|
|
629
|
+
provider === "github"
|
|
630
|
+
? `repos/${repository.replace(/^[^/]+\//u, "")}/pulls?state=open&head=${encodeURIComponent(`${owner}:${opts.source}`)}&base=${encodeURIComponent(opts.target)}&per_page=100`
|
|
631
|
+
: `projects/${encodeURIComponent(repoPath)}/merge_requests?state=opened&source_branch=${encodeURIComponent(opts.source)}&target_branch=${encodeURIComponent(opts.target)}&per_page=100`;
|
|
632
|
+
const listOpen = () =>
|
|
633
|
+
spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
|
|
634
|
+
cwd: root,
|
|
635
|
+
encoding: "utf8",
|
|
636
|
+
env: {
|
|
637
|
+
...process.env,
|
|
638
|
+
PATH: process.env.PATH ?? "",
|
|
639
|
+
...(provider === "github" ? { GH_HOST: identity.host } : { GITLAB_HOST: identity.host }),
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
const listedId = (stdout: string): string | null => {
|
|
643
|
+
try {
|
|
644
|
+
const records = JSON.parse(stdout) as Array<Record<string, any>>;
|
|
645
|
+
if (!Array.isArray(records) || records.length >= 100) return null;
|
|
646
|
+
const matches = records.filter((record) =>
|
|
647
|
+
provider === "github"
|
|
648
|
+
? record.state === "open" &&
|
|
649
|
+
record.head?.ref === opts.source &&
|
|
650
|
+
record.head?.sha === opts.sourceCommit &&
|
|
651
|
+
record.base?.ref === opts.target
|
|
652
|
+
: record.state === "opened" &&
|
|
653
|
+
record.source_branch === opts.source &&
|
|
654
|
+
record.sha === opts.sourceCommit &&
|
|
655
|
+
record.target_branch === opts.target,
|
|
656
|
+
);
|
|
657
|
+
if (matches.length !== 1) return null;
|
|
658
|
+
const id = provider === "github" ? matches[0].number : matches[0].iid;
|
|
659
|
+
return typeof id === "number" ? String(id) : null;
|
|
660
|
+
} catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
const list = listOpen();
|
|
665
|
+
if (list.status !== 0) return { error: "approved merge request could not be resolved" };
|
|
666
|
+
const mergeId = listedId(list.stdout ?? "");
|
|
667
|
+
if (!mergeId) return { error: "exact approved merge request is not open" };
|
|
668
|
+
// The PR/MR target can change after the first lookup. Re-resolve the
|
|
669
|
+
// target-filtered request immediately before calling the provider merge CLI.
|
|
670
|
+
const current = listOpen();
|
|
671
|
+
if (current.status !== 0 || listedId(current.stdout ?? "") !== mergeId)
|
|
672
|
+
return { error: "approved merge target or source changed before merge" };
|
|
391
673
|
const pr = (cfg.pr ?? {}) as Record<string, any>;
|
|
392
|
-
const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
|
|
393
674
|
const squash = pr.squashOnMerge !== false;
|
|
394
675
|
const removeBranch = pr.removeSourceBranch !== false;
|
|
395
676
|
let cmd: string[];
|
|
396
677
|
let cmdEnv: NodeJS.ProcessEnv;
|
|
397
678
|
if (provider === "gitlab") {
|
|
398
|
-
cmd = ["glab", "mr", "merge", opts.
|
|
679
|
+
cmd = ["glab", "mr", "merge", mergeId, "--repo", repository, "--sha", opts.sourceCommit];
|
|
399
680
|
cmd.push(squash ? "--squash" : "--squash=false");
|
|
400
681
|
if (removeBranch) cmd.push("--remove-source-branch");
|
|
401
682
|
cmd.push("--yes");
|
|
402
|
-
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "",
|
|
683
|
+
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_HOST: identity.host };
|
|
403
684
|
} else {
|
|
404
|
-
cmd = [
|
|
685
|
+
cmd = [
|
|
686
|
+
"gh",
|
|
687
|
+
"pr",
|
|
688
|
+
"merge",
|
|
689
|
+
mergeId,
|
|
690
|
+
"--repo",
|
|
691
|
+
repository,
|
|
692
|
+
"--match-head-commit",
|
|
693
|
+
opts.sourceCommit,
|
|
694
|
+
];
|
|
405
695
|
cmd.push(squash ? "--squash" : "--merge");
|
|
406
696
|
if (removeBranch) cmd.push("--delete-branch");
|
|
407
697
|
// NOTE: gh pr merge has no --yes flag; fully-specified flags merge
|
|
408
698
|
// non-interactively on their own.
|
|
409
|
-
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "",
|
|
699
|
+
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_HOST: identity.host };
|
|
410
700
|
}
|
|
411
701
|
const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });
|
|
412
702
|
if (result.status !== 0)
|
package/src/core/registration.ts
CHANGED
|
@@ -70,21 +70,6 @@ export function mergeOpenCodeConfig(
|
|
|
70
70
|
changed.push("plugin");
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// Drop share skills.paths — native ~/.config/opencode/skills links avoid
|
|
74
|
-
// triple-load duplicates. Matched by exact path segment, never substring: an
|
|
75
|
-
// unrelated dir like `~/projects/my-workflow-toolkit-skills` is preserved (D3).
|
|
76
|
-
const skills = base.skills;
|
|
77
|
-
if (isRecord(skills) && Array.isArray(skills.paths)) {
|
|
78
|
-
const next = skills.paths.filter((p) => {
|
|
79
|
-
const segment = String(p).split(/[\\/]/);
|
|
80
|
-
return !segment.some((seg) => named(seg, "workflow-toolkit"));
|
|
81
|
-
});
|
|
82
|
-
if (next.length !== skills.paths.length) {
|
|
83
|
-
base.skills = { ...skills, paths: next };
|
|
84
|
-
changed.push("skills.paths");
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
73
|
return { config: base, changed };
|
|
89
74
|
}
|
|
90
75
|
|
package/src/core/repo-tools.ts
CHANGED
|
@@ -9,7 +9,6 @@ export type RepoRuntime = {
|
|
|
9
9
|
changelogContext(root: string, range: string | undefined): RunResult;
|
|
10
10
|
docsContext(root: string, range: string | undefined): RunResult;
|
|
11
11
|
releaseContext(root: string, range: string): RunResult;
|
|
12
|
-
prCreate(root: string, env: Record<string, string>): RunResult;
|
|
13
12
|
initApply(root: string, action: string, env: Record<string, string>): RunResult;
|
|
14
13
|
initStatus(root: string): RunResult;
|
|
15
14
|
toolkitStatus(root: string): RunResult | Promise<RunResult>;
|