@brainervirus/workit-core 0.6.1 → 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 +3 -7
- package/scripts/doctor-check.ts +20 -0
- package/scripts/install-cursor-plugin.sh +51 -28
- package/scripts/install-opencode-plugin.sh +19 -21
- 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 +30 -16
- package/src/core/config.ts +193 -31
- package/src/core/docs-layout.ts +251 -0
- package/src/core/docs-migration.ts +639 -0
- package/src/core/docs-repo.ts +11 -9
- package/src/core/docs-validate.ts +18 -6
- package/src/core/doctor.ts +801 -0
- package/src/core/flow-state.ts +1579 -141
- package/src/core/git.ts +22 -5
- package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
- package/src/core/hygiene.ts +26 -12
- package/src/core/init.ts +43 -11
- package/src/core/logger.ts +321 -0
- package/src/core/package-root.ts +28 -0
- 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 +1 -1
- package/src/core/ports/youtrack-verify-token.ts +1 -1
- package/src/core/pr-create.ts +116 -21
- package/src/core/registration.ts +215 -0
- package/src/core/repo-context.ts +447 -0
- package/src/core/repo-tools.ts +23 -0
- package/src/core/safe-write.ts +22 -0
- package/src/core/scripts.ts +3 -44
- package/src/core/sdd.ts +45 -28
- 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 +2 -2
- package/src/core/vcs-config.ts +107 -37
- 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 +125 -67
- package/templates/execution-contract.md +9 -7
- package/templates/superpowers-doc-contract.md +4 -3
- package/scripts/_shared/common.sh +0 -173
- 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 -51
- package/src/tools/flow.ts +0 -99
- package/src/tools/index.ts +0 -22
- package/src/tools/present.ts +0 -49
- package/src/tools/repo.ts +0 -490
- package/src/tools/rules.ts +0 -30
- package/src/tools/sdd.ts +0 -216
- package/src/tools/templates.ts +0 -27
- package/src/tools/youtrack.ts +0 -423
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// Secret-safe structured logger (DG-01-DG-03, DG-05, DG-10). Host-neutral:
|
|
6
|
+
// node: builtins only. Never logs prompts, messages, content, raw tool
|
|
7
|
+
// arguments/results, credentials, tokens, authorization headers, issue data,
|
|
8
|
+
// URL queries, home prefixes, or unbounded stacks. Every record is bounded,
|
|
9
|
+
// redacted, rate-limited JSONL under a daily filename.
|
|
10
|
+
|
|
11
|
+
export type JsonValue =
|
|
12
|
+
| null
|
|
13
|
+
| boolean
|
|
14
|
+
| number
|
|
15
|
+
| string
|
|
16
|
+
| JsonValue[]
|
|
17
|
+
| { [key: string]: JsonValue };
|
|
18
|
+
|
|
19
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
20
|
+
|
|
21
|
+
export type LogEvent = {
|
|
22
|
+
level: LogLevel;
|
|
23
|
+
time: string;
|
|
24
|
+
message: string;
|
|
25
|
+
context: Record<string, JsonValue>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type LogSink = (event: LogEvent) => void;
|
|
29
|
+
|
|
30
|
+
export type LoggerOptions = {
|
|
31
|
+
stateDir?: string;
|
|
32
|
+
now?: () => Date;
|
|
33
|
+
appLog?: LogSink;
|
|
34
|
+
stderr?: LogSink;
|
|
35
|
+
maxRate?: number;
|
|
36
|
+
rateWindowMs?: number;
|
|
37
|
+
maxFieldLength?: number;
|
|
38
|
+
maxStackLines?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type Logger = {
|
|
42
|
+
debug: (message: string, context?: Record<string, unknown>) => void;
|
|
43
|
+
info: (message: string, context?: Record<string, unknown>) => void;
|
|
44
|
+
warn: (message: string, context?: Record<string, unknown>) => void;
|
|
45
|
+
error: (message: string, context?: Record<string, unknown>) => void;
|
|
46
|
+
guard: <T>(name: string, fn: () => T) => T | undefined;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const REDACTED = "[REDACTED]";
|
|
50
|
+
|
|
51
|
+
const DEFAULT_MAX_RATE = 20;
|
|
52
|
+
const DEFAULT_RATE_WINDOW_MS = 1000;
|
|
53
|
+
const DEFAULT_MAX_FIELD_LENGTH = 200;
|
|
54
|
+
const DEFAULT_MAX_STACK_LINES = 30;
|
|
55
|
+
|
|
56
|
+
const DAY_FILE = /^workit-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
57
|
+
const RETAINED_DAYS = 7;
|
|
58
|
+
|
|
59
|
+
// Home prefixes, URL queries, and inline secret values are redacted inside any
|
|
60
|
+
// string. Key names drive the rest: a field whose name contains a secret or
|
|
61
|
+
// content word is fully replaced, a stack/trace field is line-bounded.
|
|
62
|
+
const SECRET_VALUE = /\b(?:Bearer|Basic|Digest|Token)\s+\S+/gi;
|
|
63
|
+
// Case-insensitive: `Authorization: abc`, `Api-Token=xyz`, `Bearer`-style
|
|
64
|
+
// headers are redacted regardless of casing (D2). The alternation is the same
|
|
65
|
+
// secret vocabulary as SENSITIVE_WORDS, so the over-redaction risk is limited
|
|
66
|
+
// to values next to a secret-ish key — acceptable for a security logger.
|
|
67
|
+
const KEY_EQ_VALUE =
|
|
68
|
+
/\b([A-Za-z0-9_-]*(?:token|secret|password|passwd|apikey|api[_-]?key|authorization|credential|bearer)[A-Za-z0-9_-]*)([:=]\s*).+/gi;
|
|
69
|
+
const URL_QUERY = /(https?:\/\/[^?#\s]+)\?[^#\s]*/g;
|
|
70
|
+
|
|
71
|
+
// Split camelCase AND acronym+word compounds (APIToken -> api|token, so the
|
|
72
|
+
// "token" word lands in SENSITIVE_WORDS instead of the whole lowercase
|
|
73
|
+
// compound "apitoken" leaking past it) (D1).
|
|
74
|
+
const splitKey = (key: string): string[] =>
|
|
75
|
+
key
|
|
76
|
+
.split(/[_-]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/)
|
|
77
|
+
.map((word) => word.toLowerCase());
|
|
78
|
+
|
|
79
|
+
const SENSITIVE_WORDS = new Set([
|
|
80
|
+
"token",
|
|
81
|
+
"secret",
|
|
82
|
+
"password",
|
|
83
|
+
"passwd",
|
|
84
|
+
"authorization",
|
|
85
|
+
"credential",
|
|
86
|
+
"credentials",
|
|
87
|
+
"cookie",
|
|
88
|
+
"api",
|
|
89
|
+
"apikey",
|
|
90
|
+
"apitoken",
|
|
91
|
+
"accesstoken",
|
|
92
|
+
"clientsecret",
|
|
93
|
+
"refreshtoken",
|
|
94
|
+
"key",
|
|
95
|
+
"bearer",
|
|
96
|
+
"prompt",
|
|
97
|
+
"message",
|
|
98
|
+
"messages",
|
|
99
|
+
"content",
|
|
100
|
+
"body",
|
|
101
|
+
"args",
|
|
102
|
+
"argument",
|
|
103
|
+
"result",
|
|
104
|
+
"results",
|
|
105
|
+
"output",
|
|
106
|
+
"issue",
|
|
107
|
+
"description",
|
|
108
|
+
"summary",
|
|
109
|
+
"payload",
|
|
110
|
+
"script",
|
|
111
|
+
"command",
|
|
112
|
+
"text",
|
|
113
|
+
"query",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const STACK_WORDS = new Set(["stack", "stacktrace", "trace"]);
|
|
117
|
+
|
|
118
|
+
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
|
|
120
|
+
export const resolveStateDir = (): string => {
|
|
121
|
+
const override = process.env.WORKFLOW_TOOLKIT_STATE;
|
|
122
|
+
if (override) return override;
|
|
123
|
+
const home = os.homedir();
|
|
124
|
+
if (process.env.XDG_STATE_HOME) return path.join(process.env.XDG_STATE_HOME, "workit");
|
|
125
|
+
switch (os.platform()) {
|
|
126
|
+
case "darwin":
|
|
127
|
+
return path.join(home, "Library", "Application Support", "workit");
|
|
128
|
+
case "win32":
|
|
129
|
+
return path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "workit");
|
|
130
|
+
default:
|
|
131
|
+
return path.join(process.env.HOME ?? home, ".local", "state", "workit");
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
type RedactOptions = {
|
|
136
|
+
maxFieldLength: number;
|
|
137
|
+
maxStackLines: number;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// The separator lookahead accepts both / and \ so home-relative Windows paths
|
|
141
|
+
// (C:\Users\x\repo) redact too — os.homedir() uses backslashes there.
|
|
142
|
+
const homePattern = new RegExp(
|
|
143
|
+
`${escapeRegExp(os.homedir())}(?=[/\\\\]|$)|\\$HOME(?=[/\\\\]|$)`,
|
|
144
|
+
"g",
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const applyPatterns = (value: string): string => {
|
|
148
|
+
let out = value;
|
|
149
|
+
out = out.replace(SECRET_VALUE, REDACTED);
|
|
150
|
+
out = out.replace(KEY_EQ_VALUE, "$1$2[REDACTED]");
|
|
151
|
+
out = out.replace(URL_QUERY, "$1?[REDACTED]");
|
|
152
|
+
out = out.replace(homePattern, "~");
|
|
153
|
+
// win32 paths keep backslashes after the ~ substitution (~\repo); normalize
|
|
154
|
+
// separators so logged paths are portable and match the shell-shaped ~/
|
|
155
|
+
// form. Other platforms are untouched.
|
|
156
|
+
if (process.platform === "win32") out = out.replace(/\\/g, "/");
|
|
157
|
+
return out;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pattern-only redaction: strips secret value patterns from a string while
|
|
162
|
+
* preserving its full text — no field-length truncation. The receiver owns its
|
|
163
|
+
* own size bound (e.g. an MCP error string must stay intact for the client).
|
|
164
|
+
*/
|
|
165
|
+
export const redactSecrets = (value: string): string => applyPatterns(value);
|
|
166
|
+
|
|
167
|
+
const redactString = (value: string, options: RedactOptions): string => {
|
|
168
|
+
const out = applyPatterns(value);
|
|
169
|
+
if (out.length > options.maxFieldLength) {
|
|
170
|
+
return `${out.slice(0, options.maxFieldLength)}…`;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const boundStack = (value: unknown, options: RedactOptions): JsonValue => {
|
|
176
|
+
if (typeof value !== "string") return redact(value, options);
|
|
177
|
+
const lines = value.split("\n");
|
|
178
|
+
const kept = lines.slice(0, options.maxStackLines).map(applyPatterns);
|
|
179
|
+
const dropped = lines.length - kept.length;
|
|
180
|
+
const body = dropped > 0 ? [...kept, ` ... ${dropped} more`] : kept;
|
|
181
|
+
return body.join("\n");
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const redact = (value: unknown, options: RedactOptions = DEFAULT_OPTIONS): JsonValue => {
|
|
185
|
+
if (value === null || value === undefined) return null;
|
|
186
|
+
if (typeof value === "boolean" || typeof value === "number") return value;
|
|
187
|
+
if (typeof value === "string") return redactString(value, options);
|
|
188
|
+
if (value instanceof Date) return value.toISOString();
|
|
189
|
+
if (Array.isArray(value)) return value.map((entry) => redact(entry, options));
|
|
190
|
+
if (typeof value === "object") {
|
|
191
|
+
const out: Record<string, JsonValue> = {};
|
|
192
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
193
|
+
const words = splitKey(key);
|
|
194
|
+
if (words.some((word) => STACK_WORDS.has(word))) out[key] = boundStack(entry, options);
|
|
195
|
+
else if (words.some((word) => SENSITIVE_WORDS.has(word))) out[key] = REDACTED;
|
|
196
|
+
else out[key] = redact(entry, options);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const DEFAULT_OPTIONS: RedactOptions = {
|
|
204
|
+
maxFieldLength: DEFAULT_MAX_FIELD_LENGTH,
|
|
205
|
+
maxStackLines: DEFAULT_MAX_STACK_LINES,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const dailyFileName = (date: Date): string => {
|
|
209
|
+
const y = date.getFullYear();
|
|
210
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
211
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
212
|
+
return `workit-${y}-${m}-${d}.jsonl`;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const pruneOldFiles = (dir: string): void => {
|
|
216
|
+
try {
|
|
217
|
+
const files = readdirSync(dir)
|
|
218
|
+
.filter((name) => DAY_FILE.test(name))
|
|
219
|
+
.sort()
|
|
220
|
+
.reverse();
|
|
221
|
+
for (const file of files.slice(RETAINED_DAYS)) {
|
|
222
|
+
try {
|
|
223
|
+
unlinkSync(path.join(dir, file));
|
|
224
|
+
} catch {
|
|
225
|
+
// a concurrent prune may already have removed it
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// logging must never break the host
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const createLogger = (options: LoggerOptions = {}): Logger => {
|
|
234
|
+
const stateDir = options.stateDir ?? resolveStateDir();
|
|
235
|
+
const logDir = path.join(stateDir, "logs");
|
|
236
|
+
const now = options.now ?? (() => new Date());
|
|
237
|
+
const maxRate = options.maxRate ?? DEFAULT_MAX_RATE;
|
|
238
|
+
const rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
|
|
239
|
+
const redactOptions: RedactOptions = {
|
|
240
|
+
maxFieldLength: options.maxFieldLength ?? DEFAULT_MAX_FIELD_LENGTH,
|
|
241
|
+
maxStackLines: options.maxStackLines ?? DEFAULT_MAX_STACK_LINES,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
let windowStart = 0;
|
|
245
|
+
const windowCounts: Record<LogLevel, number> = { debug: 0, info: 0, warn: 0, error: 0 };
|
|
246
|
+
|
|
247
|
+
const emit = (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
|
|
248
|
+
const date = now();
|
|
249
|
+
const elapsed = date.getTime() - windowStart;
|
|
250
|
+
if (elapsed >= rateWindowMs || windowStart === 0) {
|
|
251
|
+
windowStart = date.getTime();
|
|
252
|
+
windowCounts.debug = 0;
|
|
253
|
+
windowCounts.info = 0;
|
|
254
|
+
windowCounts.warn = 0;
|
|
255
|
+
windowCounts.error = 0;
|
|
256
|
+
}
|
|
257
|
+
windowCounts[level] += 1;
|
|
258
|
+
if (windowCounts[level] > maxRate) return;
|
|
259
|
+
|
|
260
|
+
// ponytail: per-level budget so an info flood can't starve warn/error
|
|
261
|
+
// canaries; shared-token budget would need cross-level prioritization.
|
|
262
|
+
let event: LogEvent | null = null;
|
|
263
|
+
try {
|
|
264
|
+
event = {
|
|
265
|
+
level,
|
|
266
|
+
time: date.toISOString(),
|
|
267
|
+
message: redactString(message, redactOptions),
|
|
268
|
+
context: redact(context ?? {}, redactOptions) as Record<string, JsonValue>,
|
|
269
|
+
};
|
|
270
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
271
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
272
|
+
appendFileSync(path.join(logDir, dailyFileName(date)), line, { mode: 0o600 });
|
|
273
|
+
pruneOldFiles(logDir);
|
|
274
|
+
} catch {
|
|
275
|
+
// Serialization (e.g. a circular context) must never throw into the
|
|
276
|
+
// caller: record a bounded failure event instead.
|
|
277
|
+
try {
|
|
278
|
+
event = {
|
|
279
|
+
level,
|
|
280
|
+
time: date.toISOString(),
|
|
281
|
+
message: redactString(message, redactOptions),
|
|
282
|
+
context: { redaction_failed: true },
|
|
283
|
+
};
|
|
284
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
285
|
+
appendFileSync(path.join(logDir, dailyFileName(date)), `${JSON.stringify(event)}\n`, {
|
|
286
|
+
mode: 0o600,
|
|
287
|
+
});
|
|
288
|
+
} catch {
|
|
289
|
+
// logging must never break the host
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const sink of [options.appLog, options.stderr]) {
|
|
294
|
+
if (!sink) continue;
|
|
295
|
+
if (!event) continue;
|
|
296
|
+
try {
|
|
297
|
+
sink(event);
|
|
298
|
+
} catch {
|
|
299
|
+
// a broken sink must not break the caller
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
debug: (message, context) => emit("debug", message, context),
|
|
306
|
+
info: (message, context) => emit("info", message, context),
|
|
307
|
+
warn: (message, context) => emit("warn", message, context),
|
|
308
|
+
error: (message, context) => emit("error", message, context),
|
|
309
|
+
guard: <T>(name: string, fn: () => T): T | undefined => {
|
|
310
|
+
try {
|
|
311
|
+
return fn();
|
|
312
|
+
} catch (err) {
|
|
313
|
+
emit("warn", "detector_failed", {
|
|
314
|
+
detector: name,
|
|
315
|
+
error: err instanceof Error ? err.message : String(err),
|
|
316
|
+
});
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// Package-local asset root. Core modules resolve their static assets relative to
|
|
6
|
+
// their own package root (templates/, commands/, skills/, hygiene/). In the
|
|
7
|
+
// monorepo the source package keeps assets at the package root; packaged
|
|
8
|
+
// adapters ship the same content under an explicit `assets/` directory.
|
|
9
|
+
//
|
|
10
|
+
// The walk finds the nearest ancestor directory that owns a package.json, which
|
|
11
|
+
// is the package root for both the monorepo source (packages/<pkg>/src/core/…)
|
|
12
|
+
// and a bundled adapter entry (packages/<pkg>/dist/….js).
|
|
13
|
+
export const packageRoot = (): string => {
|
|
14
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
while (true) {
|
|
16
|
+
const parent = path.dirname(dir);
|
|
17
|
+
// ponytail: stop at the filesystem root even without an ancestor package.json,
|
|
18
|
+
// so the upward walk can never loop forever.
|
|
19
|
+
if (existsSync(path.join(dir, "package.json")) || parent === dir) return dir;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const assetRoot = (): string => {
|
|
25
|
+
const root = packageRoot();
|
|
26
|
+
const assets = path.join(root, "assets");
|
|
27
|
+
return existsSync(assets) ? assets : root;
|
|
28
|
+
};
|
|
@@ -11,7 +11,7 @@ if (cmd === "log-time" || cmd === "post-comment") {
|
|
|
11
11
|
process.exit(1);
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
const out = youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? "");
|
|
14
|
+
const out = await youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? "");
|
|
15
15
|
if ("error" in out) {
|
|
16
16
|
console.log(JSON.stringify({ ok: false, error: out.error }));
|
|
17
17
|
process.exit(1);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// CLI port of scripts/youtrack/verify-token.sh.
|
|
2
2
|
import { youTrackVerifyToken } from "../youtrack";
|
|
3
3
|
|
|
4
|
-
const out = youTrackVerifyToken();
|
|
4
|
+
const out = await youTrackVerifyToken();
|
|
5
5
|
if ("error" in out) {
|
|
6
6
|
const payload: Record<string, any> = { ok: false, error: out.error };
|
|
7
7
|
if (out.http_status !== undefined) payload.http_status = out.http_status;
|
package/src/core/pr-create.ts
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { vcsConfig } from "./vcs-config";
|
|
5
|
+
import { resolveBranchPolicyFor } from "./branch";
|
|
5
6
|
|
|
6
7
|
// Port of scripts/pr-create.sh — build MR/PR body issue linking + create via glab/gh.
|
|
7
8
|
|
|
@@ -19,6 +20,23 @@ function parseGhIssue(value: string): string {
|
|
|
19
20
|
return m ? m[1] : String(value).trim().replace(/^#/, "");
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// RL-03/CA-25/AR-08: a branch-derived numeric issue id must be a bare number at
|
|
24
|
+
// a segment or dash boundary — and never part of a date segment. Year-first
|
|
25
|
+
// (feature/2024-01-15/x) and day-first (feature/15-01-2024/x) dates are both
|
|
26
|
+
// skipped — a complete date anywhere in a segment (release-2024-01-15,
|
|
27
|
+
// v2-2024-01-15-fix) — so no date digit ever closes an issue. Deliberate
|
|
28
|
+
// numeric issue branches (feature/42-title, feature/2024-fix) keep linking.
|
|
29
|
+
function deriveGhIssueFromBranch(branch: string): string {
|
|
30
|
+
for (const segment of branch.split("/")) {
|
|
31
|
+
if (/^\d{4}-\d/.test(segment)) continue; // year-first date-like segment (incl. year-month)
|
|
32
|
+
if (/\d{4}-\d{1,2}-\d{1,2}/.test(segment)) continue; // complete year-first date anywhere
|
|
33
|
+
if (/\d{1,2}-\d{1,2}-\d{4}/.test(segment)) continue; // complete day-first date anywhere
|
|
34
|
+
const m = /(?:^|-)(\d+)(?:-|$)/.exec(segment);
|
|
35
|
+
if (m) return m[1];
|
|
36
|
+
}
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
function buildBody(
|
|
23
41
|
body: string,
|
|
24
42
|
branch: string,
|
|
@@ -44,9 +62,7 @@ function buildBody(
|
|
|
44
62
|
if (!issue && branch) {
|
|
45
63
|
// pure-number issue id (feature/42-title -> 42); digits must be followed by a dash or end-of-string
|
|
46
64
|
// so version tokens (release/1.2.3, backport/8.0.1, lodash-4.17.21, 2024.1) never link
|
|
47
|
-
|
|
48
|
-
const m = /(?:^|\/|-)(\d+)(?:-|$)/.exec(branch);
|
|
49
|
-
if (m) issue = m[1];
|
|
65
|
+
issue = deriveGhIssueFromBranch(branch);
|
|
50
66
|
}
|
|
51
67
|
if (issue) {
|
|
52
68
|
if (ghRelation === "related") {
|
|
@@ -66,14 +82,19 @@ const truthy = (v: string | undefined): boolean =>
|
|
|
66
82
|
|
|
67
83
|
// Port of python's shutil.which — scan PATH in-process (no `which` binary needed).
|
|
68
84
|
function whichOnPath(tool: string): string | null {
|
|
69
|
-
|
|
85
|
+
// win32 CLIs/stubs carry .exe/.cmd suffixes (gh.exe, gh.cmd), so probe them
|
|
86
|
+
// too — accessSync with the bare name would never find them.
|
|
87
|
+
const names = process.platform === "win32" ? [tool, `${tool}.exe`, `${tool}.cmd`] : [tool];
|
|
88
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
70
89
|
if (!dir) continue;
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const candidate = path.join(dir, name);
|
|
92
|
+
try {
|
|
93
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
94
|
+
return candidate;
|
|
95
|
+
} catch {
|
|
96
|
+
/* keep scanning */
|
|
97
|
+
}
|
|
77
98
|
}
|
|
78
99
|
}
|
|
79
100
|
return null;
|
|
@@ -108,14 +129,93 @@ export function prBuildBody(env: NodeJS.ProcessEnv, cwd?: string): string {
|
|
|
108
129
|
/** Port of scripts/pr-create.sh create mode — glab/gh MR/PR creation. */
|
|
109
130
|
export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, any> {
|
|
110
131
|
const root = process.env.WORKFLOW_WORKSPACE_ROOT ?? repoRoot(cwd);
|
|
132
|
+
const policy = resolveBranchPolicyFor(root);
|
|
111
133
|
const cfg = vcsConfig("load", root);
|
|
112
134
|
if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
|
|
113
|
-
|
|
135
|
+
// Merge mode finishes the feature locally (git merge + push) — no glab/gh,
|
|
136
|
+
// no API token, so token readiness is only required for the PR path.
|
|
137
|
+
if (policy.integration !== "merge" && !cfg.tokenReady)
|
|
114
138
|
return { error: "VCS token not ready — run /wk-init and edit token file locally" };
|
|
115
139
|
|
|
116
140
|
const provider = cfg.provider as string;
|
|
117
141
|
if (provider !== "gitlab" && provider !== "github")
|
|
118
142
|
return { error: `unsupported provider: ${provider}` };
|
|
143
|
+
|
|
144
|
+
// B1/RL-03: WF_PR_TARGET is the one deliberate override knob on the create
|
|
145
|
+
// surface (resolvePrBranchContext/docsBranch have none). Unlike the config
|
|
146
|
+
// default — which is authoritative by construction — a caller-supplied
|
|
147
|
+
// target is validated against the resolved branch policy so a PR can never
|
|
148
|
+
// be aimed at a protected or disallowed branch.
|
|
149
|
+
const targetOverride = env.WF_PR_TARGET;
|
|
150
|
+
const target =
|
|
151
|
+
targetOverride || String(cfg.defaultTargetBranch ?? policy.defaultTargetBranch ?? "develop");
|
|
152
|
+
if (targetOverride) {
|
|
153
|
+
const { allowed, protected: protectedTargets } = policy;
|
|
154
|
+
if (protectedTargets.has(targetOverride.toLowerCase()))
|
|
155
|
+
return {
|
|
156
|
+
error: `PR target ${JSON.stringify(targetOverride)} is a protected branch — override must be an allowed non-protected target`,
|
|
157
|
+
};
|
|
158
|
+
if (!allowed.some((r) => r.test(targetOverride)))
|
|
159
|
+
return {
|
|
160
|
+
error: `PR target ${JSON.stringify(targetOverride)} is not allowed by the branch policy`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const title = String(env.WF_PR_TITLE ?? "");
|
|
165
|
+
const br = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
166
|
+
cwd: root,
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
});
|
|
169
|
+
const branch = br.status === 0 ? (br.stdout ?? "").trim() : "";
|
|
170
|
+
|
|
171
|
+
if (policy.integration === "merge") {
|
|
172
|
+
const finish = (): Record<string, any> => {
|
|
173
|
+
const merge = spawnSync("git", ["merge", "--no-ff", branch, "-m", title], {
|
|
174
|
+
cwd: root,
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
});
|
|
177
|
+
if (merge.status !== 0)
|
|
178
|
+
return {
|
|
179
|
+
error: "merge failed",
|
|
180
|
+
mode: "merge",
|
|
181
|
+
targetBranch: target,
|
|
182
|
+
stderr: (merge.stderr ?? "").slice(0, 800),
|
|
183
|
+
};
|
|
184
|
+
const push = spawnSync("git", ["push", "origin", target], { cwd: root, encoding: "utf8" });
|
|
185
|
+
if (push.status !== 0)
|
|
186
|
+
return {
|
|
187
|
+
error: "push failed",
|
|
188
|
+
mode: "merge",
|
|
189
|
+
targetBranch: target,
|
|
190
|
+
stderr: (push.stderr ?? "").slice(0, 800),
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
mode: "merge",
|
|
195
|
+
targetBranch: target,
|
|
196
|
+
merged: true,
|
|
197
|
+
pushed: true,
|
|
198
|
+
output: (push.stdout ?? "").trim(),
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
const co = spawnSync("git", ["checkout", target], { cwd: root, encoding: "utf8" });
|
|
202
|
+
if (co.status !== 0)
|
|
203
|
+
return {
|
|
204
|
+
error: `cannot checkout target ${target}`,
|
|
205
|
+
mode: "merge",
|
|
206
|
+
targetBranch: target,
|
|
207
|
+
stderr: (co.stderr ?? "").slice(0, 800),
|
|
208
|
+
};
|
|
209
|
+
// finish() returns error objects, it never throws, so the best-effort
|
|
210
|
+
// `git checkout branch` restore below runs on ALL outcomes (success,
|
|
211
|
+
// merge failure, and push failure) — the tree always returns to the
|
|
212
|
+
// feature branch. Deliberately not a try/finally: the restore is
|
|
213
|
+
// best-effort and its own failure is not actionable on this path.
|
|
214
|
+
const result = finish();
|
|
215
|
+
spawnSync("git", ["checkout", branch], { cwd: root, encoding: "utf8" }); // best-effort return
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
119
219
|
const cli = provider === "gitlab" ? "glab" : "gh";
|
|
120
220
|
const installUrl =
|
|
121
221
|
provider === "gitlab" ? "https://gitlab.com/gitlab-org/cli" : "https://cli.github.com";
|
|
@@ -130,16 +230,8 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
130
230
|
|
|
131
231
|
const pr = (cfg.pr ?? {}) as Record<string, any>;
|
|
132
232
|
const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
|
|
133
|
-
const title = String(env.WF_PR_TITLE ?? "");
|
|
134
233
|
const body = env.WF_PR_BODY ?? "";
|
|
135
234
|
const draft = String(env.WF_PR_DRAFT ?? "false").toLowerCase() === "true";
|
|
136
|
-
const target = env.WF_PR_TARGET || String(cfg.defaultTargetBranch ?? "develop");
|
|
137
|
-
|
|
138
|
-
const br = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
139
|
-
cwd: root,
|
|
140
|
-
encoding: "utf8",
|
|
141
|
-
});
|
|
142
|
-
const branch = br.status === 0 ? (br.stdout ?? "").trim() : "";
|
|
143
235
|
|
|
144
236
|
let baseUrl = cfg.youtrack_base_url as string | undefined;
|
|
145
237
|
if (!baseUrl) {
|
|
@@ -186,12 +278,15 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
186
278
|
if (draft) cmd.push("--draft");
|
|
187
279
|
if (push) cmd.push("--push");
|
|
188
280
|
if (skipConfirm) cmd.push("--yes");
|
|
189
|
-
|
|
281
|
+
// bun's Windows spawn only consults PATH when the env object carries an
|
|
282
|
+
// explicit PATH key — a spread-only PATH is invisible to the lookup and
|
|
283
|
+
// uv_spawn fails with ENOENT even though the CLI is on PATH.
|
|
284
|
+
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_TOKEN: token };
|
|
190
285
|
} else {
|
|
191
286
|
cmd = ["gh", "pr", "create", "--title", title, "--base", target];
|
|
192
287
|
if (finalBody) cmd.push("--body", finalBody);
|
|
193
288
|
if (draft) cmd.push("--draft");
|
|
194
|
-
cmdEnv = { ...process.env, GH_TOKEN: token };
|
|
289
|
+
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_TOKEN: token };
|
|
195
290
|
}
|
|
196
291
|
|
|
197
292
|
const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });
|