ai-foreman 1.3.1 → 1.4.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/README.md +8 -0
- package/dist/branch/git.d.ts +2 -0
- package/dist/branch/git.js +18 -1
- package/dist/branch/github.d.ts +3 -1
- package/dist/branch/github.js +32 -0
- package/dist/branch/gitlab.d.ts +15 -0
- package/dist/branch/gitlab.js +290 -0
- package/dist/branch/planner.d.ts +1 -0
- package/dist/branch/planner.js +3 -1
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +7 -1
- package/dist/branch/runner.d.ts +6 -1
- package/dist/branch/runner.js +155 -20
- package/dist/branch/types.d.ts +10 -2
- package/dist/cli/start.js +135 -7
- package/dist/cli/tickets.d.ts +11 -0
- package/dist/cli/tickets.js +717 -86
- package/dist/log.d.ts +1 -1
- package/dist/tickets/commands.d.ts +2 -0
- package/dist/tickets/commands.js +27 -0
- package/dist/tickets/importer.d.ts +55 -0
- package/dist/tickets/importer.js +480 -0
- package/dist/tickets/recommendations.d.ts +32 -0
- package/dist/tickets/recommendations.js +183 -0
- package/dist/tickets/renderMarkdown.js +23 -0
- package/dist/tickets/setupConfig.d.ts +77 -0
- package/dist/tickets/setupConfig.js +369 -0
- package/dist/tickets/stateDb.d.ts +17 -0
- package/dist/tickets/stateDb.js +46 -0
- package/dist/tickets/ticketSchema.d.ts +55 -0
- package/dist/tickets/ticketSchema.js +14 -0
- package/package.json +2 -2
package/dist/cli/tickets.js
CHANGED
|
@@ -2,17 +2,18 @@ import { Command } from "commander";
|
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { parse as parseYaml } from "yaml";
|
|
5
|
-
import { select, isCancel } from "@clack/prompts";
|
|
5
|
+
import { select, text, confirm, isCancel } from "@clack/prompts";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { Log } from "../log.js";
|
|
8
8
|
import { Foreman } from "../foreman.js";
|
|
9
9
|
import { printEvents } from "./events.js";
|
|
10
|
-
import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdArchive, } from "../tickets/commands.js";
|
|
10
|
+
import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdArchive, cmdReview, } from "../tickets/commands.js";
|
|
11
11
|
import { DEFAULT_TICKETS_CONFIG, isTicketsInitialized, loadTicketsConfig } from "../tickets/config.js";
|
|
12
12
|
import { validateDocsRoot } from "../tickets/config.js";
|
|
13
|
-
import { importFromMarkdown } from "../tickets/importer.js";
|
|
13
|
+
import { importExternalSources, importFromMarkdown, validateExternalSourceAccess } from "../tickets/importer.js";
|
|
14
14
|
import { formatValidationIssues } from "../tickets/validate.js";
|
|
15
15
|
import { assertEffortLevel, createRoleBuilder, makeLogPath, } from "../agentRun.js";
|
|
16
|
+
import { DEFAULT_TICKET_SETUP, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, } from "../tickets/setupConfig.js";
|
|
16
17
|
function fail(msg) {
|
|
17
18
|
console.error(`foreman tickets: ${msg}`);
|
|
18
19
|
process.exit(1);
|
|
@@ -89,6 +90,10 @@ STEP_STATUS: blocked | reason="why ticket population cannot proceed"`;
|
|
|
89
90
|
export function resolvePopulateSources(projectDir, explicitSources, ticketsConfig) {
|
|
90
91
|
if (explicitSources && explicitSources.length > 0)
|
|
91
92
|
return explicitSources;
|
|
93
|
+
const setup = loadTicketSetupConfig(projectDir);
|
|
94
|
+
const savedLocalSources = localSourcePaths(setup);
|
|
95
|
+
if (savedLocalSources.length > 0)
|
|
96
|
+
return savedLocalSources;
|
|
92
97
|
const rafiPlan = resolveConfiguredRafiPlanSource(projectDir, "rafi-config.yaml");
|
|
93
98
|
if (rafiPlan)
|
|
94
99
|
return [rafiPlan];
|
|
@@ -129,8 +134,644 @@ export function buildPopulateAgentRunOptions(opts) {
|
|
|
129
134
|
log: opts.log,
|
|
130
135
|
};
|
|
131
136
|
}
|
|
137
|
+
async function cmdSetupInitCli(opts) {
|
|
138
|
+
const dir = cwd(opts);
|
|
139
|
+
if (!existsSync(dir))
|
|
140
|
+
fail(`project directory not found: ${dir}`);
|
|
141
|
+
if (hasTicketSetupConfig(dir)) {
|
|
142
|
+
console.log("foreman tickets setup: existing setup found; opening setup:update");
|
|
143
|
+
await cmdSetupUpdateCli(opts);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const answers = await collectTicketSetup(dir, opts, undefined);
|
|
147
|
+
ensureRafiConfigForTicketSetup(dir, {
|
|
148
|
+
appName: opts.appName ?? defaultAppName(dir),
|
|
149
|
+
docsRoot: opts.docsRoot,
|
|
150
|
+
targets: parseRuntimeTargets(opts.runtime),
|
|
151
|
+
});
|
|
152
|
+
saveTicketSetupConfig(dir, answers, {
|
|
153
|
+
appName: opts.appName ?? defaultAppName(dir),
|
|
154
|
+
docsRoot: opts.docsRoot,
|
|
155
|
+
targets: parseRuntimeTargets(opts.runtime),
|
|
156
|
+
});
|
|
157
|
+
console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
158
|
+
await validateConfiguredSourcesIfRequested(answers, Boolean(opts.skipAccessCheck));
|
|
159
|
+
if (!isTicketsInitialized(dir)) {
|
|
160
|
+
cmdInit(dir, {
|
|
161
|
+
appName: opts.appName ?? defaultAppName(dir),
|
|
162
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
163
|
+
docsRoot: opts.docsRoot,
|
|
164
|
+
});
|
|
165
|
+
console.log("foreman tickets setup: initialized .tickets/");
|
|
166
|
+
}
|
|
167
|
+
if (shouldPrompt(opts)) {
|
|
168
|
+
const populate = await confirm({
|
|
169
|
+
message: "Run ticket population now?",
|
|
170
|
+
initialValue: answers.sources.length > 0,
|
|
171
|
+
});
|
|
172
|
+
if (isCancel(populate))
|
|
173
|
+
process.exit(0);
|
|
174
|
+
if (populate) {
|
|
175
|
+
await cmdPopulateCli({ project: dir, yes: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
console.log(`foreman tickets setup: next — run \`foreman tickets populate --project ${shellQuote(dir)}\``);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function cmdSetupUpdateCli(opts) {
|
|
183
|
+
const dir = cwd(opts);
|
|
184
|
+
if (!existsSync(dir))
|
|
185
|
+
fail(`project directory not found: ${dir}`);
|
|
186
|
+
const current = loadTicketSetupConfigWithDefaults(dir);
|
|
187
|
+
const patch = await collectTicketSetupPatch(dir, opts, current);
|
|
188
|
+
const next = mergeTicketSetup(current, patch);
|
|
189
|
+
ensureRafiConfigForTicketSetup(dir, {
|
|
190
|
+
appName: opts.appName ?? defaultAppName(dir),
|
|
191
|
+
docsRoot: opts.docsRoot,
|
|
192
|
+
targets: parseRuntimeTargets(opts.runtime),
|
|
193
|
+
});
|
|
194
|
+
saveTicketSetupConfig(dir, next, {
|
|
195
|
+
appName: opts.appName ?? defaultAppName(dir),
|
|
196
|
+
docsRoot: opts.docsRoot,
|
|
197
|
+
targets: parseRuntimeTargets(opts.runtime),
|
|
198
|
+
});
|
|
199
|
+
console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
200
|
+
await validateConfiguredSourcesIfRequested(next, Boolean(opts.skipAccessCheck));
|
|
201
|
+
if (isTicketsInitialized(dir)) {
|
|
202
|
+
const rows = cmdQueue(dir, 1);
|
|
203
|
+
if (rows.length === 0) {
|
|
204
|
+
console.log(`foreman tickets setup: queue is empty; run \`foreman tickets populate --project ${shellQuote(dir)}\` to import or refresh tickets.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function collectTicketSetup(dir, opts, current) {
|
|
209
|
+
const patch = await collectTicketSetupPatch(dir, opts, current ?? DEFAULT_TICKET_SETUP);
|
|
210
|
+
const build = patch.build ?? {};
|
|
211
|
+
return mergeTicketSetup(current, {
|
|
212
|
+
...patch,
|
|
213
|
+
build: Object.keys(build).length > 0 ? build : recommendedBuildDefaults(dir),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async function collectTicketSetupPatch(dir, opts, current) {
|
|
217
|
+
const nonInteractiveSources = sourcesFromSetupOptions(opts);
|
|
218
|
+
const populatePatch = {};
|
|
219
|
+
const buildPatch = {};
|
|
220
|
+
if (opts.agentPreference)
|
|
221
|
+
populatePatch.agent_preference = parseAgentPreference(opts.agentPreference);
|
|
222
|
+
if (opts.completion)
|
|
223
|
+
buildPatch.completion = parseCompletionMode(opts.completion);
|
|
224
|
+
if (opts.provider)
|
|
225
|
+
buildPatch.provider = parseProvider(opts.provider);
|
|
226
|
+
if (opts.autoMergeWait !== undefined)
|
|
227
|
+
buildPatch.auto_merge_wait = Boolean(opts.autoMergeWait);
|
|
228
|
+
if (opts.autoMergeTimeoutMinutes !== undefined) {
|
|
229
|
+
buildPatch.auto_merge_timeout_minutes = parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes");
|
|
230
|
+
}
|
|
231
|
+
if (!shouldPrompt(opts)) {
|
|
232
|
+
return {
|
|
233
|
+
sources: nonInteractiveSources.length > 0 ? nonInteractiveSources : current.sources,
|
|
234
|
+
populate: populatePatch,
|
|
235
|
+
build: Object.keys(buildPatch).length > 0 ? buildPatch : {},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const section = await select({
|
|
239
|
+
message: "Which ticket setup section should be configured?",
|
|
240
|
+
options: [
|
|
241
|
+
{ value: "sources", label: "Ticket sources" },
|
|
242
|
+
{ value: "populate", label: "Populate defaults" },
|
|
243
|
+
{ value: "build", label: "Build defaults" },
|
|
244
|
+
{ value: "all", label: "All sections" },
|
|
245
|
+
],
|
|
246
|
+
});
|
|
247
|
+
if (isCancel(section))
|
|
248
|
+
process.exit(0);
|
|
249
|
+
let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : current.sources;
|
|
250
|
+
if (section === "sources" || section === "all") {
|
|
251
|
+
sources = await promptTicketSources(dir, current.sources);
|
|
252
|
+
}
|
|
253
|
+
if (section === "populate" || section === "all") {
|
|
254
|
+
const agent = await select({
|
|
255
|
+
message: "When both runtimes are configured, which should populate use?",
|
|
256
|
+
initialValue: current.populate.agent_preference,
|
|
257
|
+
options: [
|
|
258
|
+
{ value: "configured", label: "Configured project default" },
|
|
259
|
+
{ value: "claude", label: "Claude" },
|
|
260
|
+
{ value: "codex", label: "Codex" },
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
if (isCancel(agent))
|
|
264
|
+
process.exit(0);
|
|
265
|
+
populatePatch.agent_preference = agent;
|
|
266
|
+
}
|
|
267
|
+
if (section === "build" || section === "all") {
|
|
268
|
+
const recommended = recommendedBuildDefaults(dir);
|
|
269
|
+
const completion = await select({
|
|
270
|
+
message: "Default completion behavior for branch ticket runs:",
|
|
271
|
+
initialValue: current.build.completion === "none" ? recommended.completion : current.build.completion,
|
|
272
|
+
options: [
|
|
273
|
+
{ value: "auto-merge", label: "PR/MR auto-merge, squash when checks pass" },
|
|
274
|
+
{ value: "pr", label: "Create PR/MR only" },
|
|
275
|
+
{ value: "direct-merge", label: "Direct local squash merge" },
|
|
276
|
+
{ value: "none", label: "No branch completion action" },
|
|
277
|
+
],
|
|
278
|
+
});
|
|
279
|
+
if (isCancel(completion))
|
|
280
|
+
process.exit(0);
|
|
281
|
+
buildPatch.branch_strategy = "branch-per-ticket";
|
|
282
|
+
buildPatch.completion = completion;
|
|
283
|
+
buildPatch.provider = recommended.provider;
|
|
284
|
+
buildPatch.pr_ready = completion === "auto-merge";
|
|
285
|
+
buildPatch.merge_method = "squash";
|
|
286
|
+
buildPatch.cleanup = true;
|
|
287
|
+
if (completion === "auto-merge") {
|
|
288
|
+
const wait = await confirm({
|
|
289
|
+
message: "Wait for dependency PR/MRs to merge before starting dependent tickets?",
|
|
290
|
+
initialValue: current.build.auto_merge_wait,
|
|
291
|
+
});
|
|
292
|
+
if (isCancel(wait))
|
|
293
|
+
process.exit(0);
|
|
294
|
+
buildPatch.auto_merge_wait = Boolean(wait);
|
|
295
|
+
if (wait) {
|
|
296
|
+
const timeout = await text({
|
|
297
|
+
message: "Auto-merge dependency wait timeout in minutes (blank for no timeout):",
|
|
298
|
+
initialValue: current.build.auto_merge_timeout_minutes === null ? "" : String(current.build.auto_merge_timeout_minutes),
|
|
299
|
+
defaultValue: "",
|
|
300
|
+
validate: (value) => {
|
|
301
|
+
const textValue = String(value ?? "").trim();
|
|
302
|
+
if (!textValue)
|
|
303
|
+
return undefined;
|
|
304
|
+
const parsed = Number.parseInt(textValue, 10);
|
|
305
|
+
return Number.isInteger(parsed) && parsed > 0 ? undefined : "Enter a positive integer or leave blank";
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
if (isCancel(timeout))
|
|
309
|
+
process.exit(0);
|
|
310
|
+
buildPatch.auto_merge_timeout_minutes = String(timeout).trim()
|
|
311
|
+
? Number.parseInt(String(timeout).trim(), 10)
|
|
312
|
+
: null;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
buildPatch.auto_merge_timeout_minutes = null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
buildPatch.auto_merge_wait = false;
|
|
320
|
+
buildPatch.auto_merge_timeout_minutes = null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
sources,
|
|
325
|
+
populate: populatePatch,
|
|
326
|
+
build: buildPatch,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async function promptTicketSources(dir, current) {
|
|
330
|
+
const kind = await select({
|
|
331
|
+
message: "Primary ticket source:",
|
|
332
|
+
options: [
|
|
333
|
+
{ value: "local", label: "Local docs, files, folders, or globs" },
|
|
334
|
+
{ value: "linear", label: "Linear" },
|
|
335
|
+
{ value: "jira", label: "Jira Cloud" },
|
|
336
|
+
{ value: "none", label: "No saved source" },
|
|
337
|
+
],
|
|
338
|
+
});
|
|
339
|
+
if (isCancel(kind))
|
|
340
|
+
process.exit(0);
|
|
341
|
+
if (kind === "none")
|
|
342
|
+
return [];
|
|
343
|
+
if (kind === "local") {
|
|
344
|
+
const existing = current.find((source) => source.type === "local");
|
|
345
|
+
const answer = await text({
|
|
346
|
+
message: "Local source paths or globs, comma-separated:",
|
|
347
|
+
initialValue: existing?.paths.join(", ") || `${configuredDocsPlanPath(dir)}`,
|
|
348
|
+
defaultValue: existing?.paths.join(", ") || `${configuredDocsPlanPath(dir)}`,
|
|
349
|
+
});
|
|
350
|
+
if (isCancel(answer))
|
|
351
|
+
process.exit(0);
|
|
352
|
+
return [{ type: "local", paths: splitCommaList(String(answer)) }];
|
|
353
|
+
}
|
|
354
|
+
if (kind === "linear") {
|
|
355
|
+
const team = await text({ message: "Linear team key (optional):" });
|
|
356
|
+
if (isCancel(team))
|
|
357
|
+
process.exit(0);
|
|
358
|
+
const filter = await text({ message: "Linear IssueFilter JSON or title search text (optional):" });
|
|
359
|
+
if (isCancel(filter))
|
|
360
|
+
process.exit(0);
|
|
361
|
+
return [{
|
|
362
|
+
type: "linear",
|
|
363
|
+
api_key_env: "LINEAR_API_KEY",
|
|
364
|
+
team_key: String(team).trim() || null,
|
|
365
|
+
filter: String(filter).trim() || null,
|
|
366
|
+
}];
|
|
367
|
+
}
|
|
368
|
+
const site = await text({
|
|
369
|
+
message: "Jira Cloud site URL:",
|
|
370
|
+
placeholder: "https://your-domain.atlassian.net",
|
|
371
|
+
validate: (value) => String(value ?? "").trim() ? undefined : "Enter a Jira Cloud site URL",
|
|
372
|
+
});
|
|
373
|
+
if (isCancel(site))
|
|
374
|
+
process.exit(0);
|
|
375
|
+
const jql = await text({
|
|
376
|
+
message: "Jira JQL:",
|
|
377
|
+
initialValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
|
|
378
|
+
defaultValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
|
|
379
|
+
});
|
|
380
|
+
if (isCancel(jql))
|
|
381
|
+
process.exit(0);
|
|
382
|
+
return [{
|
|
383
|
+
type: "jira",
|
|
384
|
+
site: String(site).trim(),
|
|
385
|
+
email_env: "JIRA_EMAIL",
|
|
386
|
+
token_env: "JIRA_API_TOKEN",
|
|
387
|
+
jql: String(jql).trim(),
|
|
388
|
+
}];
|
|
389
|
+
}
|
|
390
|
+
function sourcesFromSetupOptions(opts) {
|
|
391
|
+
const sources = [];
|
|
392
|
+
if (opts.localSource?.length)
|
|
393
|
+
sources.push({ type: "local", paths: opts.localSource });
|
|
394
|
+
if (opts.linear || opts.linearTeamKey || opts.linearFilter) {
|
|
395
|
+
sources.push({
|
|
396
|
+
type: "linear",
|
|
397
|
+
api_key_env: "LINEAR_API_KEY",
|
|
398
|
+
team_key: opts.linearTeamKey ?? null,
|
|
399
|
+
filter: opts.linearFilter ?? null,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (opts.jiraSite || opts.jiraJql) {
|
|
403
|
+
if (!opts.jiraSite || !opts.jiraJql)
|
|
404
|
+
fail("--jira-site and --jira-jql must be passed together");
|
|
405
|
+
sources.push({
|
|
406
|
+
type: "jira",
|
|
407
|
+
site: opts.jiraSite,
|
|
408
|
+
email_env: "JIRA_EMAIL",
|
|
409
|
+
token_env: "JIRA_API_TOKEN",
|
|
410
|
+
jql: opts.jiraJql,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return sources;
|
|
414
|
+
}
|
|
415
|
+
async function validateConfiguredSourcesIfRequested(setup, skip) {
|
|
416
|
+
if (skip)
|
|
417
|
+
return;
|
|
418
|
+
for (const source of externalSources(setup)) {
|
|
419
|
+
try {
|
|
420
|
+
await validateExternalSourceAccess(source);
|
|
421
|
+
console.log(`foreman tickets setup: validated ${source.type} access`);
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
console.log(`foreman tickets setup: ${source.type} access check skipped/failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function shouldPrompt(opts) {
|
|
429
|
+
return !opts.defaults && !opts.yes && process.stdin.isTTY && process.stdout.isTTY;
|
|
430
|
+
}
|
|
431
|
+
function parseRuntimeTargets(value) {
|
|
432
|
+
if (!value)
|
|
433
|
+
return undefined;
|
|
434
|
+
if (value === "both")
|
|
435
|
+
return ["claude", "codex"];
|
|
436
|
+
if (value === "claude" || value === "codex")
|
|
437
|
+
return [value];
|
|
438
|
+
fail("--runtime must be one of: both, claude, codex");
|
|
439
|
+
}
|
|
440
|
+
function parseCompletionMode(value) {
|
|
441
|
+
if (["pr", "auto-merge", "direct-merge", "none"].includes(value))
|
|
442
|
+
return value;
|
|
443
|
+
fail("--completion must be one of: pr, auto-merge, direct-merge, none");
|
|
444
|
+
}
|
|
445
|
+
function parseProvider(value) {
|
|
446
|
+
if (["auto", "github", "gitlab", "local"].includes(value))
|
|
447
|
+
return value;
|
|
448
|
+
fail("--provider must be one of: auto, github, gitlab, local");
|
|
449
|
+
}
|
|
450
|
+
function parseAgentPreference(value) {
|
|
451
|
+
if (["configured", "claude", "codex"].includes(value))
|
|
452
|
+
return value;
|
|
453
|
+
fail("--agent-preference must be one of: configured, claude, codex");
|
|
454
|
+
}
|
|
455
|
+
function parseOptionalPositiveInteger(value, label) {
|
|
456
|
+
if (!value.trim())
|
|
457
|
+
return null;
|
|
458
|
+
const parsed = Number.parseInt(value, 10);
|
|
459
|
+
if (Number.isInteger(parsed) && parsed > 0)
|
|
460
|
+
return parsed;
|
|
461
|
+
fail(`${label} must be a positive integer or blank`);
|
|
462
|
+
}
|
|
463
|
+
function splitCommaList(value) {
|
|
464
|
+
return value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
465
|
+
}
|
|
466
|
+
function shellQuote(value) {
|
|
467
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
|
|
468
|
+
return value;
|
|
469
|
+
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
|
470
|
+
}
|
|
471
|
+
function configuredDocsPlanPath(dir) {
|
|
472
|
+
const raw = loadTicketSetupConfig(dir);
|
|
473
|
+
const local = localSourcePaths(raw)[0];
|
|
474
|
+
if (local)
|
|
475
|
+
return local;
|
|
476
|
+
const configPath = join(dir, "rafi-config.yaml");
|
|
477
|
+
if (existsSync(configPath)) {
|
|
478
|
+
const parsed = parseYaml(readFileSync(configPath, "utf8"));
|
|
479
|
+
const docs = parsed?.docs;
|
|
480
|
+
if (typeof docs?.root === "string")
|
|
481
|
+
return `${docs.root}/rafi-plan.md`;
|
|
482
|
+
}
|
|
483
|
+
return "docs/rafi-plan.md";
|
|
484
|
+
}
|
|
485
|
+
async function resolveInitAppName(dir, yes) {
|
|
486
|
+
const configured = defaultAppName(dir);
|
|
487
|
+
if (configured !== "My App")
|
|
488
|
+
return configured;
|
|
489
|
+
if (yes || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
490
|
+
return configured;
|
|
491
|
+
const answer = await text({
|
|
492
|
+
message: "App name:",
|
|
493
|
+
initialValue: configured,
|
|
494
|
+
defaultValue: configured,
|
|
495
|
+
});
|
|
496
|
+
if (isCancel(answer))
|
|
497
|
+
process.exit(0);
|
|
498
|
+
return String(answer).trim() || configured;
|
|
499
|
+
}
|
|
500
|
+
async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, yes) {
|
|
501
|
+
if (explicitSources?.length)
|
|
502
|
+
return explicitSources;
|
|
503
|
+
const setup = loadTicketSetupConfig(dir);
|
|
504
|
+
const saved = localSourcePaths(setup);
|
|
505
|
+
if (saved.length > 0)
|
|
506
|
+
return saved;
|
|
507
|
+
const defaultPlan = resolvePopulateSources(dir, undefined, ticketsConfig);
|
|
508
|
+
if (defaultPlan?.length) {
|
|
509
|
+
if (yes || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
510
|
+
return defaultPlan;
|
|
511
|
+
const usePlan = await confirm({
|
|
512
|
+
message: `Use ${defaultPlan.join(", ")} as the ticket population source?`,
|
|
513
|
+
initialValue: true,
|
|
514
|
+
});
|
|
515
|
+
if (isCancel(usePlan))
|
|
516
|
+
process.exit(0);
|
|
517
|
+
return usePlan ? defaultPlan : undefined;
|
|
518
|
+
}
|
|
519
|
+
if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
520
|
+
console.log("foreman tickets: no ticket sources were found.");
|
|
521
|
+
console.log("foreman tickets: options:");
|
|
522
|
+
console.log(" rafi plan .");
|
|
523
|
+
console.log(" rafi tickets setup:init");
|
|
524
|
+
console.log(" rafi tickets populate --sources <files-or-globs>");
|
|
525
|
+
console.log(" edit .tickets/tickets.yaml manually");
|
|
526
|
+
process.exit(2);
|
|
527
|
+
}
|
|
528
|
+
const action = await select({
|
|
529
|
+
message: "No ticket sources were found. What should happen next?",
|
|
530
|
+
options: [
|
|
531
|
+
{ value: "plan", label: "Run planner first - rafi plan ." },
|
|
532
|
+
{ value: "setup", label: "Configure ticket setup" },
|
|
533
|
+
{ value: "manual", label: "Enter source paths now" },
|
|
534
|
+
{ value: "none", label: "Do nothing" },
|
|
535
|
+
],
|
|
536
|
+
});
|
|
537
|
+
if (isCancel(action) || action === "none") {
|
|
538
|
+
console.log("foreman tickets: cancelled");
|
|
539
|
+
process.exit(0);
|
|
540
|
+
}
|
|
541
|
+
if (action === "setup") {
|
|
542
|
+
await cmdSetupInitCli({ project: dir });
|
|
543
|
+
process.exit(0);
|
|
544
|
+
}
|
|
545
|
+
if (action === "plan") {
|
|
546
|
+
console.log(`foreman tickets: run \`rafi plan ${shellQuote(dir)}\`, then \`rafi tickets populate --project ${shellQuote(dir)}\`.`);
|
|
547
|
+
process.exit(0);
|
|
548
|
+
}
|
|
549
|
+
const entered = await text({
|
|
550
|
+
message: "Source paths or globs, comma-separated:",
|
|
551
|
+
validate: (value) => String(value ?? "").trim() ? undefined : "Enter at least one source path or glob",
|
|
552
|
+
});
|
|
553
|
+
if (isCancel(entered))
|
|
554
|
+
process.exit(0);
|
|
555
|
+
return splitCommaList(String(entered));
|
|
556
|
+
}
|
|
557
|
+
function reviewActionFromOptions(opts) {
|
|
558
|
+
const selected = [
|
|
559
|
+
opts.accept ? "accept" : null,
|
|
560
|
+
opts.dismiss ? "dismiss" : null,
|
|
561
|
+
opts.defer ? "defer" : null,
|
|
562
|
+
opts.acceptAll ? "acceptAll" : null,
|
|
563
|
+
opts.dismissAll ? "dismissAll" : null,
|
|
564
|
+
opts.deferAll ? "deferAll" : null,
|
|
565
|
+
].filter(Boolean);
|
|
566
|
+
if (selected.length === 0)
|
|
567
|
+
return undefined;
|
|
568
|
+
if (selected.length > 1)
|
|
569
|
+
fail("choose only one review action");
|
|
570
|
+
const raw = selected[0];
|
|
571
|
+
const all = raw.endsWith("All");
|
|
572
|
+
const action = raw.replace(/All$/, "");
|
|
573
|
+
if (!all && opts.id === undefined)
|
|
574
|
+
fail("--id <n> is required unless using an all action");
|
|
575
|
+
const id = opts.id !== undefined ? Number(opts.id) : undefined;
|
|
576
|
+
if (id !== undefined && (!Number.isInteger(id) || id < 1))
|
|
577
|
+
fail("--id must be a positive integer");
|
|
578
|
+
return { action, all, id };
|
|
579
|
+
}
|
|
580
|
+
export async function cmdPopulateCli(opts) {
|
|
581
|
+
const dir = cwd(opts);
|
|
582
|
+
validateEffort(opts.effort);
|
|
583
|
+
if (!existsSync(dir))
|
|
584
|
+
fail(`project directory not found: ${dir}`);
|
|
585
|
+
if (!isTicketsInitialized(dir)) {
|
|
586
|
+
fail(`ticket tracker is not initialized in ${dir}; run \`foreman tickets init --project ${dir}\` first`);
|
|
587
|
+
}
|
|
588
|
+
const ticketsConfig = loadTicketsConfig(dir);
|
|
589
|
+
const setup = loadTicketSetupConfig(dir);
|
|
590
|
+
const explicitSources = opts.sources;
|
|
591
|
+
const configuredExternalSources = explicitSources?.length ? [] : externalSources(setup);
|
|
592
|
+
const savedLocalSources = explicitSources?.length ? [] : localSourcePaths(setup);
|
|
593
|
+
const sourceHints = configuredExternalSources.length > 0 && savedLocalSources.length === 0
|
|
594
|
+
? undefined
|
|
595
|
+
: await resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, Boolean(opts.yes));
|
|
596
|
+
const populateAgent = opts.agent
|
|
597
|
+
?? (setup?.populate.agent_preference && setup.populate.agent_preference !== "configured"
|
|
598
|
+
? setup.populate.agent_preference
|
|
599
|
+
: undefined);
|
|
600
|
+
if (configuredExternalSources.length > 0) {
|
|
601
|
+
if (!opts.yes) {
|
|
602
|
+
const action = await select({
|
|
603
|
+
message: `Import ${configuredExternalSources.length} configured external ticket source(s)?`,
|
|
604
|
+
options: [
|
|
605
|
+
{ value: "proceed", label: "Proceed - fetch external tickets and update .tickets" },
|
|
606
|
+
{ value: "cancel", label: "Cancel" },
|
|
607
|
+
],
|
|
608
|
+
});
|
|
609
|
+
if (isCancel(action) || action === "cancel") {
|
|
610
|
+
console.log("foreman tickets: cancelled");
|
|
611
|
+
process.exit(0);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const results = await importExternalSources(dir, configuredExternalSources, {
|
|
615
|
+
importCap: setup?.populate.import_cap ?? DEFAULT_TICKET_SETUP.populate.import_cap,
|
|
616
|
+
commentLimit: setup?.populate.comment_limit ?? DEFAULT_TICKET_SETUP.populate.comment_limit,
|
|
617
|
+
recommendSplitForXl: setup?.populate.recommend_split_for_xl ?? DEFAULT_TICKET_SETUP.populate.recommend_split_for_xl,
|
|
618
|
+
});
|
|
619
|
+
for (const result of results) {
|
|
620
|
+
console.log(`foreman tickets: imported ${result.fetched} ${result.provider} item(s) from ${result.sourceLabel} (${result.created} created, ${result.updated} updated)`);
|
|
621
|
+
console.log(`foreman tickets: snapshot ${result.snapshotPath}`);
|
|
622
|
+
}
|
|
623
|
+
cmdRender(dir);
|
|
624
|
+
const validation = cmdValidate(dir);
|
|
625
|
+
if (validation.issues.length > 0) {
|
|
626
|
+
console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
|
|
627
|
+
console.log(formatValidationIssues(validation.issues));
|
|
628
|
+
if (!validation.clean)
|
|
629
|
+
process.exit(1);
|
|
630
|
+
}
|
|
631
|
+
if (!sourceHints?.length) {
|
|
632
|
+
console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (!opts.yes) {
|
|
637
|
+
const action = await select({
|
|
638
|
+
message: "Populate .tickets/tickets.yaml by letting the builder edit this project?",
|
|
639
|
+
options: [
|
|
640
|
+
{ value: "proceed", label: "Proceed - builder may edit ticket files" },
|
|
641
|
+
{ value: "cancel", label: "Cancel" },
|
|
642
|
+
],
|
|
643
|
+
});
|
|
644
|
+
if (isCancel(action) || action === "cancel") {
|
|
645
|
+
console.log("foreman tickets: cancelled");
|
|
646
|
+
process.exit(0);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const logPath = makeLogPath(dir, "tickets-populate");
|
|
650
|
+
const log = new Log(logPath);
|
|
651
|
+
const config = loadConfig(join(dir, "foreman.yaml"));
|
|
652
|
+
let builder;
|
|
653
|
+
let viewer;
|
|
654
|
+
try {
|
|
655
|
+
const roleBuilder = await createRoleBuilder(buildPopulateAgentRunOptions({
|
|
656
|
+
projectDir: dir,
|
|
657
|
+
agent: populateAgent,
|
|
658
|
+
model: opts.model,
|
|
659
|
+
effort: opts.effort,
|
|
660
|
+
fast: opts.fast,
|
|
661
|
+
yes: Boolean(opts.yes),
|
|
662
|
+
log,
|
|
663
|
+
}));
|
|
664
|
+
builder = roleBuilder.builder;
|
|
665
|
+
viewer = printEvents(builder.events());
|
|
666
|
+
const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
|
|
667
|
+
console.log(`foreman tickets: populating tickets with ${roleBuilder.runtime}`);
|
|
668
|
+
console.log(`foreman tickets: project ${dir}`);
|
|
669
|
+
console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
|
|
670
|
+
console.log(`foreman tickets: log ${logPath}\n`);
|
|
671
|
+
if (sourceHints?.length) {
|
|
672
|
+
console.log(`foreman tickets: source hints ${sourceHints.join(" ")}`);
|
|
673
|
+
}
|
|
674
|
+
const turn = await foreman.runInstruction(buildPopulateInstruction(sourceHints, ticketsConfig.paths.progressDoc));
|
|
675
|
+
await builder.close();
|
|
676
|
+
await viewer;
|
|
677
|
+
log.write("ticket-populate", {
|
|
678
|
+
statusKind: turn.status.kind,
|
|
679
|
+
summary: turn.status.summary,
|
|
680
|
+
reason: turn.status.reason,
|
|
681
|
+
costUsd: turn.result.costUsd,
|
|
682
|
+
isError: turn.result.isError,
|
|
683
|
+
});
|
|
684
|
+
if (turn.result.isError) {
|
|
685
|
+
fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
|
|
686
|
+
}
|
|
687
|
+
if (turn.status.kind === "blocked") {
|
|
688
|
+
console.error(`foreman tickets: blocked — ${turn.status.reason ?? "builder reported blocked"}`);
|
|
689
|
+
process.exit(2);
|
|
690
|
+
}
|
|
691
|
+
if (turn.status.kind !== "done" && turn.status.kind !== "plan_complete") {
|
|
692
|
+
console.error(`foreman tickets: needs human — ${turn.status.error ?? "builder did not emit done"}`);
|
|
693
|
+
process.exit(2);
|
|
694
|
+
}
|
|
695
|
+
cmdRender(dir);
|
|
696
|
+
const validation = cmdValidate(dir);
|
|
697
|
+
if (validation.issues.length > 0) {
|
|
698
|
+
console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
|
|
699
|
+
console.log(formatValidationIssues(validation.issues));
|
|
700
|
+
if (!validation.clean)
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
console.log("foreman tickets: validation passed — all 4 passes clean");
|
|
705
|
+
}
|
|
706
|
+
console.log(`foreman tickets: populated .tickets/tickets.yaml and rendered ${ticketsConfig.paths.progressDoc}`);
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
await builder?.close().catch(() => { });
|
|
710
|
+
await viewer?.catch(() => { });
|
|
711
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
132
714
|
export function buildTicketsCommand() {
|
|
133
715
|
const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
|
|
716
|
+
// ── setup:init / setup:update ─────────────────────────────────────────────
|
|
717
|
+
tickets
|
|
718
|
+
.command("setup:init")
|
|
719
|
+
.description("Configure ticket sources, populate defaults, and build defaults in rafi-config.yaml.")
|
|
720
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
721
|
+
.option("--defaults", "skip prompts and use recommended ticket setup defaults")
|
|
722
|
+
.option("-y, --yes", "skip prompts where possible")
|
|
723
|
+
.option("--app-name <name>", "application name for a new minimal rafi-config.yaml")
|
|
724
|
+
.option("--docs-root <dir>", "repo-relative docs root for a new minimal rafi-config.yaml and ticket docs")
|
|
725
|
+
.option("--runtime <runtime>", "runtime targets for a new minimal rafi-config.yaml (both | claude | codex)")
|
|
726
|
+
.option("--local-source <paths...>", "saved local ticket source files, folders, or globs")
|
|
727
|
+
.option("--linear", "add a Linear source using LINEAR_API_KEY")
|
|
728
|
+
.option("--linear-team-key <key>", "Linear team key filter")
|
|
729
|
+
.option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
|
|
730
|
+
.option("--jira-site <url>", "Jira Cloud site URL")
|
|
731
|
+
.option("--jira-jql <jql>", "Jira JQL query")
|
|
732
|
+
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
733
|
+
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
734
|
+
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
735
|
+
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
736
|
+
.option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
|
|
737
|
+
.option("--skip-access-check", "do not validate Linear/Jira access during setup")
|
|
738
|
+
.action(async (opts) => {
|
|
739
|
+
try {
|
|
740
|
+
await cmdSetupInitCli(opts);
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
tickets
|
|
747
|
+
.command("setup:update")
|
|
748
|
+
.description("Update selected ticket setup sections in rafi-config.yaml.")
|
|
749
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
750
|
+
.option("--defaults", "skip prompts and keep existing values unless explicit options are provided")
|
|
751
|
+
.option("-y, --yes", "skip prompts where possible")
|
|
752
|
+
.option("--app-name <name>", "application name for a new minimal rafi-config.yaml")
|
|
753
|
+
.option("--docs-root <dir>", "repo-relative docs root for a new minimal rafi-config.yaml and ticket docs")
|
|
754
|
+
.option("--runtime <runtime>", "runtime targets for a new minimal rafi-config.yaml (both | claude | codex)")
|
|
755
|
+
.option("--local-source <paths...>", "replace saved local ticket source files, folders, or globs")
|
|
756
|
+
.option("--linear", "replace saved sources with a Linear source using LINEAR_API_KEY")
|
|
757
|
+
.option("--linear-team-key <key>", "Linear team key filter")
|
|
758
|
+
.option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
|
|
759
|
+
.option("--jira-site <url>", "Jira Cloud site URL")
|
|
760
|
+
.option("--jira-jql <jql>", "Jira JQL query")
|
|
761
|
+
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
762
|
+
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
763
|
+
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
764
|
+
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
765
|
+
.option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
|
|
766
|
+
.option("--skip-access-check", "do not validate Linear/Jira access during setup")
|
|
767
|
+
.action(async (opts) => {
|
|
768
|
+
try {
|
|
769
|
+
await cmdSetupUpdateCli(opts);
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
773
|
+
}
|
|
774
|
+
});
|
|
134
775
|
// ── init ────────────────────────────────────────────────────────────────────
|
|
135
776
|
tickets
|
|
136
777
|
.command("init")
|
|
@@ -142,7 +783,8 @@ export function buildTicketsCommand() {
|
|
|
142
783
|
.option("--view-limit <n>", "ticket queue display limit", String(DEFAULT_TICKETS_CONFIG.viewLimit))
|
|
143
784
|
.option("--queue-limit <n>", "deprecated alias for --implementation-limit")
|
|
144
785
|
.option("--docs-root <dir>", "repo-relative directory for generated ticket docs")
|
|
145
|
-
.
|
|
786
|
+
.option("-y, --yes", "skip app-name prompt when no config/package default exists")
|
|
787
|
+
.action(async (opts, command) => {
|
|
146
788
|
const dir = cwd(opts);
|
|
147
789
|
try {
|
|
148
790
|
const hasImplementationLimit = optionWasProvided(command, "implementationLimit");
|
|
@@ -150,8 +792,9 @@ export function buildTicketsCommand() {
|
|
|
150
792
|
if (hasImplementationLimit && hasLegacyQueueLimit) {
|
|
151
793
|
fail("--implementation-limit and deprecated --queue-limit cannot both be passed");
|
|
152
794
|
}
|
|
795
|
+
const appName = opts.appName ?? await resolveInitAppName(dir, Boolean(opts.yes));
|
|
153
796
|
cmdInit(dir, {
|
|
154
|
-
appName
|
|
797
|
+
appName,
|
|
155
798
|
timezone: opts.timezone,
|
|
156
799
|
implementationLimit: hasImplementationLimit ? Number(opts.implementationLimit) : undefined,
|
|
157
800
|
viewLimit: optionWasProvided(command, "viewLimit") ? Number(opts.viewLimit) : undefined,
|
|
@@ -177,90 +820,10 @@ export function buildTicketsCommand() {
|
|
|
177
820
|
.option("--fast", "fast mode - lower latency")
|
|
178
821
|
.option("-y, --yes", "skip confirmation prompt before letting the builder edit tickets")
|
|
179
822
|
.action(async (opts) => {
|
|
180
|
-
const dir = cwd(opts);
|
|
181
|
-
validateEffort(opts.effort);
|
|
182
|
-
if (!existsSync(dir))
|
|
183
|
-
fail(`project directory not found: ${dir}`);
|
|
184
|
-
if (!isTicketsInitialized(dir)) {
|
|
185
|
-
fail(`ticket tracker is not initialized in ${dir}; run \`foreman tickets init --project ${dir}\` first`);
|
|
186
|
-
}
|
|
187
|
-
if (!opts.yes) {
|
|
188
|
-
const action = await select({
|
|
189
|
-
message: "Populate .tickets/tickets.yaml by letting the builder edit this project?",
|
|
190
|
-
options: [
|
|
191
|
-
{ value: "proceed", label: "Proceed - builder may edit ticket files" },
|
|
192
|
-
{ value: "cancel", label: "Cancel" },
|
|
193
|
-
],
|
|
194
|
-
});
|
|
195
|
-
if (isCancel(action) || action === "cancel") {
|
|
196
|
-
console.log("foreman tickets: cancelled");
|
|
197
|
-
process.exit(0);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
const logPath = makeLogPath(dir, "tickets-populate");
|
|
201
|
-
const log = new Log(logPath);
|
|
202
|
-
const config = loadConfig(join(dir, "foreman.yaml"));
|
|
203
|
-
let builder;
|
|
204
|
-
let viewer;
|
|
205
823
|
try {
|
|
206
|
-
|
|
207
|
-
projectDir: dir,
|
|
208
|
-
agent: opts.agent,
|
|
209
|
-
model: opts.model,
|
|
210
|
-
effort: opts.effort,
|
|
211
|
-
fast: opts.fast,
|
|
212
|
-
yes: Boolean(opts.yes),
|
|
213
|
-
log,
|
|
214
|
-
}));
|
|
215
|
-
builder = roleBuilder.builder;
|
|
216
|
-
viewer = printEvents(builder.events());
|
|
217
|
-
const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
|
|
218
|
-
console.log(`foreman tickets: populating tickets with ${roleBuilder.runtime}`);
|
|
219
|
-
console.log(`foreman tickets: project ${dir}`);
|
|
220
|
-
console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
|
|
221
|
-
console.log(`foreman tickets: log ${logPath}\n`);
|
|
222
|
-
const ticketsConfig = loadTicketsConfig(dir);
|
|
223
|
-
const sourceHints = resolvePopulateSources(dir, opts.sources, ticketsConfig);
|
|
224
|
-
if (sourceHints?.length) {
|
|
225
|
-
console.log(`foreman tickets: source hints ${sourceHints.join(" ")}`);
|
|
226
|
-
}
|
|
227
|
-
const turn = await foreman.runInstruction(buildPopulateInstruction(sourceHints, ticketsConfig.paths.progressDoc));
|
|
228
|
-
await builder.close();
|
|
229
|
-
await viewer;
|
|
230
|
-
log.write("ticket-populate", {
|
|
231
|
-
statusKind: turn.status.kind,
|
|
232
|
-
summary: turn.status.summary,
|
|
233
|
-
reason: turn.status.reason,
|
|
234
|
-
costUsd: turn.result.costUsd,
|
|
235
|
-
isError: turn.result.isError,
|
|
236
|
-
});
|
|
237
|
-
if (turn.result.isError) {
|
|
238
|
-
fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
|
|
239
|
-
}
|
|
240
|
-
if (turn.status.kind === "blocked") {
|
|
241
|
-
console.error(`foreman tickets: blocked — ${turn.status.reason ?? "builder reported blocked"}`);
|
|
242
|
-
process.exit(2);
|
|
243
|
-
}
|
|
244
|
-
if (turn.status.kind !== "done" && turn.status.kind !== "plan_complete") {
|
|
245
|
-
console.error(`foreman tickets: needs human — ${turn.status.error ?? "builder did not emit done"}`);
|
|
246
|
-
process.exit(2);
|
|
247
|
-
}
|
|
248
|
-
cmdRender(dir);
|
|
249
|
-
const validation = cmdValidate(dir);
|
|
250
|
-
if (validation.issues.length > 0) {
|
|
251
|
-
console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
|
|
252
|
-
console.log(formatValidationIssues(validation.issues));
|
|
253
|
-
if (!validation.clean)
|
|
254
|
-
process.exit(1);
|
|
255
|
-
}
|
|
256
|
-
else {
|
|
257
|
-
console.log("foreman tickets: validation passed — all 4 passes clean");
|
|
258
|
-
}
|
|
259
|
-
console.log(`foreman tickets: populated .tickets/tickets.yaml and rendered ${ticketsConfig.paths.progressDoc}`);
|
|
824
|
+
await cmdPopulateCli(opts);
|
|
260
825
|
}
|
|
261
826
|
catch (err) {
|
|
262
|
-
await builder?.close().catch(() => { });
|
|
263
|
-
await viewer?.catch(() => { });
|
|
264
827
|
fail(String(err instanceof Error ? err.message : err));
|
|
265
828
|
}
|
|
266
829
|
});
|
|
@@ -462,6 +1025,74 @@ export function buildTicketsCommand() {
|
|
|
462
1025
|
fail(String(err instanceof Error ? err.message : err));
|
|
463
1026
|
}
|
|
464
1027
|
});
|
|
1028
|
+
// ── review recommendations ────────────────────────────────────────────────
|
|
1029
|
+
tickets
|
|
1030
|
+
.command("review")
|
|
1031
|
+
.description("Review pending split/combine/duplicate ticket recommendations.")
|
|
1032
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1033
|
+
.option("--id <n>", "recommendation id to review")
|
|
1034
|
+
.option("--accept", "accept the selected recommendation")
|
|
1035
|
+
.option("--dismiss", "dismiss the selected recommendation")
|
|
1036
|
+
.option("--defer", "defer the selected recommendation")
|
|
1037
|
+
.option("--accept-all", "accept every pending recommendation")
|
|
1038
|
+
.option("--dismiss-all", "dismiss every pending recommendation")
|
|
1039
|
+
.option("--defer-all", "defer every pending recommendation")
|
|
1040
|
+
.action(async (opts) => {
|
|
1041
|
+
try {
|
|
1042
|
+
const dir = cwd(opts);
|
|
1043
|
+
const action = reviewActionFromOptions(opts);
|
|
1044
|
+
if (action) {
|
|
1045
|
+
const result = cmdReview(dir, {
|
|
1046
|
+
action: action.action,
|
|
1047
|
+
all: action.all,
|
|
1048
|
+
ids: action.id !== undefined ? [action.id] : undefined,
|
|
1049
|
+
});
|
|
1050
|
+
console.log(`foreman tickets: ${action.action}ed ${result.changed} recommendation(s)`);
|
|
1051
|
+
if (result.pending.length > 0)
|
|
1052
|
+
console.log(`foreman tickets: ${result.pending.length} pending recommendation(s) remain`);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const pending = cmdReview(dir).pending;
|
|
1056
|
+
if (pending.length === 0) {
|
|
1057
|
+
console.log("foreman tickets: no pending review recommendations");
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1061
|
+
for (const rec of pending) {
|
|
1062
|
+
console.log(`#${rec.id} [${rec.kind}] ${rec.summary}`);
|
|
1063
|
+
}
|
|
1064
|
+
console.log("foreman tickets: use --accept-all, --dismiss-all, --defer-all, or --id <n> --accept|--dismiss|--defer");
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const picked = await select({
|
|
1068
|
+
message: "Pending recommendation:",
|
|
1069
|
+
options: pending.map((rec) => ({
|
|
1070
|
+
value: String(rec.id),
|
|
1071
|
+
label: `#${rec.id} [${rec.kind}] ${rec.summary}`,
|
|
1072
|
+
})),
|
|
1073
|
+
});
|
|
1074
|
+
if (isCancel(picked))
|
|
1075
|
+
process.exit(0);
|
|
1076
|
+
const disposition = await select({
|
|
1077
|
+
message: "Apply recommendation?",
|
|
1078
|
+
options: [
|
|
1079
|
+
{ value: "accept", label: "Accept and apply patch" },
|
|
1080
|
+
{ value: "defer", label: "Defer" },
|
|
1081
|
+
{ value: "dismiss", label: "Dismiss" },
|
|
1082
|
+
],
|
|
1083
|
+
});
|
|
1084
|
+
if (isCancel(disposition))
|
|
1085
|
+
process.exit(0);
|
|
1086
|
+
const result = cmdReview(dir, {
|
|
1087
|
+
action: disposition,
|
|
1088
|
+
ids: [Number(picked)],
|
|
1089
|
+
});
|
|
1090
|
+
console.log(`foreman tickets: ${disposition}ed ${result.changed} recommendation(s)`);
|
|
1091
|
+
}
|
|
1092
|
+
catch (err) {
|
|
1093
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
465
1096
|
// ── render ───────────────────────────────────────────────────────────────────
|
|
466
1097
|
tickets
|
|
467
1098
|
.command("render")
|