@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/youtrack.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
3
|
import { readTemplate } from "./templates";
|
|
5
4
|
import { resolveWorkspaceRoot } from "./scripts";
|
|
6
|
-
import { configDir } from "./config";
|
|
5
|
+
import { configDir, isConfigObject } from "./config";
|
|
7
6
|
|
|
8
7
|
const ISSUE_RE = /^[A-Z]+-\d+$/;
|
|
9
8
|
const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
|
|
@@ -11,8 +10,7 @@ const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
|
|
|
11
10
|
// Port of scripts/youtrack/config.sh chain: WORKFLOW_YOUTRACK_CONFIG ->
|
|
12
11
|
// configDir() (XDG_CONFIG_HOME / HOME .config + workit).
|
|
13
12
|
export const youTrackConfigPath = (): string =>
|
|
14
|
-
process.env.WORKFLOW_YOUTRACK_CONFIG ??
|
|
15
|
-
path.join(configDir(), "youtrack.json");
|
|
13
|
+
process.env.WORKFLOW_YOUTRACK_CONFIG ?? path.join(configDir(), "youtrack.json");
|
|
16
14
|
|
|
17
15
|
const youTrackTokenModeOk = (p: string): boolean => {
|
|
18
16
|
if (process.platform === "win32") return true;
|
|
@@ -20,27 +18,51 @@ const youTrackTokenModeOk = (p: string): boolean => {
|
|
|
20
18
|
return mode === 0o600;
|
|
21
19
|
};
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
// AR-07/CA-37: the shared shape rule applies here too — a parseable non-object
|
|
22
|
+
// youtrack.json (null, scalar, array) is malformed with the exact path, never
|
|
23
|
+
// missing/unconfigured, never silently defaulted.
|
|
24
|
+
function readYouTrackConfig(
|
|
25
|
+
required: boolean,
|
|
26
|
+
): { config: Record<string, any>; path: string } | { error: string; path: string } {
|
|
24
27
|
const cfgPath = youTrackConfigPath();
|
|
25
28
|
if (!fs.existsSync(cfgPath)) {
|
|
26
|
-
return required
|
|
29
|
+
return required
|
|
30
|
+
? { error: "ERROR: missing youtrack.json", path: cfgPath }
|
|
31
|
+
: { config: {}, path: cfgPath };
|
|
27
32
|
}
|
|
33
|
+
let parsed: unknown;
|
|
28
34
|
try {
|
|
29
|
-
|
|
30
|
-
return { config, path: cfgPath };
|
|
35
|
+
parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
31
36
|
} catch {
|
|
32
|
-
return { error:
|
|
37
|
+
return { error: `${cfgPath} is not valid JSON`, path: cfgPath };
|
|
33
38
|
}
|
|
39
|
+
if (!isConfigObject(parsed)) {
|
|
40
|
+
return { error: `${cfgPath} is not a JSON object`, path: cfgPath };
|
|
41
|
+
}
|
|
42
|
+
return { config: parsed as Record<string, any>, path: cfgPath };
|
|
34
43
|
}
|
|
35
44
|
|
|
45
|
+
// RL-01: typed load result — malformed carries {ok:false, error, configPath}
|
|
46
|
+
// mirroring vcsConfig, so risky consumers stop on the exact path.
|
|
47
|
+
export type YouTrackConfigResult =
|
|
48
|
+
| { data: Record<string, any> }
|
|
49
|
+
| { error: string }
|
|
50
|
+
| { ok: false; error: string; configPath: string };
|
|
51
|
+
|
|
36
52
|
/** Load + redact youtrack.json; validates the token file like youtrack/config.sh load. */
|
|
37
|
-
export function youTrackConfigLoad():
|
|
53
|
+
export function youTrackConfigLoad(): YouTrackConfigResult {
|
|
38
54
|
const loaded = readYouTrackConfig(true);
|
|
39
|
-
if ("error" in loaded)
|
|
55
|
+
if ("error" in loaded) {
|
|
56
|
+
// RL-01: malformed (parse failure or non-object) fails closed with the exact
|
|
57
|
+
// path, mirroring vcsConfig's {ok:false,error,configPath} shape.
|
|
58
|
+
return { ok: false, error: loaded.error, configPath: loaded.path };
|
|
59
|
+
}
|
|
40
60
|
const cfgPath = loaded.path;
|
|
41
61
|
const tokenFile = String(loaded.config.tokenFile ?? "");
|
|
42
62
|
const tokenPath = tokenFile
|
|
43
|
-
?
|
|
63
|
+
? path.isAbsolute(tokenFile)
|
|
64
|
+
? path.resolve(tokenFile)
|
|
65
|
+
: path.resolve(process.cwd(), tokenFile)
|
|
44
66
|
: "";
|
|
45
67
|
if (!tokenPath || !fs.existsSync(tokenPath)) {
|
|
46
68
|
return { error: "missing youtrack.token" };
|
|
@@ -60,20 +82,47 @@ export function youTrackConfigLoad(): { data: Record<string, any> } | { error: s
|
|
|
60
82
|
return { data: redacted };
|
|
61
83
|
}
|
|
62
84
|
|
|
63
|
-
function tzParts(
|
|
85
|
+
function tzParts(
|
|
86
|
+
date: Date,
|
|
87
|
+
tz: string,
|
|
88
|
+
): { y: string; m: string; d: string; hour: string; minute: string } {
|
|
64
89
|
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
65
|
-
timeZone: tz,
|
|
66
|
-
|
|
90
|
+
timeZone: tz,
|
|
91
|
+
year: "numeric",
|
|
92
|
+
month: "2-digit",
|
|
93
|
+
day: "2-digit",
|
|
94
|
+
hour: "2-digit",
|
|
95
|
+
minute: "2-digit",
|
|
96
|
+
hourCycle: "h23",
|
|
67
97
|
}).formatToParts(date);
|
|
68
98
|
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
69
99
|
return { y: map.year, m: map.month, d: map.day, hour: map.hour, minute: map.minute };
|
|
70
100
|
}
|
|
71
101
|
|
|
72
102
|
/** Port of scripts/youtrack/greeting.sh. */
|
|
73
|
-
export function youTrackGreeting(configOverride?: string): {
|
|
103
|
+
export function youTrackGreeting(configOverride?: string): {
|
|
104
|
+
stdout: string;
|
|
105
|
+
exitCode: number;
|
|
106
|
+
stderr: string;
|
|
107
|
+
} {
|
|
74
108
|
const cfgPath = configOverride ?? youTrackConfigPath();
|
|
109
|
+
let parsed: unknown;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
112
|
+
} catch {
|
|
113
|
+
return {
|
|
114
|
+
stdout: "",
|
|
115
|
+
exitCode: 1,
|
|
116
|
+
stderr: fs.existsSync(cfgPath)
|
|
117
|
+
? `${cfgPath} is not valid JSON`
|
|
118
|
+
: `missing youtrack.json: ${cfgPath}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (!isConfigObject(parsed)) {
|
|
122
|
+
return { stdout: "", exitCode: 1, stderr: `${cfgPath} is not a JSON object` };
|
|
123
|
+
}
|
|
124
|
+
const config = parsed as Record<string, any>;
|
|
75
125
|
try {
|
|
76
|
-
const config = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as Record<string, any>;
|
|
77
126
|
const tz = String(config.timezone ?? "America/Santiago");
|
|
78
127
|
const now = new Date();
|
|
79
128
|
const { y, m, d, hour, minute } = tzParts(now, tz);
|
|
@@ -81,20 +130,29 @@ export function youTrackGreeting(configOverride?: string): { stdout: string; exi
|
|
|
81
130
|
const cutoffHour = Number(cutoff[0]);
|
|
82
131
|
const cutoffMinute = Number(cutoff[1] ?? 0);
|
|
83
132
|
const greetings = (config.greetings ?? {}) as Record<string, string>;
|
|
84
|
-
const isMorning =
|
|
133
|
+
const isMorning =
|
|
134
|
+
Number(hour) < cutoffHour || (Number(hour) === cutoffHour && Number(minute) < cutoffMinute);
|
|
85
135
|
const greeting = isMorning
|
|
86
|
-
? greetings.morning ?? "buenos días"
|
|
87
|
-
: greetings.afternoon ?? "buenas tardes";
|
|
136
|
+
? (greetings.morning ?? "buenos días")
|
|
137
|
+
: (greetings.afternoon ?? "buenas tardes");
|
|
88
138
|
const mention = String(config.defaultMention ?? "Alejandra.Flores");
|
|
89
|
-
void y;
|
|
139
|
+
void y;
|
|
140
|
+
void m;
|
|
141
|
+
void d;
|
|
90
142
|
return { stdout: `@${mention} Hola, ${greeting}.\n`, exitCode: 0, stderr: "" };
|
|
91
143
|
} catch (err) {
|
|
92
|
-
return {
|
|
144
|
+
return {
|
|
145
|
+
stdout: "",
|
|
146
|
+
exitCode: 1,
|
|
147
|
+
stderr: err instanceof Error ? err.message : "greeting failed",
|
|
148
|
+
};
|
|
93
149
|
}
|
|
94
150
|
}
|
|
95
151
|
|
|
96
152
|
/** Port of scripts/youtrack/parse-duration.sh. */
|
|
97
|
-
export function youTrackParseDuration(
|
|
153
|
+
export function youTrackParseDuration(
|
|
154
|
+
text: string,
|
|
155
|
+
): { data: { minutes: number; text: string } } | { error: string } {
|
|
98
156
|
const lower = String(text).toLowerCase().trim();
|
|
99
157
|
let total = 0;
|
|
100
158
|
for (const match of lower.matchAll(/(\d+)\s*h/g)) total += Number(match[1]) * 60;
|
|
@@ -105,13 +163,26 @@ export function youTrackParseDuration(text: string): { data: { minutes: number;
|
|
|
105
163
|
}
|
|
106
164
|
|
|
107
165
|
/** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms. */
|
|
108
|
-
export function youTrackWorkDateMs(
|
|
166
|
+
export function youTrackWorkDateMs(
|
|
167
|
+
dateRaw: string,
|
|
168
|
+
): { data: { dateMs: number; timezone: string; localDate: string } } | { error: string } {
|
|
109
169
|
const cfgPath = youTrackConfigPath();
|
|
110
170
|
let tz = "America/Santiago";
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
171
|
+
// Missing file is a legitimate unconfigured state (reader: "missing" keeps
|
|
172
|
+
// defaults); a parseable non-object is malformed and must propagate the
|
|
173
|
+
// exact-path error instead of silently defaulting the timezone.
|
|
174
|
+
if (fs.existsSync(cfgPath)) {
|
|
175
|
+
let parsed: unknown;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
178
|
+
} catch {
|
|
179
|
+
return { error: `${cfgPath} is not valid JSON` };
|
|
180
|
+
}
|
|
181
|
+
if (!isConfigObject(parsed)) {
|
|
182
|
+
return { error: `${cfgPath} is not a JSON object` };
|
|
183
|
+
}
|
|
184
|
+
tz = String((parsed as Record<string, any>).timezone ?? "America/Santiago");
|
|
185
|
+
}
|
|
115
186
|
const raw = dateRaw || "auto";
|
|
116
187
|
try {
|
|
117
188
|
if (raw === "auto" || !raw) {
|
|
@@ -128,7 +199,13 @@ export function youTrackWorkDateMs(dateRaw: string): { data: { dateMs: number; t
|
|
|
128
199
|
const [y, m, d] = raw.split("-").map(Number);
|
|
129
200
|
const iso = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00`;
|
|
130
201
|
const dateMs = Math.floor(Date.parse(iso) / 86400000) * 86400000;
|
|
131
|
-
return {
|
|
202
|
+
return {
|
|
203
|
+
data: {
|
|
204
|
+
dateMs,
|
|
205
|
+
timezone: tz,
|
|
206
|
+
localDate: `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
132
209
|
} catch (err) {
|
|
133
210
|
return { error: err instanceof Error ? err.message : "could not resolve date" };
|
|
134
211
|
}
|
|
@@ -139,63 +216,106 @@ const youTrackToken = (): { token: string; base: string } | { error: string } =>
|
|
|
139
216
|
if ("error" in loaded) return loaded;
|
|
140
217
|
const tokenFile = String(loaded.config.tokenFile ?? "");
|
|
141
218
|
const tokenPath = tokenFile
|
|
142
|
-
?
|
|
219
|
+
? path.isAbsolute(tokenFile)
|
|
220
|
+
? path.resolve(tokenFile)
|
|
221
|
+
: path.resolve(process.cwd(), tokenFile)
|
|
143
222
|
: "";
|
|
144
223
|
if (!tokenPath || !fs.existsSync(tokenPath)) return { error: "missing youtrack.token" };
|
|
145
224
|
if (!youTrackTokenModeOk(tokenPath)) return { error: "youtrack.token mode must be 0600" };
|
|
146
225
|
const token = fs.readFileSync(tokenPath, "utf8").trim();
|
|
147
226
|
if (!token) return { error: "empty token file" };
|
|
148
227
|
if (token === TOKEN_PLACEHOLDER || token.startsWith(TOKEN_PLACEHOLDER)) {
|
|
149
|
-
return {
|
|
228
|
+
return {
|
|
229
|
+
error:
|
|
230
|
+
"token file still has placeholder YOUR_TOKEN_HERE — edit the file locally, then run /wk-status",
|
|
231
|
+
};
|
|
150
232
|
}
|
|
151
233
|
const base = String(loaded.config.baseUrl ?? "").replace(/\/+$/, "");
|
|
152
234
|
if (!base) return { error: "baseUrl missing in config" };
|
|
153
235
|
return { token, base };
|
|
154
236
|
};
|
|
155
237
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
238
|
+
// fetch replaces the previous curl -fsS request helper: check res.ok,
|
|
239
|
+
// surface HTTP errors without a token-bearing body, parse JSON on success.
|
|
240
|
+
async function youTrackRequest(
|
|
241
|
+
url: string,
|
|
242
|
+
init: { method: string; token: string; body?: unknown },
|
|
243
|
+
): Promise<{ status: number; stdout: string; stderr: string }> {
|
|
244
|
+
const headers: Record<string, string> = {
|
|
245
|
+
Authorization: `Bearer ${init.token}`,
|
|
246
|
+
Accept: "application/json",
|
|
247
|
+
};
|
|
248
|
+
let body: string | undefined;
|
|
249
|
+
if (init.body !== undefined) {
|
|
250
|
+
headers["Content-Type"] = "application/json";
|
|
251
|
+
body = JSON.stringify(init.body);
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const res = await fetch(url, { method: init.method, headers, body });
|
|
255
|
+
const text = await res.text();
|
|
256
|
+
if (!res.ok) {
|
|
257
|
+
return { status: res.status, stdout: "", stderr: text.slice(0, 200) };
|
|
258
|
+
}
|
|
259
|
+
return { status: 0, stdout: text, stderr: "" };
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return { status: 1, stdout: "", stderr: err instanceof Error ? err.message : "network error" };
|
|
262
|
+
}
|
|
159
263
|
}
|
|
160
264
|
|
|
161
265
|
/** Port of scripts/youtrack/api.sh — log-time / post-comment with the WORKFLOW_YT_WRITE guard. */
|
|
162
|
-
export function youTrackApi(
|
|
266
|
+
export async function youTrackApi(
|
|
267
|
+
args: string[],
|
|
268
|
+
writeFlag = process.env.WORKFLOW_YT_WRITE ?? "",
|
|
269
|
+
): Promise<{ data: Record<string, any> } | { error: string }> {
|
|
163
270
|
const cmd = args[0];
|
|
164
271
|
if (cmd === "log-time" || cmd === "post-comment") {
|
|
165
272
|
if (writeFlag !== "1") {
|
|
166
|
-
return {
|
|
273
|
+
return {
|
|
274
|
+
error:
|
|
275
|
+
"YouTrack write operations require WORKFLOW_YT_WRITE=1 (refusing to mutate production)",
|
|
276
|
+
};
|
|
167
277
|
}
|
|
168
278
|
}
|
|
169
279
|
const creds = youTrackToken();
|
|
170
280
|
if ("error" in creds) return creds;
|
|
171
281
|
const { token, base } = creds;
|
|
172
|
-
const auth = ["-H", `Authorization: Bearer ${token}`, "-H", "Accept: application/json"];
|
|
173
282
|
|
|
174
283
|
if (cmd === "log-time") {
|
|
175
284
|
const [issue, minutesRaw, text, dateArg] = args.slice(1);
|
|
176
285
|
const minutes = Number(minutesRaw);
|
|
177
286
|
const dateMs = youTrackWorkDateMs(dateArg ?? "auto");
|
|
178
287
|
if ("error" in dateMs) return dateMs;
|
|
179
|
-
const
|
|
180
|
-
const out = youTrackCurl([
|
|
181
|
-
...auth, "-H", "Content-Type: application/json", "-d", body,
|
|
288
|
+
const out = await youTrackRequest(
|
|
182
289
|
`${base}/api/issues/${issue}/timeTracking/workItems?fields=id,idReadable`,
|
|
183
|
-
|
|
290
|
+
{
|
|
291
|
+
method: "POST",
|
|
292
|
+
token,
|
|
293
|
+
body: { duration: { minutes }, text, date: dateMs.data.dateMs },
|
|
294
|
+
},
|
|
295
|
+
);
|
|
184
296
|
if (out.status !== 0) return { error: "YouTrack HTTP request failed" };
|
|
185
297
|
try {
|
|
186
298
|
const created = JSON.parse(out.stdout) as Record<string, any>;
|
|
187
|
-
return {
|
|
299
|
+
return {
|
|
300
|
+
data: {
|
|
301
|
+
ok: true,
|
|
302
|
+
issueId: issue,
|
|
303
|
+
workItemId: created.id,
|
|
304
|
+
dateMs: dateMs.data.dateMs,
|
|
305
|
+
minutes,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
188
308
|
} catch {
|
|
189
309
|
return { error: "invalid JSON from YouTrack API" };
|
|
190
310
|
}
|
|
191
311
|
}
|
|
192
312
|
if (cmd === "post-comment") {
|
|
193
313
|
const [issue, text] = args.slice(1);
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
314
|
+
const out = await youTrackRequest(`${base}/api/issues/${issue}/comments`, {
|
|
315
|
+
method: "POST",
|
|
316
|
+
token,
|
|
317
|
+
body: { text },
|
|
318
|
+
});
|
|
199
319
|
if (out.status !== 0) return { error: "YouTrack HTTP request failed" };
|
|
200
320
|
return { data: { ok: true, issueId: issue } };
|
|
201
321
|
}
|
|
@@ -203,18 +323,24 @@ export function youTrackApi(args: string[], writeFlag = process.env.WORKFLOW_YT_
|
|
|
203
323
|
}
|
|
204
324
|
|
|
205
325
|
/** Port of scripts/youtrack/verify-token.sh — read-only GET /api/users/me. */
|
|
206
|
-
export function youTrackVerifyToken():
|
|
326
|
+
export async function youTrackVerifyToken(): Promise<
|
|
327
|
+
{ data: Record<string, any> } | { error: string; http_status?: number; path?: string }
|
|
328
|
+
> {
|
|
207
329
|
const cfgPath = youTrackConfigPath();
|
|
208
330
|
if (!fs.existsSync(cfgPath)) return { error: "missing youtrack.json" };
|
|
209
331
|
const creds = youTrackToken();
|
|
210
332
|
if ("error" in creds) return { error: creds.error };
|
|
211
333
|
const { token, base } = creds;
|
|
212
334
|
|
|
213
|
-
const me =
|
|
214
|
-
|
|
335
|
+
const me = await youTrackRequest(`${base}/api/users/me?fields=id,login,name,email`, {
|
|
336
|
+
method: "GET",
|
|
337
|
+
token,
|
|
338
|
+
});
|
|
215
339
|
if (me.status !== 0) {
|
|
216
|
-
const
|
|
217
|
-
|
|
340
|
+
const err =
|
|
341
|
+
me.status === 401 || me.status === 403
|
|
342
|
+
? "authentication failed (401/403)"
|
|
343
|
+
: `HTTP error: ${me.stderr.slice(0, 200)}`;
|
|
218
344
|
return { error: err, http_status: me.status };
|
|
219
345
|
}
|
|
220
346
|
let user: Record<string, any>;
|
|
@@ -224,21 +350,30 @@ export function youTrackVerifyToken(): { data: Record<string, any> } | { error:
|
|
|
224
350
|
return { error: "invalid JSON from YouTrack /api/users/me" };
|
|
225
351
|
}
|
|
226
352
|
const result: Record<string, any> = {
|
|
227
|
-
ok: true,
|
|
228
|
-
|
|
353
|
+
ok: true,
|
|
354
|
+
method: "GET /api/users/me",
|
|
355
|
+
baseUrl: base,
|
|
356
|
+
login: user.login,
|
|
357
|
+
name: user.name,
|
|
358
|
+
email: user.email,
|
|
359
|
+
id: user.id,
|
|
229
360
|
};
|
|
230
361
|
const meeting = readYouTrackConfig(false);
|
|
231
362
|
const meetingIssue = "config" in meeting ? meeting.config.meetingIssue : undefined;
|
|
232
363
|
if (meetingIssue) {
|
|
233
|
-
const issue =
|
|
234
|
-
`${base}/api/issues/${meetingIssue}?fields=id,idReadable,summary
|
|
364
|
+
const issue = await youTrackRequest(
|
|
365
|
+
`${base}/api/issues/${meetingIssue}?fields=id,idReadable,summary`,
|
|
366
|
+
{ method: "GET", token },
|
|
367
|
+
);
|
|
235
368
|
if (issue.status === 0) {
|
|
236
369
|
try {
|
|
237
370
|
const parsed = JSON.parse(issue.stdout) as Record<string, any>;
|
|
238
371
|
result.meetingIssue = meetingIssue;
|
|
239
372
|
result.meetingIssueReadable = true;
|
|
240
373
|
result.meetingIssueSummary = parsed.summary;
|
|
241
|
-
} catch {
|
|
374
|
+
} catch {
|
|
375
|
+
/* unreadable */
|
|
376
|
+
}
|
|
242
377
|
} else {
|
|
243
378
|
result.meetingIssue = meetingIssue;
|
|
244
379
|
result.meetingIssueReadable = false;
|
|
@@ -252,14 +387,20 @@ export function youTrackVerifyToken(): { data: Record<string, any> } | { error:
|
|
|
252
387
|
export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
|
|
253
388
|
const tokenName = process.env.WORKFLOW_YT_TOKEN_NAME ?? "workit";
|
|
254
389
|
const loaded = readYouTrackConfig(false);
|
|
255
|
-
|
|
256
|
-
|
|
390
|
+
if ("error" in loaded) {
|
|
391
|
+
return { data: { error: loaded.error } };
|
|
392
|
+
}
|
|
393
|
+
const config = loaded.config;
|
|
257
394
|
const defaults = (config.tokenDefaults ?? {}) as Record<string, any>;
|
|
258
395
|
const name = String(defaults.name ?? tokenName);
|
|
259
|
-
const desc = String(
|
|
396
|
+
const desc = String(
|
|
397
|
+
defaults.description ?? "OpenCode workit — /wk-issue-update and /wk-meetings",
|
|
398
|
+
);
|
|
260
399
|
const scopes = Array.isArray(defaults.scopes) ? defaults.scopes : ["YouTrack"];
|
|
261
400
|
const base = String(config.baseUrl ?? "https://enghouseamg.youtrack.cloud").replace(/\/+$/, "");
|
|
262
|
-
const tokenFile = String(
|
|
401
|
+
const tokenFile = String(
|
|
402
|
+
config.tokenFile ?? path.join(path.dirname(loaded.path), "youtrack.token"),
|
|
403
|
+
);
|
|
263
404
|
const tab = String(defaults.profileTab ?? "account-security");
|
|
264
405
|
const createUrl = `${base}/users/me?${new URLSearchParams({ tab })}`;
|
|
265
406
|
const docsUrl = "https://www.jetbrains.com/help/youtrack/cloud/manage-permanent-token.html";
|
|
@@ -284,7 +425,9 @@ export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
|
|
|
284
425
|
}
|
|
285
426
|
|
|
286
427
|
/** Parse bare id (NSR-40) or YouTrack URL into issue id. */
|
|
287
|
-
export function parseIssueRef(
|
|
428
|
+
export function parseIssueRef(
|
|
429
|
+
input: unknown,
|
|
430
|
+
): { issueId: string; source: string } | { error: string } {
|
|
288
431
|
const trimmed = String(input ?? "").trim();
|
|
289
432
|
if (!trimmed) return { error: "empty issue reference" };
|
|
290
433
|
|
|
@@ -309,7 +452,7 @@ export type YouTrackScripts = {
|
|
|
309
452
|
config(): Record<string, any>;
|
|
310
453
|
greeting(): { stdout: string; exitCode: number; stderr: string };
|
|
311
454
|
parseDuration(text: string): Record<string, any>;
|
|
312
|
-
api(args: string[]): Record<string, any
|
|
455
|
+
api(args: string[]): Record<string, any> | Promise<Record<string, any>>;
|
|
313
456
|
};
|
|
314
457
|
|
|
315
458
|
const defaultScripts: YouTrackScripts = {
|
|
@@ -319,11 +462,17 @@ const defaultScripts: YouTrackScripts = {
|
|
|
319
462
|
api: (args) => youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? ""),
|
|
320
463
|
};
|
|
321
464
|
|
|
322
|
-
export function verifyYouTrackToken(
|
|
465
|
+
export function verifyYouTrackToken(
|
|
466
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
467
|
+
): Record<string, any> {
|
|
323
468
|
return scripts.config();
|
|
324
469
|
}
|
|
325
470
|
|
|
326
|
-
function resolveYouTrackFromPaths(
|
|
471
|
+
function resolveYouTrackFromPaths(
|
|
472
|
+
spec_path: string | undefined,
|
|
473
|
+
plan_path: string | undefined,
|
|
474
|
+
workspace_root: string,
|
|
475
|
+
): string | null {
|
|
327
476
|
const root = resolveWorkspaceRoot(workspace_root);
|
|
328
477
|
for (const rel of [spec_path, plan_path].filter(Boolean) as string[]) {
|
|
329
478
|
const full = path.isAbsolute(rel) ? rel : path.join(root, rel);
|
|
@@ -358,7 +507,26 @@ function meetingOptionsFromConfig(cfg: any): Record<string, any>[] {
|
|
|
358
507
|
];
|
|
359
508
|
}
|
|
360
509
|
|
|
361
|
-
export function context(
|
|
510
|
+
export function context(
|
|
511
|
+
{
|
|
512
|
+
spec_path,
|
|
513
|
+
plan_path,
|
|
514
|
+
issue_id,
|
|
515
|
+
issue_url,
|
|
516
|
+
issue_ref,
|
|
517
|
+
mode,
|
|
518
|
+
workspace_root,
|
|
519
|
+
}: {
|
|
520
|
+
spec_path?: string;
|
|
521
|
+
plan_path?: string;
|
|
522
|
+
issue_id?: string;
|
|
523
|
+
issue_url?: string;
|
|
524
|
+
issue_ref?: string;
|
|
525
|
+
mode?: string;
|
|
526
|
+
workspace_root: string;
|
|
527
|
+
},
|
|
528
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
529
|
+
): Record<string, any> {
|
|
362
530
|
const cfg = scripts.config();
|
|
363
531
|
if (cfg.error) return { error: cfg.error };
|
|
364
532
|
|
|
@@ -390,7 +558,8 @@ export function context({ spec_path, plan_path, issue_id, issue_url, issue_ref,
|
|
|
390
558
|
if (!issue) issue = resolveYouTrackFromPaths(spec_path, plan_path, workspace_root) ?? undefined;
|
|
391
559
|
if (!issue || !ISSUE_RE.test(issue)) {
|
|
392
560
|
return {
|
|
393
|
-
error:
|
|
561
|
+
error:
|
|
562
|
+
"invalid or missing issue id — pass issue_url, issue_id, or spec/plan with **YouTrack:**",
|
|
394
563
|
requiresIssueInput: true,
|
|
395
564
|
};
|
|
396
565
|
}
|
|
@@ -411,39 +580,86 @@ export function context({ spec_path, plan_path, issue_id, issue_url, issue_ref,
|
|
|
411
580
|
};
|
|
412
581
|
}
|
|
413
582
|
|
|
414
|
-
export function parseDuration(
|
|
583
|
+
export function parseDuration(
|
|
584
|
+
text: string,
|
|
585
|
+
_workspace_root: string,
|
|
586
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
587
|
+
): Record<string, any> {
|
|
415
588
|
const out = scripts.parseDuration(text);
|
|
416
589
|
if (out.error) return { error: out.error };
|
|
417
590
|
return out.data;
|
|
418
591
|
}
|
|
419
592
|
|
|
420
|
-
export function logTime(
|
|
593
|
+
export async function logTime(
|
|
594
|
+
{
|
|
595
|
+
issueId,
|
|
596
|
+
minutes,
|
|
597
|
+
text,
|
|
598
|
+
date,
|
|
599
|
+
dateMs,
|
|
600
|
+
workspace_root: _workspace_root,
|
|
601
|
+
}: {
|
|
602
|
+
issueId: string;
|
|
603
|
+
minutes: number;
|
|
604
|
+
text?: string;
|
|
605
|
+
date?: string;
|
|
606
|
+
dateMs?: number;
|
|
607
|
+
workspace_root: string;
|
|
608
|
+
},
|
|
609
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
610
|
+
): Promise<Record<string, any>> {
|
|
421
611
|
if (!issueId || !ISSUE_RE.test(issueId)) return { error: "invalid issueId" };
|
|
422
612
|
if (!minutes || minutes <= 0) return { error: "minutes must be positive" };
|
|
423
613
|
const workText = text ?? "workit";
|
|
424
614
|
const dateArg =
|
|
425
|
-
dateMs != null
|
|
426
|
-
|
|
427
|
-
: date && /^\d+$/.test(String(date))
|
|
428
|
-
? String(date)
|
|
429
|
-
: "auto";
|
|
430
|
-
const out = scripts.api(["log-time", issueId, String(minutes), workText, dateArg]);
|
|
615
|
+
dateMs != null ? String(dateMs) : date && /^\d+$/.test(String(date)) ? String(date) : "auto";
|
|
616
|
+
const out = await scripts.api(["log-time", issueId, String(minutes), workText, dateArg]);
|
|
431
617
|
if (out.error) return { error: out.error };
|
|
432
618
|
return { issueId, minutes, text: workText, ...out.data, ok: true };
|
|
433
619
|
}
|
|
434
620
|
|
|
435
|
-
|
|
436
|
-
|
|
621
|
+
export function buildDraft({
|
|
622
|
+
issueId,
|
|
623
|
+
projectName,
|
|
624
|
+
userNotes,
|
|
625
|
+
greeting,
|
|
626
|
+
facts,
|
|
627
|
+
includeProjectOpener,
|
|
628
|
+
includeFacts,
|
|
629
|
+
}: {
|
|
630
|
+
issueId: string;
|
|
631
|
+
projectName?: string;
|
|
632
|
+
userNotes?: string;
|
|
633
|
+
greeting?: string;
|
|
634
|
+
facts?: any;
|
|
635
|
+
includeProjectOpener?: boolean;
|
|
636
|
+
includeFacts?: boolean;
|
|
637
|
+
}): Record<string, any> {
|
|
437
638
|
const tpl = readTemplate("issue-update").content;
|
|
438
639
|
const para = (value: string): string => (value ? `\n\n${value}` : "");
|
|
439
640
|
const filled = tpl
|
|
440
641
|
.replaceAll("{{greetingSection}}", para(greeting ? `${greeting}` : ""))
|
|
441
|
-
.replaceAll(
|
|
642
|
+
.replaceAll(
|
|
643
|
+
"{{projectSection}}",
|
|
644
|
+
para(includeProjectOpener && projectName ? `Hoy estuve full con ${projectName}.` : ""),
|
|
645
|
+
)
|
|
442
646
|
.replaceAll("{{userNotesSection}}", para((userNotes ?? "").trim()))
|
|
443
|
-
.replaceAll(
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
647
|
+
.replaceAll(
|
|
648
|
+
"{{progressSection}}",
|
|
649
|
+
para(
|
|
650
|
+
includeFacts && facts?.progress_excerpt?.length
|
|
651
|
+
? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n")
|
|
652
|
+
: "",
|
|
653
|
+
),
|
|
654
|
+
)
|
|
655
|
+
.replaceAll(
|
|
656
|
+
"{{gitCommitsSection}}",
|
|
657
|
+
para(
|
|
658
|
+
includeFacts && facts?.git_commits?.length
|
|
659
|
+
? facts.git_commits.map((c: string) => `- ${c}`).join("\n")
|
|
660
|
+
: "",
|
|
661
|
+
),
|
|
662
|
+
);
|
|
447
663
|
const collapsed = filled.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
448
664
|
// Bare draft keeps the header's trailing blank line (matches legacy output);
|
|
449
665
|
// drafts with sections end right after the last one.
|
|
@@ -451,20 +667,37 @@ export function buildDraft({ issueId, projectName, userNotes, greeting, facts, i
|
|
|
451
667
|
return { issueId, markdown };
|
|
452
668
|
}
|
|
453
669
|
|
|
454
|
-
export function postUpdate(
|
|
670
|
+
export async function postUpdate(
|
|
671
|
+
{
|
|
672
|
+
confirmed,
|
|
673
|
+
issueId,
|
|
674
|
+
markdown,
|
|
675
|
+
minutes,
|
|
676
|
+
workspace_root,
|
|
677
|
+
}: {
|
|
678
|
+
confirmed: boolean;
|
|
679
|
+
issueId: string;
|
|
680
|
+
markdown: string;
|
|
681
|
+
minutes?: number;
|
|
682
|
+
workspace_root?: string;
|
|
683
|
+
},
|
|
684
|
+
operations?: Record<string, any>,
|
|
685
|
+
): Promise<Record<string, any>> {
|
|
455
686
|
operations ??= {};
|
|
456
687
|
if (!confirmed) return { error: "confirmed: true required" };
|
|
457
688
|
if (!issueId || !ISSUE_RE.test(issueId)) return { error: "invalid issueId" };
|
|
458
689
|
if (!markdown?.trim()) return { error: "markdown required" };
|
|
459
690
|
|
|
460
|
-
const postComment =
|
|
461
|
-
|
|
691
|
+
const postComment =
|
|
692
|
+
operations.postComment ??
|
|
693
|
+
((id: string, text: string, _root: string) =>
|
|
694
|
+
youTrackApi(["post-comment", id, text], process.env.WORKFLOW_YT_WRITE ?? ""));
|
|
462
695
|
const logTimeOperation = operations.logTime ?? logTime;
|
|
463
|
-
const comment = postComment(issueId, markdown, workspace_root);
|
|
696
|
+
const comment = await postComment(issueId, markdown, workspace_root);
|
|
464
697
|
if (comment.error) return { error: comment.error };
|
|
465
698
|
|
|
466
699
|
if (minutes && minutes > 0) {
|
|
467
|
-
const time = logTimeOperation({
|
|
700
|
+
const time = await logTimeOperation({
|
|
468
701
|
issueId,
|
|
469
702
|
minutes,
|
|
470
703
|
text: "workit update",
|