@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
|
|
6
|
+
export { hostingApiHostMatches } from "./vcs-config";
|
|
7
|
+
import { resolveBranchPolicyFor } from "./branch";
|
|
8
|
+
import { buildBody, parseGhIssue, parseGhRepo } from "../forge/pr-body";
|
|
9
|
+
|
|
10
|
+
// Port of scripts/pr-create.sh — build MR/PR body issue linking + create via glab/gh.
|
|
11
|
+
// Hosted creation is enabled (decision ae03c569): every create path pre-binds
|
|
12
|
+
// the approved source SHA through WORKIT_EXPECTED_* and the shared executor
|
|
13
|
+
// post-verifies the provider PR head before reporting success. The residual
|
|
14
|
+
// non-atomic source-SHA race is accepted.
|
|
15
|
+
|
|
16
|
+
// Every child has a deadline and no stdin, so a prompt or a stalled remote
|
|
17
|
+
// fails instead of hanging the caller (#644).
|
|
18
|
+
const bounded = (timeout: number) => ({
|
|
19
|
+
encoding: "utf8" as const,
|
|
20
|
+
timeout,
|
|
21
|
+
killSignal: "SIGKILL" as const,
|
|
22
|
+
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
});
|
|
25
|
+
const LOCAL = bounded(20_000);
|
|
26
|
+
const NETWORK = bounded(60_000);
|
|
27
|
+
const NO_PROMPT = {
|
|
28
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
29
|
+
GH_PROMPT_DISABLED: "1",
|
|
30
|
+
GLAB_CHECK_UPDATE: "false",
|
|
31
|
+
NO_PROMPT: "1",
|
|
32
|
+
};
|
|
33
|
+
const remoteRepoPath = (
|
|
34
|
+
remote: string,
|
|
35
|
+
): { host: string; path: string; protocol: string } | null => {
|
|
36
|
+
const raw = remote.trim();
|
|
37
|
+
if (!raw) return null;
|
|
38
|
+
if (!raw.includes("://")) {
|
|
39
|
+
const scp = /^(?:[^@\s]+@)?([^:]+):(.+)$/u.exec(raw);
|
|
40
|
+
if (scp)
|
|
41
|
+
return {
|
|
42
|
+
host: scp[1].toLowerCase(),
|
|
43
|
+
path: scp[2].replace(/^\//u, "").replace(/\.git$/u, ""),
|
|
44
|
+
protocol: "ssh:",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(raw);
|
|
49
|
+
return {
|
|
50
|
+
host: url.hostname.toLowerCase(),
|
|
51
|
+
path: url.pathname
|
|
52
|
+
.replace(/^\//u, "")
|
|
53
|
+
.replace(/\.git$/u, "")
|
|
54
|
+
.replace(/\/$/u, ""),
|
|
55
|
+
protocol: url.protocol,
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const cliRepository = (
|
|
63
|
+
remote: string,
|
|
64
|
+
provider: "github" | "gitlab",
|
|
65
|
+
cfg: Record<string, any>,
|
|
66
|
+
): string | null => {
|
|
67
|
+
const parsed = remoteRepoPath(remote);
|
|
68
|
+
if (!parsed?.path) return null;
|
|
69
|
+
const host = String(
|
|
70
|
+
provider === "github" ? (cfg.github?.host ?? "github.com") : (cfg.gitlab?.host ?? "gitlab.com"),
|
|
71
|
+
).toLowerCase();
|
|
72
|
+
if (provider === "github") return `${host}/${parsed.path}`;
|
|
73
|
+
return host === "gitlab.com" ? parsed.path : `https://${host}/${parsed.path}`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const truthy = (v: string | undefined): boolean =>
|
|
77
|
+
["1", "true", "yes"].includes(String(v ?? "").toLowerCase());
|
|
78
|
+
|
|
79
|
+
// Port of python's shutil.which — scan PATH in-process (no `which` binary needed).
|
|
80
|
+
function whichOnPath(tool: string): string | null {
|
|
81
|
+
// win32 CLIs/stubs carry .exe/.cmd suffixes (gh.exe, gh.cmd), so probe them
|
|
82
|
+
// too — accessSync with the bare name would never find them.
|
|
83
|
+
const names = process.platform === "win32" ? [tool, `${tool}.exe`, `${tool}.cmd`] : [tool];
|
|
84
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
85
|
+
if (!dir) continue;
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const candidate = path.join(dir, name);
|
|
88
|
+
try {
|
|
89
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
90
|
+
return candidate;
|
|
91
|
+
} catch {
|
|
92
|
+
/* keep scanning */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether the configured hosting provider's native CLI is available. */
|
|
100
|
+
export const hostingCliAvailable = (provider: string): boolean =>
|
|
101
|
+
whichOnPath(provider === "gitlab" ? "glab" : "gh") !== null;
|
|
102
|
+
|
|
103
|
+
function repoRoot(cwd: string): string {
|
|
104
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, ...LOCAL });
|
|
105
|
+
return result.status === 0 ? (result.stdout ?? "").trim() : cwd;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Git pushes to every configured push URL; authorization binds only one target. */
|
|
109
|
+
export const pushRemote = (cwd: string): string | null => {
|
|
110
|
+
const result = spawnSync("git", ["remote", "get-url", "--push", "--all", "origin"], {
|
|
111
|
+
cwd,
|
|
112
|
+
...LOCAL,
|
|
113
|
+
});
|
|
114
|
+
if (result.status !== 0) return null;
|
|
115
|
+
const urls = (result.stdout ?? "")
|
|
116
|
+
.split(/\r?\n/u)
|
|
117
|
+
.map((url) => url.trim())
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
return urls.length === 1 ? urls[0] : null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const safePushUrl = (remote: string): string | null => {
|
|
123
|
+
const raw = remote.trim();
|
|
124
|
+
if (path.isAbsolute(raw)) return pathToFileURL(raw).href;
|
|
125
|
+
if (!raw.includes("://")) {
|
|
126
|
+
const scp = /^(?:([^@\s]+)@)?([^:]+):(.+)$/u.exec(raw);
|
|
127
|
+
if (scp) return `${scp[1] || "git"}@${scp[2]}:${scp[3]}`;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const url = new URL(raw);
|
|
131
|
+
if (!["file:", "git:", "http:", "https:", "ssh:"].includes(url.protocol)) return null;
|
|
132
|
+
url.password = "";
|
|
133
|
+
if (url.protocol !== "ssh:") url.username = "";
|
|
134
|
+
url.search = "";
|
|
135
|
+
url.hash = "";
|
|
136
|
+
return url.toString();
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const pushRemoteIdentity = (remote: string): string | null => {
|
|
143
|
+
const raw = remote.trim();
|
|
144
|
+
if (!raw) return null;
|
|
145
|
+
if (path.isAbsolute(raw)) return pathToFileURL(raw).href;
|
|
146
|
+
if (!raw.includes("://")) {
|
|
147
|
+
const scp = /^(?:([^@\s]+)@)?([^:]+):(.+)$/u.exec(raw);
|
|
148
|
+
if (scp)
|
|
149
|
+
return `ssh://${scp[1] || "git"}@${scp[2].toLowerCase()}/${scp[3].replace(/^\//u, "")}`;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const url = new URL(raw);
|
|
153
|
+
url.password = "";
|
|
154
|
+
if (url.protocol !== "ssh:") url.username = "";
|
|
155
|
+
url.hostname = url.hostname.toLowerCase();
|
|
156
|
+
url.search = "";
|
|
157
|
+
url.hash = "";
|
|
158
|
+
return url.toString().replace(/\/$/u, "");
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Git's URL rewrite rules also apply to explicit push URLs. */
|
|
165
|
+
export const pushTargetIsStable = (cwd: string, remote: string): boolean => {
|
|
166
|
+
const target = safePushUrl(remote);
|
|
167
|
+
if (!target) return false;
|
|
168
|
+
const result = spawnSync(
|
|
169
|
+
"git",
|
|
170
|
+
["config", "--get-regexp", "^url\\..*\\.(insteadof|pushinsteadof)$"],
|
|
171
|
+
{ cwd, ...LOCAL },
|
|
172
|
+
);
|
|
173
|
+
if (result.status !== 0 && result.status !== 1) return false;
|
|
174
|
+
const prefixes = (result.stdout ?? "")
|
|
175
|
+
.split(/\r?\n/u)
|
|
176
|
+
.map((line) => line.trim().slice(line.trim().indexOf(" ") + 1))
|
|
177
|
+
.filter(Boolean);
|
|
178
|
+
return !prefixes.some((prefix) => target.startsWith(prefix));
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Port of pr-create.sh --build-body — pure body builder, no network. */
|
|
182
|
+
export function prBuildBody(env: NodeJS.ProcessEnv, cwd?: string): string {
|
|
183
|
+
const ghLinkOnPr = truthy(env.GH_LINK_ON_PR);
|
|
184
|
+
let ghRepo = env.GH_REPO || null;
|
|
185
|
+
if (!ghRepo && ghLinkOnPr) {
|
|
186
|
+
const result = spawnSync("git", ["remote", "get-url", "origin"], { cwd, ...LOCAL });
|
|
187
|
+
if (result.status === 0) ghRepo = parseGhRepo(result.stdout ?? "");
|
|
188
|
+
}
|
|
189
|
+
return buildBody(
|
|
190
|
+
env.BODY ?? "",
|
|
191
|
+
env.BRANCH ?? "",
|
|
192
|
+
truthy(env.LINK_ISSUES),
|
|
193
|
+
env.YT_BASE_URL ?? "",
|
|
194
|
+
env.WORKIT_YT_ISSUE ?? "",
|
|
195
|
+
ghLinkOnPr,
|
|
196
|
+
env.WORKIT_GH_ISSUE ?? "",
|
|
197
|
+
env.WORKIT_GH_ISSUE_RELATION ?? "closes",
|
|
198
|
+
ghRepo,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Port of scripts/pr-create.sh create mode — glab/gh MR/PR creation. */
|
|
203
|
+
export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, any> {
|
|
204
|
+
// An explicitly supplied, already-approved checkout root outranks ambient
|
|
205
|
+
// process state; otherwise an unrelated env root could redirect the effect.
|
|
206
|
+
const root = cwd ? repoRoot(cwd) : (process.env.WORKIT_WORKSPACE_ROOT ?? process.cwd());
|
|
207
|
+
const policy = resolveBranchPolicyFor(root);
|
|
208
|
+
const cfg = vcsConfig("load", root);
|
|
209
|
+
if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
|
|
210
|
+
|
|
211
|
+
const provider = cfg.provider as string;
|
|
212
|
+
if (provider !== "gitlab" && provider !== "github")
|
|
213
|
+
return { error: `unsupported provider: ${provider}` };
|
|
214
|
+
const cli = provider === "gitlab" ? "glab" : "gh";
|
|
215
|
+
const installUrl =
|
|
216
|
+
provider === "gitlab" ? "https://gitlab.com/gitlab-org/cli" : "https://cli.github.com";
|
|
217
|
+
if (whichOnPath(cli) === null)
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
cli_missing: true,
|
|
221
|
+
error: `workflow CLI missing: ${cli} (required for ${provider}). Install: ${installUrl}`,
|
|
222
|
+
install_url: installUrl,
|
|
223
|
+
};
|
|
224
|
+
const identity = vcsCliIdentity(root);
|
|
225
|
+
if (!identity.ok) return { error: identity.error };
|
|
226
|
+
if (env.WORKIT_EXPECTED_API_HOST && env.WORKIT_EXPECTED_API_HOST !== identity.host)
|
|
227
|
+
return { error: "approved hosting API host changed before execution" };
|
|
228
|
+
const pushUrl = pushRemote(root);
|
|
229
|
+
const targetUrl = pushUrl ? safePushUrl(pushUrl) : null;
|
|
230
|
+
const targetIdentity = pushUrl ? pushRemoteIdentity(pushUrl) : null;
|
|
231
|
+
const targetRepo = pushUrl ? cliRepository(pushUrl, provider, cfg) : null;
|
|
232
|
+
if (!pushUrl || !targetUrl || !targetIdentity || !pushTargetIsStable(root, pushUrl))
|
|
233
|
+
return {
|
|
234
|
+
error: "origin must have exactly one supported push URL for a reserved hosting action",
|
|
235
|
+
};
|
|
236
|
+
const localOnlyTarget = path.isAbsolute(pushUrl) || pushUrl.startsWith("file://");
|
|
237
|
+
if (
|
|
238
|
+
(!localOnlyTarget || env.WORKIT_EXPECTED_API_HOST) &&
|
|
239
|
+
!hostingApiHostMatches(pushUrl, identity.host)
|
|
240
|
+
)
|
|
241
|
+
return { error: "hosting API host does not match the push destination" };
|
|
242
|
+
if (env.WORKIT_EXPECTED_REMOTE && env.WORKIT_EXPECTED_REMOTE !== targetIdentity)
|
|
243
|
+
return { error: "approved push destination changed before execution" };
|
|
244
|
+
if (
|
|
245
|
+
env.WORKIT_EXPECTED_ACCOUNT &&
|
|
246
|
+
identity.username?.toLowerCase() !== env.WORKIT_EXPECTED_ACCOUNT.toLowerCase()
|
|
247
|
+
)
|
|
248
|
+
return { error: "approved hosting account changed before execution" };
|
|
249
|
+
|
|
250
|
+
// The caller selects the target; GitHub/GitLab enforce their own protections.
|
|
251
|
+
const targetOverride = env.WF_PR_TARGET;
|
|
252
|
+
let resolvedDefault = String(cfg.defaultTargetBranch ?? policy.defaultTargetBranch ?? "develop");
|
|
253
|
+
if (!targetOverride) {
|
|
254
|
+
// Release tracks resolve in resolve mode: the target is the branch's line.
|
|
255
|
+
const resolved = vcsConfig("resolve", root);
|
|
256
|
+
if (resolved.ok === false) return { error: String(resolved.error) };
|
|
257
|
+
if (resolved.releaseTrack?.blocking) return { error: String(resolved.releaseTrack.blocking) };
|
|
258
|
+
resolvedDefault = String(resolved.defaultTargetBranch ?? resolvedDefault);
|
|
259
|
+
}
|
|
260
|
+
const target = targetOverride || resolvedDefault;
|
|
261
|
+
const title = String(env.WF_PR_TITLE ?? "");
|
|
262
|
+
const br = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
263
|
+
cwd: root,
|
|
264
|
+
...LOCAL,
|
|
265
|
+
});
|
|
266
|
+
const branch = br.status === 0 ? (br.stdout ?? "").trim() : "";
|
|
267
|
+
const sourceResult = branch
|
|
268
|
+
? spawnSync("git", ["rev-parse", "--verify", `refs/heads/${branch}`], {
|
|
269
|
+
cwd: root,
|
|
270
|
+
...LOCAL,
|
|
271
|
+
})
|
|
272
|
+
: null;
|
|
273
|
+
const sourceCommit = sourceResult?.status === 0 ? (sourceResult.stdout ?? "").trim() : "";
|
|
274
|
+
if (
|
|
275
|
+
env.WORKIT_EXPECTED_REMOTE &&
|
|
276
|
+
(!env.WORKIT_EXPECTED_SOURCE_BRANCH || !env.WORKIT_EXPECTED_SOURCE_COMMIT)
|
|
277
|
+
)
|
|
278
|
+
return { error: "approved PR source branch and commit are incomplete" };
|
|
279
|
+
if (
|
|
280
|
+
(env.WORKIT_EXPECTED_SOURCE_BRANCH && env.WORKIT_EXPECTED_SOURCE_BRANCH !== branch) ||
|
|
281
|
+
(env.WORKIT_EXPECTED_SOURCE_COMMIT && env.WORKIT_EXPECTED_SOURCE_COMMIT !== sourceCommit)
|
|
282
|
+
)
|
|
283
|
+
return { error: "approved PR source branch or commit changed before execution" };
|
|
284
|
+
const pr = (cfg.pr ?? {}) as Record<string, any>;
|
|
285
|
+
const body = env.WF_PR_BODY ?? "";
|
|
286
|
+
const draft = String(env.WF_PR_DRAFT ?? "false").toLowerCase() === "true";
|
|
287
|
+
|
|
288
|
+
let baseUrl = cfg.youtrack_base_url as string | undefined;
|
|
289
|
+
if (!baseUrl) {
|
|
290
|
+
const ytCfg =
|
|
291
|
+
process.env.WORKIT_YOUTRACK_CONFIG ??
|
|
292
|
+
path.join(path.dirname(String(cfg.configPath)), "youtrack.json");
|
|
293
|
+
try {
|
|
294
|
+
const yt = JSON.parse(fs.readFileSync(ytCfg, "utf8")) as Record<string, any>;
|
|
295
|
+
if (yt && typeof yt === "object") baseUrl = yt.baseUrl;
|
|
296
|
+
} catch {
|
|
297
|
+
/* optional */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const ghLinkOnPr = cfg.issues_provider === "github" && cfg.link_on_pr === true;
|
|
301
|
+
let ghRepo: string | null = null;
|
|
302
|
+
if (ghLinkOnPr) {
|
|
303
|
+
ghRepo = remoteRepoPath(pushUrl)?.path ?? null;
|
|
304
|
+
}
|
|
305
|
+
const finalBody = buildBody(
|
|
306
|
+
body,
|
|
307
|
+
branch,
|
|
308
|
+
cfg.link_issues === true,
|
|
309
|
+
baseUrl ?? "",
|
|
310
|
+
env.WORKIT_YT_ISSUE ?? "",
|
|
311
|
+
ghLinkOnPr,
|
|
312
|
+
env.WORKIT_GH_ISSUE ?? "",
|
|
313
|
+
env.WORKIT_GH_ISSUE_RELATION ?? "closes",
|
|
314
|
+
ghRepo,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const squash = pr.squashOnMerge !== false;
|
|
318
|
+
const removeBranch = pr.removeSourceBranch !== false;
|
|
319
|
+
const push = pr.pushBranch !== false;
|
|
320
|
+
const skipConfirm = pr.confirmSkip !== false;
|
|
321
|
+
const remoteBranchTip = (): string | null => {
|
|
322
|
+
const read = spawnSync("git", ["ls-remote", targetUrl, `refs/heads/${branch}`], {
|
|
323
|
+
cwd: root,
|
|
324
|
+
...NETWORK,
|
|
325
|
+
env: { ...process.env, ...NO_PROMPT, PATH: process.env.PATH ?? "" },
|
|
326
|
+
});
|
|
327
|
+
return read.status === 0 ? (read.stdout ?? "").trim().split(/\s+/u)[0] || null : null;
|
|
328
|
+
};
|
|
329
|
+
if (!branch || !sourceCommit)
|
|
330
|
+
return {
|
|
331
|
+
error: "push failed",
|
|
332
|
+
provider,
|
|
333
|
+
mode: "push",
|
|
334
|
+
targetBranch: target,
|
|
335
|
+
stderr: branch
|
|
336
|
+
? "source branch commit could not be resolved"
|
|
337
|
+
: "empty current branch (detached HEAD or unborn HEAD)",
|
|
338
|
+
};
|
|
339
|
+
if (push) {
|
|
340
|
+
const pushed = spawnSync(
|
|
341
|
+
"git",
|
|
342
|
+
["push", "-u", targetUrl, `${sourceCommit}:refs/heads/${branch}`],
|
|
343
|
+
{
|
|
344
|
+
cwd: root,
|
|
345
|
+
...NETWORK,
|
|
346
|
+
env: { ...process.env, ...NO_PROMPT, PATH: process.env.PATH ?? "" },
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
if (pushed.status !== 0)
|
|
350
|
+
return { error: "push failed", provider, stderr: (pushed.stderr ?? "").slice(0, 800) };
|
|
351
|
+
}
|
|
352
|
+
if (remoteBranchTip() !== sourceCommit)
|
|
353
|
+
return { error: "push destination branch does not match the approved source commit" };
|
|
354
|
+
|
|
355
|
+
let cmd: string[];
|
|
356
|
+
let cmdEnv: NodeJS.ProcessEnv;
|
|
357
|
+
if (provider === "gitlab") {
|
|
358
|
+
// glab non-interactive mode requires BOTH title and description flags (issue #652).
|
|
359
|
+
cmd = [
|
|
360
|
+
"glab",
|
|
361
|
+
"mr",
|
|
362
|
+
"create",
|
|
363
|
+
...(targetRepo ? ["-R", targetRepo] : []),
|
|
364
|
+
"-t",
|
|
365
|
+
title,
|
|
366
|
+
"-d",
|
|
367
|
+
finalBody || "",
|
|
368
|
+
"-b",
|
|
369
|
+
target,
|
|
370
|
+
];
|
|
371
|
+
cmd.push(squash ? "--squash-before-merge" : "--squash-before-merge=false");
|
|
372
|
+
cmd.push(removeBranch ? "--remove-source-branch" : "--remove-source-branch=false");
|
|
373
|
+
if (draft) cmd.push("--draft");
|
|
374
|
+
if (skipConfirm) cmd.push("--yes");
|
|
375
|
+
// bun's Windows spawn only consults PATH when the env object carries an
|
|
376
|
+
// explicit PATH key — a spread-only PATH is invisible to the lookup and
|
|
377
|
+
// uv_spawn fails with ENOENT even though the CLI is on PATH.
|
|
378
|
+
cmdEnv = {
|
|
379
|
+
...process.env,
|
|
380
|
+
...NO_PROMPT,
|
|
381
|
+
PATH: process.env.PATH ?? "",
|
|
382
|
+
GITLAB_HOST: identity.host,
|
|
383
|
+
};
|
|
384
|
+
} else {
|
|
385
|
+
cmd = [
|
|
386
|
+
"gh",
|
|
387
|
+
"pr",
|
|
388
|
+
"create",
|
|
389
|
+
...(targetRepo ? ["--repo", targetRepo] : []),
|
|
390
|
+
"--title",
|
|
391
|
+
title,
|
|
392
|
+
"--base",
|
|
393
|
+
target,
|
|
394
|
+
];
|
|
395
|
+
if (finalBody) cmd.push("--body", finalBody);
|
|
396
|
+
if (draft) cmd.push("--draft");
|
|
397
|
+
cmdEnv = { ...process.env, ...NO_PROMPT, PATH: process.env.PATH ?? "", GH_HOST: identity.host };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, ...NETWORK, env: cmdEnv });
|
|
401
|
+
if (result.status !== 0) {
|
|
402
|
+
const err = (result.stderr ?? result.stdout ?? "").trim();
|
|
403
|
+
let hint: Record<string, any> | null = null;
|
|
404
|
+
if (
|
|
405
|
+
provider === "gitlab" &&
|
|
406
|
+
(err.includes("409") || err.toLowerCase().includes("already exists"))
|
|
407
|
+
) {
|
|
408
|
+
const list = spawnSync("glab", ["mr", "list", `--source-branch=${branch}`, "--output=json"], {
|
|
409
|
+
cwd: root,
|
|
410
|
+
...NETWORK,
|
|
411
|
+
env: cmdEnv,
|
|
412
|
+
});
|
|
413
|
+
if (list.status === 0 && (list.stdout ?? "").trim()) {
|
|
414
|
+
try {
|
|
415
|
+
const mrs = JSON.parse(list.stdout ?? "") as Array<Record<string, any>>;
|
|
416
|
+
if (mrs.length) {
|
|
417
|
+
hint = {
|
|
418
|
+
reason: "merge_request_already_exists",
|
|
419
|
+
existing: mrs[0],
|
|
420
|
+
next_step: "Use glab mr update or close the open MR before creating again",
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
/* no hint */
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const payload: Record<string, any> = {
|
|
429
|
+
error: "create failed",
|
|
430
|
+
provider,
|
|
431
|
+
stderr: err.slice(0, 800),
|
|
432
|
+
};
|
|
433
|
+
if (hint) payload.hint = hint;
|
|
434
|
+
return payload;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
ok: true,
|
|
439
|
+
provider,
|
|
440
|
+
targetBranch: target,
|
|
441
|
+
squashOnMerge: squash,
|
|
442
|
+
removeSourceBranch: removeBranch,
|
|
443
|
+
output: (result.stdout ?? "").trim(),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Moved to forge/pr-body.ts (S11); re-exported for the managed action path until S16.
|
|
448
|
+
export { buildBody, parseGhRepo, parseGhIssue };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PR description template a workspace fills (`prTemplate`) and the check
|
|
3
|
+
* `workit pr create` runs on a body: every template heading present, none of
|
|
4
|
+
* the template's HTML placeholder comments left.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { PR_TEMPLATE_DIRS, PR_TEMPLATE_FILES, findPrTemplate, repoRoot } from "./repo-context";
|
|
9
|
+
|
|
10
|
+
/** Workit's own template: the sections `references/pr-body.md` in the ship skill explains. */
|
|
11
|
+
export const WORKIT_PR_TEMPLATE = `## Summary
|
|
12
|
+
|
|
13
|
+
<!-- The smallest visual that makes the change clear (pseudocode, a call tree, a file tree, a Mermaid diagram or one diff hunk), with a sentence or two in the domain's words. -->
|
|
14
|
+
|
|
15
|
+
## Evidence
|
|
16
|
+
|
|
17
|
+
<!-- Before and after, observed: the checks recorded on the head SHA and the verdict, or a command with its output (red before, green after). -->
|
|
18
|
+
|
|
19
|
+
## Merge danger
|
|
20
|
+
|
|
21
|
+
<!-- One line: one-way or two-way door, plus the blast radius (who or what breaks if it is wrong). -->
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
/** Where `auto` and `repo` look, for a refusal that names them. */
|
|
25
|
+
export const PR_TEMPLATE_LOOKED_IN: readonly string[] = [
|
|
26
|
+
...PR_TEMPLATE_FILES,
|
|
27
|
+
...PR_TEMPLATE_DIRS.map((dir) => `${dir}/*.md (one file)`),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export type PrTemplate = {
|
|
31
|
+
/** The workspace setting that chose it: auto, repo, workit or a path. */
|
|
32
|
+
setting: string;
|
|
33
|
+
source: "repo" | "workit";
|
|
34
|
+
/**
|
|
35
|
+
* Whether `workit pr create` checks a body against it: a repo template, or
|
|
36
|
+
* Workit's when the workspace sets `prTemplate: workit`. Under `auto` in a
|
|
37
|
+
* repo with no template, Workit's is only a suggestion.
|
|
38
|
+
*/
|
|
39
|
+
enforced: boolean;
|
|
40
|
+
/** Repo-relative path of the repo template; null for Workit's. */
|
|
41
|
+
path: string | null;
|
|
42
|
+
content: string;
|
|
43
|
+
headings: string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type PrTemplateResult =
|
|
47
|
+
| { ok: true; template: PrTemplate }
|
|
48
|
+
| {
|
|
49
|
+
ok: false;
|
|
50
|
+
reason: "pr_template_missing";
|
|
51
|
+
setting: string;
|
|
52
|
+
/** The paths searched (repo) or the one configured (a path). */
|
|
53
|
+
lookedIn: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const fromRepo = (setting: string, root: string, rel: string): PrTemplate => {
|
|
57
|
+
const content = readFileSync(path.join(root, ...rel.split("/")), "utf8");
|
|
58
|
+
return {
|
|
59
|
+
setting,
|
|
60
|
+
source: "repo",
|
|
61
|
+
path: rel,
|
|
62
|
+
content,
|
|
63
|
+
headings: markdownHeadings(content),
|
|
64
|
+
enforced: true,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const isFile = (file: string): boolean => {
|
|
69
|
+
try {
|
|
70
|
+
return existsSync(file) && statSync(file).isFile();
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The template `setting` picks for the repo at cwd. */
|
|
77
|
+
export function resolvePrTemplate(cwd: string, setting: string): PrTemplateResult {
|
|
78
|
+
const root = path.resolve(repoRoot(cwd));
|
|
79
|
+
const workit: PrTemplate = {
|
|
80
|
+
setting,
|
|
81
|
+
source: "workit",
|
|
82
|
+
path: null,
|
|
83
|
+
content: WORKIT_PR_TEMPLATE,
|
|
84
|
+
headings: markdownHeadings(WORKIT_PR_TEMPLATE),
|
|
85
|
+
enforced: setting === "workit",
|
|
86
|
+
};
|
|
87
|
+
if (setting === "workit") return { ok: true, template: workit };
|
|
88
|
+
if (setting === "auto" || setting === "repo") {
|
|
89
|
+
const found = findPrTemplate(root);
|
|
90
|
+
if (found) return { ok: true, template: fromRepo(setting, root, found) };
|
|
91
|
+
return setting === "auto"
|
|
92
|
+
? { ok: true, template: workit }
|
|
93
|
+
: {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: "pr_template_missing",
|
|
96
|
+
setting,
|
|
97
|
+
lookedIn: [...PR_TEMPLATE_LOOKED_IN],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return isFile(path.join(root, ...setting.split("/")))
|
|
101
|
+
? { ok: true, template: fromRepo(setting, root, setting) }
|
|
102
|
+
: {
|
|
103
|
+
ok: false,
|
|
104
|
+
reason: "pr_template_missing",
|
|
105
|
+
setting,
|
|
106
|
+
lookedIn: [setting],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Markdown with fenced code blocks blanked, so neither headings nor comments inside them count. */
|
|
111
|
+
const withoutFences = (text: string): string[] => {
|
|
112
|
+
let fence: string | null = null;
|
|
113
|
+
return text.split(/\r?\n/u).map((line) => {
|
|
114
|
+
const marker = /^\s{0,3}(`{3,}|~{3,})/u.exec(line)?.[1];
|
|
115
|
+
if (marker && (fence === null || marker.startsWith(fence))) {
|
|
116
|
+
fence = fence === null ? marker : null;
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
return fence === null ? line : "";
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const stripComments = (text: string): string => text.replaceAll(/<!--[\s\S]*?-->/gu, "");
|
|
124
|
+
|
|
125
|
+
const normalize = (text: string): string => text.replaceAll(/\s+/gu, " ").trim().toLowerCase();
|
|
126
|
+
|
|
127
|
+
/** ATX headings (`#` to `######`) outside code fences and HTML comments, in order. */
|
|
128
|
+
export function markdownHeadings(text: string): string[] {
|
|
129
|
+
return stripComments(withoutFences(text).join("\n"))
|
|
130
|
+
.split("\n")
|
|
131
|
+
.map((line) => /^\s{0,3}#{1,6}\s+(.*?)(?:\s+#+)?\s*$/u.exec(line)?.[1]?.trim() ?? "")
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The HTML comments of `text` outside code fences. */
|
|
136
|
+
const htmlComments = (text: string): string[] =>
|
|
137
|
+
withoutFences(text)
|
|
138
|
+
.join("\n")
|
|
139
|
+
.match(/<!--[\s\S]*?-->/gu) ?? [];
|
|
140
|
+
|
|
141
|
+
export type PrBodyCheck = {
|
|
142
|
+
/** Template headings the body has no heading for (text compared case-insensitively). */
|
|
143
|
+
missingHeadings: string[];
|
|
144
|
+
/** Template placeholder comments still in the body. */
|
|
145
|
+
leftComments: string[];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** What a body leaves unfilled of `template`; both lists empty when it is filled. */
|
|
149
|
+
export function checkPrBody(body: string, template: PrTemplate): PrBodyCheck {
|
|
150
|
+
const have = new Set(markdownHeadings(body).map(normalize));
|
|
151
|
+
const bodyComments = new Set(htmlComments(body).map(normalize));
|
|
152
|
+
return {
|
|
153
|
+
missingHeadings: template.headings.filter((heading) => !have.has(normalize(heading))),
|
|
154
|
+
leftComments: [...new Set(htmlComments(template.content))].filter((comment) =>
|
|
155
|
+
bodyComments.has(normalize(comment)),
|
|
156
|
+
),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `the repo template <path>` or `Workit's template`, for messages. */
|
|
161
|
+
export const templateLabel = (template: PrTemplate): string =>
|
|
162
|
+
template.path ? `the repo template ${template.path}` : "Workit's template";
|