@brainervirus/workit-core 0.6.0 → 0.7.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 +10 -14
- package/scripts/doctor-check.ts +20 -0
- package/scripts/install-cursor-plugin.sh +51 -28
- package/scripts/install-opencode-plugin.sh +25 -22
- package/scripts/rewrite-workspace-deps.ts +15 -9
- package/scripts/sync-runtime.sh +71 -19
- package/scripts/vendor-assets.ts +37 -0
- package/skills/wk-implement/SKILL.md +2 -2
- package/skills/wk-pr/SKILL.md +1 -1
- package/src/core/boundary.ts +27 -0
- package/src/core/branch-policy.ts +63 -0
- package/src/core/branch.ts +166 -57
- package/src/core/changelog.ts +17 -14
- package/src/core/config-guard.ts +9 -2
- package/src/core/config.ts +227 -32
- package/src/core/detector.ts +22 -11
- package/src/core/docs-layout.ts +251 -0
- package/src/core/docs-migration.ts +639 -0
- package/src/core/docs-repo.ts +58 -21
- package/src/core/docs-validate.ts +181 -43
- package/src/core/doctor.ts +801 -0
- package/src/core/flow-state.ts +1579 -137
- package/src/core/git.ts +22 -5
- package/src/core/gitignore.ts +11 -2
- package/src/core/handoff-context.ts +18 -5
- package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
- package/src/core/hygiene.ts +53 -17
- package/src/core/init.ts +128 -31
- package/src/core/logger.ts +321 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/parse-sections.ts +2 -2
- package/src/core/plan-tasks.ts +13 -3
- package/src/core/ports/init-toolkit-status.ts +1 -1
- package/src/core/ports/vcs-verify-token.ts +1 -1
- package/src/core/ports/youtrack-api.ts +4 -2
- package/src/core/ports/youtrack-config.ts +1 -3
- package/src/core/ports/youtrack-verify-token.ts +1 -1
- package/src/core/pr-create.ts +156 -29
- package/src/core/present.ts +11 -2
- package/src/core/registration.ts +215 -0
- package/src/core/reminder.ts +1 -2
- package/src/core/repo-context.ts +447 -0
- package/src/core/repo-tool.ts +4 -1
- package/src/core/repo-tools.ts +23 -0
- package/src/core/rules.ts +10 -7
- package/src/core/safe-write.ts +22 -0
- package/src/core/scripts.ts +3 -39
- package/src/core/sdd.ts +56 -31
- package/src/core/setup-state.ts +54 -0
- package/src/core/setup.ts +1216 -0
- package/src/core/skill-manifests.ts +95 -0
- package/src/core/support-matrix.ts +12 -0
- package/src/core/sync-runtime.ts +348 -0
- package/src/core/templates.ts +16 -6
- package/src/core/vcs-config.ts +191 -62
- package/src/core/verify-parse.ts +4 -2
- package/src/core/verify-project.ts +181 -0
- package/src/core/workspaces.ts +136 -17
- package/src/core/youtrack-tools.ts +228 -0
- package/src/core/youtrack.ts +320 -87
- package/src/core.ts +18 -3
- package/templates/execution-contract.md +9 -7
- package/templates/superpowers-doc-contract.md +5 -4
- package/scripts/_shared/common.sh +0 -158
- package/scripts/changelog-context.sh +0 -42
- package/scripts/docs-refresh-context.sh +0 -40
- package/scripts/init/apply.sh +0 -5
- package/scripts/init/status.sh +0 -5
- package/scripts/init/toolkit-status.sh +0 -5
- package/scripts/pr-create.sh +0 -5
- package/scripts/pr-ready-context.sh +0 -88
- package/scripts/present/ascii-wireframe.sh +0 -5
- package/scripts/present/flow-diagram.sh +0 -5
- package/scripts/release-notes-context.sh +0 -40
- package/scripts/vcs/config.sh +0 -5
- package/scripts/vcs/merged-style.sh +0 -5
- package/scripts/vcs/token-create-urls.sh +0 -5
- package/scripts/vcs/verify-token.sh +0 -5
- package/scripts/verify-project.sh +0 -140
- package/scripts/youtrack/api.sh +0 -5
- package/scripts/youtrack/config.sh +0 -5
- package/scripts/youtrack/greeting.sh +0 -5
- package/scripts/youtrack/parse-duration.sh +0 -5
- package/scripts/youtrack/token-create-url.sh +0 -5
- package/scripts/youtrack/verify-token.sh +0 -5
- package/scripts/youtrack/work-date-ms.sh +0 -5
- package/src/tools/docs-repo.ts +0 -42
- package/src/tools/flow.ts +0 -88
- package/src/tools/index.ts +0 -22
- package/src/tools/present.ts +0 -45
- package/src/tools/repo.ts +0 -357
- package/src/tools/rules.ts +0 -30
- package/src/tools/sdd.ts +0 -189
- package/src/tools/templates.ts +0 -27
- package/src/tools/youtrack.ts +0 -360
package/src/core/vcs-config.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { configDir } from "./config";
|
|
5
5
|
import { resolveWorkspace } from "./workspaces";
|
|
6
|
-
|
|
6
|
+
import { resolveBranchPolicyFor } from "./branch";
|
|
7
7
|
// Ports of scripts/vcs/config.sh + verify-token.sh + token-create-urls.sh + merged-style.sh.
|
|
8
8
|
// Token never printed.
|
|
9
9
|
|
|
@@ -14,22 +14,55 @@ export const vcsConfigPath = (): string =>
|
|
|
14
14
|
|
|
15
15
|
const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
|
|
16
16
|
|
|
17
|
-
const vcsCwd = (cwd?: string): string =>
|
|
17
|
+
const vcsCwd = (cwd?: string): string =>
|
|
18
|
+
process.env.WORKFLOW_WORKSPACE_ROOT ?? cwd ?? process.cwd();
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
// RL-03b: the origin remote is the ground truth for PR creation. A stale global
|
|
21
|
+
// provider (e.g. gitlab from another repo) must not drive glab on a
|
|
22
|
+
// github.com-hosted checkout. Recognized hosts only — custom/self-hosted
|
|
23
|
+
// remotes keep the configured provider, and a missing origin is untouched.
|
|
24
|
+
const remoteProvider = (cwd: string): string | null => {
|
|
25
|
+
const r = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
|
|
26
|
+
if (r.status !== 0) return null;
|
|
27
|
+
const url = (r.stdout ?? "").trim();
|
|
28
|
+
if (/github\.com[:/]/.test(url)) return "github";
|
|
29
|
+
if (/gitlab\.com[:/]/.test(url)) return "gitlab";
|
|
30
|
+
return null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// RL-01: typed vcs.json reader. Missing is a legitimate unconfigured state;
|
|
34
|
+
// malformed (parse failure or a non-object) is reported with the exact path so
|
|
35
|
+
// risky consumers stop instead of silently reading defaults.
|
|
36
|
+
export type VcsConfigResult = {
|
|
37
|
+
status: "missing" | "valid" | "malformed";
|
|
38
|
+
path: string;
|
|
39
|
+
config: Record<string, any>;
|
|
40
|
+
error?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const readVcsConfig = (): VcsConfigResult => {
|
|
44
|
+
const file = vcsConfigPath();
|
|
45
|
+
let raw: string;
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFileSync(file, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
return { status: "missing", path: file, config: {} };
|
|
50
|
+
}
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(raw);
|
|
54
|
+
} catch {
|
|
55
|
+
return { status: "malformed", path: file, config: {}, error: `${file} is not valid JSON` };
|
|
31
56
|
}
|
|
32
|
-
|
|
57
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
58
|
+
return { status: "valid", path: file, config: parsed as Record<string, any> };
|
|
59
|
+
}
|
|
60
|
+
return { status: "malformed", path: file, config: {}, error: `${file} is not a JSON object` };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function readVcsJson(): { config: Record<string, any>; path: string; ok: boolean } {
|
|
64
|
+
const { status, path: cfgPath, config } = readVcsConfig();
|
|
65
|
+
return { config, path: cfgPath, ok: status === "valid" };
|
|
33
66
|
}
|
|
34
67
|
|
|
35
68
|
/** Port of scripts/vcs/config.sh — mode: load | summary | resolve. */
|
|
@@ -38,21 +71,50 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
38
71
|
const wsVcs = (ws?.vcs ?? {}) as Record<string, any>;
|
|
39
72
|
const wsYt = (ws?.youtrack ?? {}) as Record<string, any>;
|
|
40
73
|
const wsIssues = (ws?.issues ?? {}) as Record<string, any>;
|
|
41
|
-
const {
|
|
74
|
+
const { status: cfgStatus, path: cfgPath, config: cfg, error: cfgError } = readVcsConfig();
|
|
42
75
|
|
|
43
|
-
|
|
44
|
-
|
|
76
|
+
// Explicit workspace vcs.provider is the user's per-repo intent and wins;
|
|
77
|
+
// otherwise, with a determinate repo root (explicit cwd or host-provided
|
|
78
|
+
// WORKFLOW_WORKSPACE_ROOT), the origin remote is authoritative over the
|
|
79
|
+
// global default (RL-03b). Ambient process.cwd() callers keep the legacy
|
|
80
|
+
// config-only contract (CA-06 parity), so a stale global default cannot
|
|
81
|
+
// contaminate host-agnostic reads.
|
|
82
|
+
const root = vcsCwd(cwd);
|
|
83
|
+
const determinateRoot = cwd !== undefined || process.env.WORKFLOW_WORKSPACE_ROOT !== undefined;
|
|
84
|
+
const provider = String(
|
|
85
|
+
wsVcs.provider ?? (determinateRoot ? remoteProvider(root) : null) ?? cfg.provider ?? "gitlab",
|
|
86
|
+
).toLowerCase();
|
|
87
|
+
// CA-05: workspace/global explicit target wins; when unset the resolved
|
|
88
|
+
// branch-policy preset supplies the default (gitflow->develop,
|
|
89
|
+
// github-flow->main, trunk-based->master, custom->develop). Shared by load
|
|
90
|
+
// and resolve so both surfaces stay consistent.
|
|
91
|
+
// CA-09: the one resolver wrapper — same policy resolution every consumer
|
|
92
|
+
// uses, so the tightened gate in branch-policy-resolver.test.ts stays green.
|
|
93
|
+
const defaultTarget = String(
|
|
94
|
+
wsVcs.defaultTargetBranch ??
|
|
95
|
+
cfg.defaultTargetBranch ??
|
|
96
|
+
resolveBranchPolicyFor(root).defaultTargetBranch ??
|
|
97
|
+
"develop",
|
|
98
|
+
);
|
|
45
99
|
const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
|
|
46
100
|
const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
|
|
47
101
|
// github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
|
|
48
102
|
let issuesProvider: string | null = null;
|
|
49
103
|
let linkOnPr: boolean | null = null;
|
|
50
|
-
if (
|
|
104
|
+
if (
|
|
105
|
+
provider === "github" &&
|
|
106
|
+
typeof wsIssues.provider === "string" &&
|
|
107
|
+
wsIssues.provider.toLowerCase() === "github"
|
|
108
|
+
) {
|
|
51
109
|
issuesProvider = "github";
|
|
52
110
|
linkOnPr = typeof wsIssues.link_on_pr === "boolean" ? wsIssues.link_on_pr : null;
|
|
53
111
|
}
|
|
54
112
|
|
|
55
113
|
if (mode === "resolve") {
|
|
114
|
+
// RL-01: malformed vcs.json blocks resolution with an exact-path diagnostic
|
|
115
|
+
// instead of silently resolving defaults; a missing file is still a
|
|
116
|
+
// legitimate unconfigured state that falls back to defaults.
|
|
117
|
+
if (cfgStatus === "malformed") return { ok: false, error: cfgError, configPath: cfgPath };
|
|
56
118
|
return {
|
|
57
119
|
ok: true,
|
|
58
120
|
workspace_name: ws?.name ?? null,
|
|
@@ -65,7 +127,12 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
65
127
|
};
|
|
66
128
|
}
|
|
67
129
|
|
|
68
|
-
if (
|
|
130
|
+
if (cfgStatus === "malformed") {
|
|
131
|
+
return { ok: false, error: cfgError, configPath: cfgPath };
|
|
132
|
+
}
|
|
133
|
+
if (cfgStatus === "missing") {
|
|
134
|
+
return { ok: false, error: `vcs.json is missing: ${cfgPath}`, configPath: cfgPath };
|
|
135
|
+
}
|
|
69
136
|
|
|
70
137
|
const prov = (cfg[provider] ?? {}) as Record<string, any>;
|
|
71
138
|
const tokenFile = String(prov.tokenFile ?? path.join(configDir(), `${provider}.token`));
|
|
@@ -73,7 +140,8 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
73
140
|
let tokenOk = false;
|
|
74
141
|
if (fs.existsSync(tokenPath)) {
|
|
75
142
|
const token = fs.readFileSync(tokenPath, "utf8").trim();
|
|
76
|
-
const placeholder =
|
|
143
|
+
const placeholder =
|
|
144
|
+
!token || token === TOKEN_PLACEHOLDER || token.startsWith(TOKEN_PLACEHOLDER);
|
|
77
145
|
tokenOk = !placeholder;
|
|
78
146
|
}
|
|
79
147
|
|
|
@@ -93,7 +161,10 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
93
161
|
link_on_pr: linkOnPr,
|
|
94
162
|
};
|
|
95
163
|
if (provider === "gitlab") {
|
|
96
|
-
out.gitlab = {
|
|
164
|
+
out.gitlab = {
|
|
165
|
+
host: prov.host ?? "gitlab.com",
|
|
166
|
+
apiUrl: prov.apiUrl ?? "https://gitlab.com/api/v4",
|
|
167
|
+
};
|
|
97
168
|
} else if (provider === "github") {
|
|
98
169
|
out.github = { host: prov.host ?? "github.com" };
|
|
99
170
|
}
|
|
@@ -102,7 +173,7 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
102
173
|
}
|
|
103
174
|
|
|
104
175
|
/** Port of scripts/vcs/verify-token.sh — soft-fail JSON out, always exit 0. */
|
|
105
|
-
export function vcsVerifyToken(): Record<string, any
|
|
176
|
+
export async function vcsVerifyToken(): Promise<Record<string, any>> {
|
|
106
177
|
const cfg = vcsConfig("load");
|
|
107
178
|
if (!cfg.ok) return { ok: false, error: cfg.error ?? "vcs config not ready" };
|
|
108
179
|
const provider = cfg.provider as string;
|
|
@@ -118,22 +189,49 @@ export function vcsVerifyToken(): Record<string, any> {
|
|
|
118
189
|
|
|
119
190
|
if (provider === "gitlab") {
|
|
120
191
|
const host = (cfg.gitlab as Record<string, any>)?.host ?? "gitlab.com";
|
|
121
|
-
const api = ((cfg.gitlab as Record<string, any>)?.apiUrl ?? `https://${host}/api/v4`).replace(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
192
|
+
const api = ((cfg.gitlab as Record<string, any>)?.apiUrl ?? `https://${host}/api/v4`).replace(
|
|
193
|
+
/\/+$/,
|
|
194
|
+
"",
|
|
195
|
+
);
|
|
196
|
+
let user: Record<string, any>;
|
|
126
197
|
try {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
198
|
+
const res = await fetch(`${api}/user`, {
|
|
199
|
+
headers: { "PRIVATE-TOKEN": token },
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
provider,
|
|
205
|
+
error: "GitLab API rejected token",
|
|
206
|
+
detail: (await res.text()).slice(0, 200),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
user = JSON.parse(await res.text()) as Record<string, any>;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (err instanceof SyntaxError) {
|
|
212
|
+
return { ok: false, provider, error: "invalid JSON from GitLab /user" };
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
provider,
|
|
217
|
+
error: "GitLab API rejected token",
|
|
218
|
+
detail: (err instanceof Error ? err.message : "network error").slice(0, 200),
|
|
219
|
+
};
|
|
131
220
|
}
|
|
221
|
+
return { ok: true, provider, username: user.username ?? user.login, name: user.name };
|
|
132
222
|
}
|
|
133
223
|
if (provider === "github") {
|
|
134
|
-
const result = spawnSync("gh", ["api", "user"], {
|
|
224
|
+
const result = spawnSync("gh", ["api", "user"], {
|
|
225
|
+
encoding: "utf8",
|
|
226
|
+
env: { ...process.env, GH_TOKEN: token },
|
|
227
|
+
});
|
|
135
228
|
if (result.status !== 0) {
|
|
136
|
-
return {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
provider,
|
|
232
|
+
error: "GitHub API rejected token",
|
|
233
|
+
detail: (result.stderr ?? result.stdout ?? "").slice(0, 200),
|
|
234
|
+
};
|
|
137
235
|
}
|
|
138
236
|
try {
|
|
139
237
|
const user = JSON.parse(result.stdout ?? "") as Record<string, any>;
|
|
@@ -156,32 +254,44 @@ export function vcsTokenCreateUrls(): Record<string, any> {
|
|
|
156
254
|
const gitlab = (cfg.gitlab ?? {}) as Record<string, any>;
|
|
157
255
|
const host = String(gitlab.host ?? "gitlab.com");
|
|
158
256
|
const gitlabScopes = Array.isArray(defaults.gitlabScopes) ? defaults.gitlabScopes : ["api"];
|
|
159
|
-
const gitlabParams = new URLSearchParams({
|
|
257
|
+
const gitlabParams = new URLSearchParams({
|
|
258
|
+
name,
|
|
259
|
+
description: desc,
|
|
260
|
+
scopes: gitlabScopes.join(","),
|
|
261
|
+
});
|
|
160
262
|
const gitlabUrl = `https://${host}/-/user_settings/personal_access_tokens?${gitlabParams}`;
|
|
161
263
|
|
|
162
|
-
const githubPerms = defaults.githubPermissions ?? {
|
|
264
|
+
const githubPerms = defaults.githubPermissions ?? {
|
|
265
|
+
pull_requests: "write",
|
|
266
|
+
contents: "write",
|
|
267
|
+
metadata: "read",
|
|
268
|
+
};
|
|
163
269
|
const ghParams = new URLSearchParams({ name, description: desc, ...githubPerms });
|
|
164
270
|
const githubFineUrl = `https://github.com/settings/personal-access-tokens/new?${ghParams}`;
|
|
165
271
|
|
|
166
|
-
const classicScopes = Array.isArray(defaults.githubClassicScopes)
|
|
272
|
+
const classicScopes = Array.isArray(defaults.githubClassicScopes)
|
|
273
|
+
? defaults.githubClassicScopes
|
|
274
|
+
: ["repo"];
|
|
167
275
|
const githubClassicUrl = `https://github.com/settings/tokens/new?${new URLSearchParams({ description: name, scopes: classicScopes.join(",") })}`;
|
|
168
276
|
|
|
169
277
|
const provider = String(cfg.provider ?? "gitlab").toLowerCase();
|
|
170
|
-
const active =
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
278
|
+
const active =
|
|
279
|
+
{
|
|
280
|
+
gitlab: {
|
|
281
|
+
tokenFile: gitlab.tokenFile ?? path.join(configDir(), "gitlab.token"),
|
|
282
|
+
createUrl: gitlabUrl,
|
|
283
|
+
scopes: gitlabScopes,
|
|
284
|
+
name,
|
|
285
|
+
},
|
|
286
|
+
github: {
|
|
287
|
+
tokenFile:
|
|
288
|
+
(cfg.github as Record<string, any>)?.tokenFile ?? path.join(configDir(), "github.token"),
|
|
289
|
+
createUrl: githubFineUrl,
|
|
290
|
+
createUrlClassic: githubClassicUrl,
|
|
291
|
+
permissions: githubPerms,
|
|
292
|
+
name,
|
|
293
|
+
},
|
|
294
|
+
}[provider] ?? {};
|
|
185
295
|
|
|
186
296
|
return {
|
|
187
297
|
tokenName: name,
|
|
@@ -189,13 +299,18 @@ export function vcsTokenCreateUrls(): Record<string, any> {
|
|
|
189
299
|
activeProvider: provider,
|
|
190
300
|
active,
|
|
191
301
|
gitlab: { host, createUrl: gitlabUrl, scopes: gitlabScopes, tokenFile: gitlab.tokenFile },
|
|
192
|
-
github: {
|
|
302
|
+
github: {
|
|
303
|
+
createUrl: githubFineUrl,
|
|
304
|
+
createUrlClassic: githubClassicUrl,
|
|
305
|
+
permissions: githubPerms,
|
|
306
|
+
tokenFile: (cfg.github as Record<string, any>)?.tokenFile,
|
|
307
|
+
},
|
|
193
308
|
};
|
|
194
309
|
}
|
|
195
310
|
|
|
196
311
|
/** Port of scripts/vcs/merged-style.sh — recent merged MR/PR bodies for style reference. */
|
|
197
|
-
export function mergedPrStyle(limit = 6): Record<string, any> {
|
|
198
|
-
const cfg = vcsConfig("load");
|
|
312
|
+
export function mergedPrStyle(limit = 6, cwd?: string): Record<string, any> {
|
|
313
|
+
const cfg = vcsConfig("load", cwd);
|
|
199
314
|
if (!cfg.ok || !cfg.tokenReady) return { ok: false, error: "vcs not configured" };
|
|
200
315
|
const provider = cfg.provider as string;
|
|
201
316
|
const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
|
|
@@ -203,31 +318,45 @@ export function mergedPrStyle(limit = 6): Record<string, any> {
|
|
|
203
318
|
|
|
204
319
|
const descInfo = (desc: string, caseInsensitiveNotes = false): Record<string, any> => ({
|
|
205
320
|
hasNotesSection: caseInsensitiveNotes ? /##\s*notes/i.test(desc) : /##\s*Notes/.test(desc),
|
|
206
|
-
sections: desc
|
|
321
|
+
sections: desc
|
|
322
|
+
.split("\n")
|
|
323
|
+
.filter((l) => l.startsWith("## "))
|
|
324
|
+
.map((l) => l.trim()),
|
|
207
325
|
descriptionPreview: desc.slice(0, 600),
|
|
208
326
|
});
|
|
209
327
|
|
|
210
328
|
if (provider === "gitlab") {
|
|
211
|
-
const remote = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" });
|
|
329
|
+
const remote = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
|
|
212
330
|
if (remote.status !== 0) return { ok: false, error: "no origin remote" };
|
|
213
331
|
const url = (remote.stdout ?? "").trim();
|
|
214
332
|
const m = /gitlab\.com[:/](.+?)(?:\.git)?$/.exec(url);
|
|
215
333
|
if (!m) return { ok: false, error: "not a gitlab.com origin" };
|
|
216
334
|
const project = m[1];
|
|
217
335
|
const env = { ...process.env, GITLAB_TOKEN: token };
|
|
218
|
-
const run = (args: string[]) =>
|
|
219
|
-
|
|
336
|
+
const run = (args: string[]) =>
|
|
337
|
+
spawnSync("glab", ["api", ...args], { cwd, encoding: "utf8", env });
|
|
338
|
+
let r = run([
|
|
339
|
+
`projects/${project.replaceAll("/", "%2F")}/merge_requests?state=merged&per_page=${limit}&order_by=updated_at&sort=desc`,
|
|
340
|
+
]);
|
|
220
341
|
if (r.status !== 0) {
|
|
221
342
|
r = run([`merge_requests?state=merged&per_page=${limit}&order_by=updated_at&sort=desc`]);
|
|
222
343
|
}
|
|
223
344
|
if (r.status !== 0) return { ok: false, error: "could not list merge requests" };
|
|
224
345
|
for (const mr of JSON.parse(r.stdout ?? "[]") as Array<Record<string, any>>) {
|
|
225
346
|
const desc = String(mr.description ?? "").trim();
|
|
226
|
-
examples.push({
|
|
347
|
+
examples.push({
|
|
348
|
+
title: mr.title,
|
|
349
|
+
url: mr.web_url,
|
|
350
|
+
squash: mr.squash,
|
|
351
|
+
...descInfo(desc, true),
|
|
352
|
+
});
|
|
227
353
|
}
|
|
228
354
|
} else if (provider === "github") {
|
|
229
|
-
const r = spawnSync(
|
|
230
|
-
|
|
355
|
+
const r = spawnSync(
|
|
356
|
+
"gh",
|
|
357
|
+
["pr", "list", "--state", "merged", "--limit", String(limit), "--json", "title,url,body"],
|
|
358
|
+
{ cwd, encoding: "utf8", env: { ...process.env, GH_TOKEN: token } },
|
|
359
|
+
);
|
|
231
360
|
if (r.status !== 0) return { ok: false, error: "could not list pull requests" };
|
|
232
361
|
for (const pr of JSON.parse(r.stdout ?? "[]") as Array<Record<string, any>>) {
|
|
233
362
|
const desc = String(pr.body ?? "").trim();
|
package/src/core/verify-parse.ts
CHANGED
|
@@ -3,7 +3,7 @@ export function parseVerifyOutput(stdout: string): Record<string, any> {
|
|
|
3
3
|
const commands = [];
|
|
4
4
|
const parts = stdout.split(/\n## /);
|
|
5
5
|
for (const part of parts.slice(1)) {
|
|
6
|
-
const nl = part.indexOf(
|
|
6
|
+
const nl = part.indexOf("\n");
|
|
7
7
|
const label = part.slice(0, nl).trim();
|
|
8
8
|
const body = part.slice(nl + 1);
|
|
9
9
|
const cmdMatch = body.match(/^command: (.+)$/m);
|
|
@@ -18,7 +18,9 @@ export function parseVerifyOutput(stdout: string): Record<string, any> {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const summaryMatch = stdout.match(
|
|
21
|
+
const summaryMatch = stdout.match(
|
|
22
|
+
/# Summary[\s\S]*?passed: (\d+)[\s\S]*?failed: (\d+)[\s\S]*?skipped: (\d+)/,
|
|
23
|
+
);
|
|
22
24
|
const passed = summaryMatch ? Number(summaryMatch[1]) : 0;
|
|
23
25
|
const failed = summaryMatch ? Number(summaryMatch[2]) : 0;
|
|
24
26
|
const skipped = summaryMatch ? Number(summaryMatch[3]) : 0;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { repoRoot } from "./repo-context";
|
|
5
|
+
|
|
6
|
+
// Port of scripts/verify-project.sh — discover + run project verification checks
|
|
7
|
+
// (npm/pnpm/yarn, cargo, pytest/ruff, CHANGELOG format). Output is parsed by
|
|
8
|
+
// verify-parse.ts and keeps the same ## section text shape.
|
|
9
|
+
|
|
10
|
+
export type VerifyResult = { stdout: string; stderr: string; exitCode: number; cwd: string };
|
|
11
|
+
|
|
12
|
+
const commandOnPath = (name: string): boolean => {
|
|
13
|
+
const dirs = (process.env.PATH ?? "").split(path.delimiter);
|
|
14
|
+
// win32 executables carry an .exe suffix (pytest.exe), so probe both names.
|
|
15
|
+
const names = process.platform === "win32" ? [name, `${name}.exe`] : [name];
|
|
16
|
+
for (const dir of dirs) {
|
|
17
|
+
if (!dir) continue;
|
|
18
|
+
for (const candidateName of names) {
|
|
19
|
+
const candidate = path.join(dir, candidateName);
|
|
20
|
+
try {
|
|
21
|
+
statSync(candidate);
|
|
22
|
+
if (process.platform !== "win32") {
|
|
23
|
+
const mode = statSync(candidate).mode & 0o111;
|
|
24
|
+
if (mode === 0) continue;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
/* keep scanning */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const hasScript = (cwd: string, name: string): boolean => {
|
|
36
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
37
|
+
if (!existsSync(pkgPath)) return false;
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> };
|
|
40
|
+
return Boolean(pkg.scripts && typeof pkg.scripts[name] === "string");
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const packageRunner = (cwd: string): string => {
|
|
47
|
+
if (existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
48
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
49
|
+
if (existsSync(pkgPath)) {
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { packageManager?: string };
|
|
52
|
+
if (typeof pkg.packageManager === "string" && pkg.packageManager.startsWith("pnpm")) {
|
|
53
|
+
return "pnpm";
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
/* fall through */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
|
|
60
|
+
return "npm";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function runVerifyProject(root: string, dryRun = false): VerifyResult {
|
|
64
|
+
// Shell parity: verify-project.sh did `root=$(git rev-parse --show-toplevel || pwd); cd "$root"`,
|
|
65
|
+
// so a run from a repo subdirectory still checks the repo root.
|
|
66
|
+
const cwd = repoRoot(root);
|
|
67
|
+
let passed = 0;
|
|
68
|
+
let failed = 0;
|
|
69
|
+
let skipped = 0;
|
|
70
|
+
const lines: string[] = [];
|
|
71
|
+
const stderrParts: string[] = [];
|
|
72
|
+
|
|
73
|
+
const runCheck = (label: string, cmd: string, args: string[]) => {
|
|
74
|
+
lines.push(`\n## ${label}\n\n`);
|
|
75
|
+
lines.push(`command: ${[cmd, ...args].join(" ")}\n\n`);
|
|
76
|
+
if (dryRun) {
|
|
77
|
+
lines.push("status: skipped (dry run)\n");
|
|
78
|
+
skipped += 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const r = spawnSync(cmd, args, { cwd, encoding: "utf8" });
|
|
82
|
+
const so = (r.stdout ?? "").trimEnd();
|
|
83
|
+
if (so) lines.push(so + "\n");
|
|
84
|
+
const se = (r.stderr ?? "").trimEnd();
|
|
85
|
+
if (se) stderrParts.push(se);
|
|
86
|
+
if (r.status === 0) {
|
|
87
|
+
lines.push("\nstatus: pass\n");
|
|
88
|
+
passed += 1;
|
|
89
|
+
} else {
|
|
90
|
+
lines.push(`\nstatus: fail (exit ${r.status})\n`);
|
|
91
|
+
failed += 1;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const skipCheck = (label: string, reason: string) => {
|
|
96
|
+
lines.push(`\n## ${label}\n\nstatus: skipped (${reason})\n`);
|
|
97
|
+
skipped += 1;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
lines.push("# Verification Context\n");
|
|
101
|
+
lines.push(`\nroot: ${cwd}\n`);
|
|
102
|
+
lines.push(`dry_run: ${String(dryRun)}\n`);
|
|
103
|
+
|
|
104
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
105
|
+
if (existsSync(pkgPath)) {
|
|
106
|
+
const runner = packageRunner(cwd);
|
|
107
|
+
for (const script of ["lint", "format:check", "test", "build"]) {
|
|
108
|
+
if (hasScript(cwd, script)) {
|
|
109
|
+
if (runner === "npm") runCheck(script, "npm", ["run", script]);
|
|
110
|
+
else runCheck(script, runner, [script]);
|
|
111
|
+
} else {
|
|
112
|
+
skipCheck(script, `package.json has no ${script} script`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
skipCheck("javascript", "package.json not found");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const cargoManifest = existsSync(path.join(cwd, "Cargo.toml"))
|
|
120
|
+
? "Cargo.toml"
|
|
121
|
+
: existsSync(path.join(cwd, "src-tauri/Cargo.toml"))
|
|
122
|
+
? "src-tauri/Cargo.toml"
|
|
123
|
+
: "";
|
|
124
|
+
if (cargoManifest) {
|
|
125
|
+
runCheck("cargo fmt", "cargo", ["fmt", "--manifest-path", cargoManifest, "--", "--check"]);
|
|
126
|
+
runCheck("cargo clippy", "cargo", [
|
|
127
|
+
"clippy",
|
|
128
|
+
"--manifest-path",
|
|
129
|
+
cargoManifest,
|
|
130
|
+
"--all-targets",
|
|
131
|
+
"--",
|
|
132
|
+
"-D",
|
|
133
|
+
"warnings",
|
|
134
|
+
]);
|
|
135
|
+
runCheck("cargo test", "cargo", ["test", "--manifest-path", cargoManifest, "--all-targets"]);
|
|
136
|
+
} else {
|
|
137
|
+
skipCheck("rust", "Cargo.toml not found");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
existsSync(path.join(cwd, "pyproject.toml")) ||
|
|
142
|
+
existsSync(path.join(cwd, "pytest.ini")) ||
|
|
143
|
+
existsSync(path.join(cwd, "tests"))
|
|
144
|
+
) {
|
|
145
|
+
if (commandOnPath("pytest")) runCheck("pytest", "pytest", []);
|
|
146
|
+
else skipCheck("pytest", "pytest not available");
|
|
147
|
+
if (commandOnPath("ruff")) runCheck("ruff check", "ruff", ["check", "."]);
|
|
148
|
+
else skipCheck("ruff check", "ruff not available");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
lines.push("\n## CHANGELOG.md format\n\n");
|
|
152
|
+
lines.push('command: grep -qi "## [Unreleased]" CHANGELOG.md\n\n');
|
|
153
|
+
const changelogPath = path.join(cwd, "CHANGELOG.md");
|
|
154
|
+
if (dryRun) {
|
|
155
|
+
lines.push("status: skipped (dry run)\n");
|
|
156
|
+
skipped += 1;
|
|
157
|
+
} else if (existsSync(changelogPath)) {
|
|
158
|
+
if (/## \[Unreleased\]/i.test(readFileSync(changelogPath, "utf8"))) {
|
|
159
|
+
lines.push("status: pass\n");
|
|
160
|
+
passed += 1;
|
|
161
|
+
} else {
|
|
162
|
+
lines.push("status: fail (missing ## [Unreleased])\n");
|
|
163
|
+
failed += 1;
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
lines.push("status: fail (missing CHANGELOG.md)\n");
|
|
167
|
+
failed += 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
lines.push("\n# Summary\n\n");
|
|
171
|
+
lines.push(`passed: ${passed}\n`);
|
|
172
|
+
lines.push(`failed: ${failed}\n`);
|
|
173
|
+
lines.push(`skipped: ${skipped}\n`);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
stdout: lines.join(""),
|
|
177
|
+
stderr: stderrParts.join("\n"),
|
|
178
|
+
exitCode: failed > 0 ? 1 : 0,
|
|
179
|
+
cwd,
|
|
180
|
+
};
|
|
181
|
+
}
|