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