ai-foreman 1.7.4 → 1.7.6
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 +6 -1
- package/dist/activity.d.ts +63 -0
- package/dist/activity.js +244 -0
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +41 -1
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +65 -5
- package/dist/adapters/recovering.js +7 -1
- package/dist/adapters/types.d.ts +15 -0
- package/dist/agentRun.d.ts +5 -0
- package/dist/agentRun.js +5 -1
- package/dist/branch/github.js +5 -3
- package/dist/branch/gitlab.js +5 -3
- package/dist/branch/planner.d.ts +5 -0
- package/dist/branch/planner.js +42 -3
- package/dist/branch/presentation.d.ts +17 -0
- package/dist/branch/presentation.js +38 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +2 -0
- package/dist/branch/runner.js +44 -25
- package/dist/buildRuns.d.ts +41 -17
- package/dist/buildRuns.js +243 -22
- package/dist/cli/events.js +24 -5
- package/dist/cli/runtimeAuthPrompt.js +5 -1
- package/dist/cli/start.js +138 -45
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +418 -79
- package/dist/foreman.d.ts +8 -5
- package/dist/foreman.js +38 -14
- package/dist/index.js +3 -1
- package/dist/providerQuestions.d.ts +23 -0
- package/dist/providerQuestions.js +24 -0
- package/dist/qaReview.d.ts +1 -0
- package/dist/qaReview.js +23 -15
- package/dist/qaSnapshot.d.ts +14 -0
- package/dist/qaSnapshot.js +117 -1
- package/dist/runtimeReadiness.js +5 -0
- package/dist/sources/sourceRegistry.js +13 -6
- package/dist/tickets/details.d.ts +17 -0
- package/dist/tickets/details.js +94 -0
- package/dist/tickets/eligibility.d.ts +28 -0
- package/dist/tickets/eligibility.js +71 -0
- package/dist/tickets/importer.js +13 -8
- package/dist/tickets/reset.d.ts +27 -0
- package/dist/tickets/reset.js +213 -0
- package/dist/tickets/reviewStandards.d.ts +7 -0
- package/dist/tickets/reviewStandards.js +53 -0
- package/dist/tickets/setupConfig.d.ts +22 -0
- package/dist/tickets/setupConfig.js +105 -1
- package/dist/tickets/sourceFetch.js +19 -0
- package/dist/tickets/stateDb.d.ts +8 -1
- package/dist/tickets/stateDb.js +54 -23
- package/dist/workflowDb.d.ts +3 -0
- package/dist/workflowDb.js +26 -0
- package/package.json +15 -3
package/dist/cli/tickets.js
CHANGED
|
@@ -2,7 +2,7 @@ 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, text, confirm, isCancel } from "@clack/prompts";
|
|
5
|
+
import { select, text, confirm, isCancel, multiselect } from "@clack/prompts";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { Log } from "../log.js";
|
|
8
8
|
import { Foreman } from "../foreman.js";
|
|
@@ -14,16 +14,33 @@ import { validateDocsRoot } from "../tickets/config.js";
|
|
|
14
14
|
import { importExternalSources, importFromMarkdown, validateExternalSourceAccess } from "../tickets/importer.js";
|
|
15
15
|
import { formatValidationIssues } from "../tickets/validate.js";
|
|
16
16
|
import { assertEffortLevel, createRoleBuilder, makeLogPath, readOnlyPermissionConfig, } from "../agentRun.js";
|
|
17
|
-
import { DEFAULT_TICKET_SETUP, configuredPlanningSources, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, urlSources, } from "../tickets/setupConfig.js";
|
|
17
|
+
import { DEFAULT_TICKET_SETUP, configuredPlanningSources, configuredAppName, defaultAppName, detectDefaultBranch, detectPackageName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, syncTicketLimits, urlSources, } from "../tickets/setupConfig.js";
|
|
18
18
|
import { fetchAndSnapshotUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
|
|
19
19
|
import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
|
|
20
20
|
import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, TICKET_POPULATION_PROPOSAL_END, TICKET_POPULATION_PROPOSAL_START } from "../ticketPopulation.js";
|
|
21
21
|
import { loadTickets } from "../tickets/ticketLoader.js";
|
|
22
22
|
import { resolveTicketPaths } from "../tickets/config.js";
|
|
23
|
+
import { formatTicketDetails, getTicketDetails } from "../tickets/details.js";
|
|
24
|
+
import { previewTicketReset, recoverTicketResetPublications, resetTickets } from "../tickets/reset.js";
|
|
25
|
+
let nestedTicketCommandDepth = 0;
|
|
26
|
+
class TicketInterviewCancelled extends Error {
|
|
27
|
+
}
|
|
28
|
+
function cancelTicketInterview() { throw new TicketInterviewCancelled("ticket interview cancelled"); }
|
|
23
29
|
function fail(msg) {
|
|
30
|
+
if (nestedTicketCommandDepth > 0)
|
|
31
|
+
throw new Error(msg);
|
|
24
32
|
console.error(`foreman tickets: ${msg}`);
|
|
25
33
|
process.exit(1);
|
|
26
34
|
}
|
|
35
|
+
async function runNestedTicketCommand(action) {
|
|
36
|
+
nestedTicketCommandDepth += 1;
|
|
37
|
+
try {
|
|
38
|
+
return await action();
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
nestedTicketCommandDepth -= 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
27
44
|
function cwd(opts) {
|
|
28
45
|
return resolve(opts.project ?? ".");
|
|
29
46
|
}
|
|
@@ -148,6 +165,8 @@ async function cmdSetupInitCli(opts) {
|
|
|
148
165
|
if (hasTicketSetupConfig(dir)) {
|
|
149
166
|
console.log("foreman tickets setup: existing setup found; opening setup:update");
|
|
150
167
|
await cmdSetupUpdateCli(opts);
|
|
168
|
+
const existing = loadTicketSetupConfigWithDefaults(dir);
|
|
169
|
+
await continueTrackerSetup(dir, opts, existing);
|
|
151
170
|
if (interview)
|
|
152
171
|
completeInterview(dir, interview);
|
|
153
172
|
return;
|
|
@@ -168,41 +187,74 @@ async function cmdSetupInitCli(opts) {
|
|
|
168
187
|
targets: parseRuntimeTargets(opts.runtime),
|
|
169
188
|
});
|
|
170
189
|
console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
cmdInit(dir, {
|
|
175
|
-
appName: opts.appName ?? defaultAppName(dir),
|
|
176
|
-
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
177
|
-
docsRoot: opts.docsRoot,
|
|
178
|
-
});
|
|
179
|
-
console.log("foreman tickets setup: initialized .tickets/");
|
|
180
|
-
}
|
|
181
|
-
if (shouldPrompt(opts)) {
|
|
182
|
-
if (interview)
|
|
183
|
-
interview = checkpointInterview(dir, interview, { checkpoint: "populate-confirmation" });
|
|
184
|
-
const populate = await confirm({
|
|
185
|
-
message: "Run ticket population now?",
|
|
186
|
-
initialValue: answers.sources.length > 0,
|
|
187
|
-
});
|
|
188
|
-
if (isCancel(populate))
|
|
189
|
-
return;
|
|
190
|
-
if (populate) {
|
|
191
|
-
await cmdPopulateCli({ project: dir, yes: true });
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
console.log(`foreman tickets setup: next — run \`foreman tickets populate --project ${shellQuote(dir)}\``);
|
|
196
|
-
}
|
|
190
|
+
if (interview)
|
|
191
|
+
interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
|
|
192
|
+
await continueTrackerSetup(dir, opts, answers);
|
|
197
193
|
if (interview)
|
|
198
194
|
completeInterview(dir, interview);
|
|
199
195
|
}
|
|
200
196
|
catch (error) {
|
|
201
|
-
if (interview)
|
|
197
|
+
if (interview && !(error instanceof TicketInterviewCancelled))
|
|
202
198
|
failInterview(dir, interview, interview.checkpoint, error);
|
|
203
199
|
throw error;
|
|
204
200
|
}
|
|
205
201
|
}
|
|
202
|
+
async function continueTrackerSetup(dir, opts, setup) {
|
|
203
|
+
if (!isTicketsInitialized(dir)) {
|
|
204
|
+
let initialize = true;
|
|
205
|
+
if (shouldPrompt(opts)) {
|
|
206
|
+
const answer = await confirm({ message: "Initialize the ticket tracker now?", initialValue: true });
|
|
207
|
+
if (isCancel(answer))
|
|
208
|
+
return;
|
|
209
|
+
initialize = Boolean(answer);
|
|
210
|
+
}
|
|
211
|
+
if (!initialize) {
|
|
212
|
+
console.log("foreman tickets setup: preferences were saved. Ticket infrastructure is still required before building.");
|
|
213
|
+
console.log(` rafi tickets init --project ${shellQuote(dir)}`);
|
|
214
|
+
console.log(` rafi tickets populate --project ${shellQuote(dir)}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const initialized = await retrySetupStage("ticket initialization", async () => {
|
|
218
|
+
const appName = opts.appName ?? await resolveInitAppName(dir, Boolean(opts.yes || opts.defaults));
|
|
219
|
+
cmdInit(dir, { appName, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, docsRoot: opts.docsRoot, implementationLimit: setup.limits.implementation, viewLimit: setup.limits.view });
|
|
220
|
+
syncTicketLimits(dir, setup.limits);
|
|
221
|
+
}, shouldPrompt(opts));
|
|
222
|
+
if (!initialized)
|
|
223
|
+
return;
|
|
224
|
+
console.log("foreman tickets setup: initialized .tickets/");
|
|
225
|
+
}
|
|
226
|
+
if (shouldPrompt(opts)) {
|
|
227
|
+
const populate = await confirm({ message: "Populate tickets now?", initialValue: true });
|
|
228
|
+
if (isCancel(populate))
|
|
229
|
+
return;
|
|
230
|
+
if (populate)
|
|
231
|
+
await retrySetupStage("ticket population", () => runNestedTicketCommand(() => cmdPopulateCli({ project: dir, yes: true })), true);
|
|
232
|
+
else {
|
|
233
|
+
console.log("foreman tickets setup: population is the final prerequisite before building.");
|
|
234
|
+
console.log(`foreman tickets setup: run \`rafi tickets populate --project ${shellQuote(dir)}\` later.`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
console.log(`foreman tickets setup: next — run \`rafi tickets populate --project ${shellQuote(dir)}\``);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async function retrySetupStage(label, action, interactive) {
|
|
242
|
+
while (true) {
|
|
243
|
+
try {
|
|
244
|
+
await action();
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (!interactive)
|
|
249
|
+
throw error;
|
|
250
|
+
console.error(`foreman tickets setup: ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
251
|
+
console.error("Fix the reported problem, then press Enter to retry; enter q to leave setup with saved progress.");
|
|
252
|
+
const answer = await text({ message: `Retry ${label}?`, defaultValue: "" });
|
|
253
|
+
if (isCancel(answer) || String(answer).trim().toLowerCase() === "q")
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
206
258
|
async function cmdSetupUpdateCli(opts) {
|
|
207
259
|
const dir = cwd(opts);
|
|
208
260
|
if (!existsSync(dir))
|
|
@@ -225,6 +277,7 @@ async function cmdSetupUpdateCli(opts) {
|
|
|
225
277
|
docsRoot: opts.docsRoot,
|
|
226
278
|
targets: parseRuntimeTargets(opts.runtime),
|
|
227
279
|
});
|
|
280
|
+
syncTicketLimits(dir, next.limits);
|
|
228
281
|
console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
229
282
|
if (isTicketsInitialized(dir)) {
|
|
230
283
|
const rows = cmdQueue(dir, 1);
|
|
@@ -236,7 +289,7 @@ async function cmdSetupUpdateCli(opts) {
|
|
|
236
289
|
completeInterview(dir, interview);
|
|
237
290
|
}
|
|
238
291
|
catch (error) {
|
|
239
|
-
if (interview)
|
|
292
|
+
if (interview && !(error instanceof TicketInterviewCancelled))
|
|
240
293
|
failInterview(dir, interview, interview.checkpoint, error);
|
|
241
294
|
throw error;
|
|
242
295
|
}
|
|
@@ -302,38 +355,107 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
302
355
|
{ value: "sources", label: "Ticket sources" },
|
|
303
356
|
{ value: "populate", label: "Populate defaults" },
|
|
304
357
|
{ value: "build", label: "Build defaults" },
|
|
358
|
+
{ value: "limits", label: "Tracker limits" },
|
|
305
359
|
{ value: "all", label: "All sections" },
|
|
306
360
|
],
|
|
307
361
|
});
|
|
308
362
|
if (isCancel(section))
|
|
309
|
-
|
|
363
|
+
cancelTicketInterview();
|
|
310
364
|
// Do not silently persist planning hints over a completed ticket setup. They
|
|
311
365
|
// are only the initial local-source suggestion for a fresh setup.
|
|
312
366
|
const planningPrefill = current.sources.length === 0
|
|
313
367
|
? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
|
|
314
368
|
: current.sources;
|
|
315
369
|
let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : planningPrefill;
|
|
370
|
+
let limits = current.limits;
|
|
316
371
|
if (section === "sources" || section === "all") {
|
|
317
372
|
sources = await promptTicketSources(dir, current.sources);
|
|
318
373
|
}
|
|
374
|
+
if (section === "limits" || section === "all") {
|
|
375
|
+
console.log("The defaults are a 500-ticket implementation window and a 20,000-ticket view limit. After initialization these live in .tickets/config.yaml.");
|
|
376
|
+
const keep = await confirm({ message: "Keep both default limits?", initialValue: true });
|
|
377
|
+
if (isCancel(keep))
|
|
378
|
+
cancelTicketInterview();
|
|
379
|
+
limits = keep ? { implementation: 500, view: 20_000 } : {
|
|
380
|
+
implementation: await promptPositiveInteger("Implementation window limit:", current.limits.implementation),
|
|
381
|
+
view: await promptPositiveInteger("View limit:", current.limits.view),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
319
384
|
if (section === "populate" || section === "all") {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
385
|
+
const defaults = await confirm({ message: "Use the recommended population settings?", initialValue: true });
|
|
386
|
+
if (isCancel(defaults))
|
|
387
|
+
cancelTicketInterview();
|
|
388
|
+
if (defaults)
|
|
389
|
+
Object.assign(populatePatch, DEFAULT_TICKET_SETUP.populate);
|
|
390
|
+
else {
|
|
391
|
+
const handling = await select({ message: "How should saved sources be handled?", initialValue: current.populate.source_handling, options: [
|
|
392
|
+
{ value: "saved", label: "Use saved sources" }, { value: "prompt", label: "Ask each time" }, { value: "manual", label: "Manual only" },
|
|
393
|
+
] });
|
|
394
|
+
if (isCancel(handling))
|
|
395
|
+
cancelTicketInterview();
|
|
396
|
+
populatePatch.source_handling = handling;
|
|
397
|
+
const agent = await select({ message: "When both runtimes are configured, which should populate use?", initialValue: current.populate.agent_preference, options: [
|
|
398
|
+
{ value: "configured", label: "Configured project default" }, { value: "claude", label: "Claude" }, { value: "codex", label: "Codex" },
|
|
399
|
+
] });
|
|
400
|
+
if (isCancel(agent))
|
|
401
|
+
cancelTicketInterview();
|
|
402
|
+
populatePatch.agent_preference = agent;
|
|
403
|
+
populatePatch.import_cap = await promptPositiveInteger("Maximum tickets imported in one population run (separate from the implementation window):", current.populate.import_cap);
|
|
404
|
+
populatePatch.comment_limit = await promptNonNegativeInteger("Maximum comments imported per external ticket:", current.populate.comment_limit);
|
|
405
|
+
const enrichment = await select({ message: "Population enrichment policy:", initialValue: current.populate.enrichment, options: [
|
|
406
|
+
{ value: "recommendations", label: "Deterministic recommendations" }, { value: "agent", label: "Agent enrichment" }, { value: "none", label: "No enrichment" },
|
|
407
|
+
] });
|
|
408
|
+
if (isCancel(enrichment))
|
|
409
|
+
cancelTicketInterview();
|
|
410
|
+
populatePatch.enrichment = enrichment;
|
|
411
|
+
const split = await confirm({ message: "Recommend splitting XL tickets?", initialValue: current.populate.recommend_split_for_xl });
|
|
412
|
+
if (isCancel(split))
|
|
413
|
+
cancelTicketInterview();
|
|
414
|
+
populatePatch.recommend_split_for_xl = Boolean(split);
|
|
415
|
+
}
|
|
332
416
|
}
|
|
333
417
|
if (section === "build" || section === "all") {
|
|
334
418
|
const recommended = recommendedBuildDefaults(dir);
|
|
335
|
-
const
|
|
336
|
-
|
|
419
|
+
const base = await text({ message: "Base branch:", initialValue: current.build.base_branch || detectDefaultBranch(dir), defaultValue: detectDefaultBranch(dir) });
|
|
420
|
+
if (isCancel(base))
|
|
421
|
+
cancelTicketInterview();
|
|
422
|
+
buildPatch.base_branch = String(base).trim();
|
|
423
|
+
const policyMode = await select({ message: "Use one global branch strategy or choose by inferred ticket size?", initialValue: current.build.branch_policy.mode, options: [
|
|
424
|
+
{ value: "size", label: "Size-based: XS/S shared, M/L/XL per ticket (Recommended)" },
|
|
425
|
+
{ value: "global", label: "One global strategy" },
|
|
426
|
+
] });
|
|
427
|
+
if (isCancel(policyMode))
|
|
428
|
+
cancelTicketInterview();
|
|
429
|
+
let sizeMapping = { XS: "shared", S: "shared", M: "per-ticket", L: "per-ticket", XL: "per-ticket" };
|
|
430
|
+
if (policyMode === "size") {
|
|
431
|
+
console.log("Rafi infers size from each canonical ticket. Recommended mapping: XS/S share a compatible run branch; M/L/XL use one branch per ticket. This never creates stack dependencies, and explicit delivery configuration wins.");
|
|
432
|
+
const keepMapping = await confirm({ message: "Keep the recommended size mapping?", initialValue: true });
|
|
433
|
+
if (isCancel(keepMapping))
|
|
434
|
+
cancelTicketInterview();
|
|
435
|
+
if (!keepMapping) {
|
|
436
|
+
sizeMapping = { ...sizeMapping };
|
|
437
|
+
for (const size of ["XS", "S", "M", "L", "XL"]) {
|
|
438
|
+
const allocation = await select({
|
|
439
|
+
message: `${size} ticket branch allocation:`,
|
|
440
|
+
initialValue: current.build.branch_policy.by_size[size],
|
|
441
|
+
options: [
|
|
442
|
+
{ value: "shared", label: "Share with compatible tickets selected in this run" },
|
|
443
|
+
{ value: "per-ticket", label: "Use a separate branch per ticket" },
|
|
444
|
+
],
|
|
445
|
+
});
|
|
446
|
+
if (isCancel(allocation))
|
|
447
|
+
cancelTicketInterview();
|
|
448
|
+
sizeMapping[size] = allocation;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
buildPatch.branch_policy = {
|
|
453
|
+
...current.build.branch_policy,
|
|
454
|
+
mode: policyMode,
|
|
455
|
+
by_size: policyMode === "size" ? sizeMapping : current.build.branch_policy.by_size,
|
|
456
|
+
};
|
|
457
|
+
const strategy = policyMode === "size" ? "branch-per-ticket" : await select({
|
|
458
|
+
message: "Default global ticket work mode:",
|
|
337
459
|
initialValue: current.build.branch_strategy,
|
|
338
460
|
options: [
|
|
339
461
|
{ value: "current", label: "One branch - work the queue on the current branch" },
|
|
@@ -342,8 +464,9 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
342
464
|
],
|
|
343
465
|
});
|
|
344
466
|
if (isCancel(strategy))
|
|
345
|
-
|
|
467
|
+
cancelTicketInterview();
|
|
346
468
|
buildPatch.branch_strategy = strategy;
|
|
469
|
+
buildPatch.branch_policy.global_strategy = buildPatch.branch_strategy;
|
|
347
470
|
if (strategy === "current") {
|
|
348
471
|
buildPatch.completion = "none";
|
|
349
472
|
buildPatch.provider = "local";
|
|
@@ -352,8 +475,10 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
352
475
|
buildPatch.cleanup = true;
|
|
353
476
|
buildPatch.auto_merge_wait = false;
|
|
354
477
|
buildPatch.auto_merge_timeout_minutes = null;
|
|
478
|
+
buildPatch.validation_checklist = await promptRequirementList("validation checklist", current.build.validation_checklist);
|
|
355
479
|
return {
|
|
356
480
|
sources,
|
|
481
|
+
limits,
|
|
357
482
|
populate: populatePatch,
|
|
358
483
|
build: buildPatch,
|
|
359
484
|
};
|
|
@@ -369,19 +494,43 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
369
494
|
],
|
|
370
495
|
});
|
|
371
496
|
if (isCancel(completion))
|
|
372
|
-
|
|
497
|
+
cancelTicketInterview();
|
|
373
498
|
buildPatch.completion = completion;
|
|
374
499
|
buildPatch.provider = recommended.provider;
|
|
375
500
|
buildPatch.pr_ready = completion === "auto-merge";
|
|
376
501
|
buildPatch.merge_method = "squash";
|
|
377
502
|
buildPatch.cleanup = true;
|
|
503
|
+
if (completion === "pr" || completion === "auto-merge") {
|
|
504
|
+
const title = await select({ message: "PR/MR title standard:", initialValue: current.build.review.title_style, options: [
|
|
505
|
+
{ value: "ticket-title", label: "T123: Add password reset (Recommended)" },
|
|
506
|
+
{ value: "conventional", label: "Conventional Commit" },
|
|
507
|
+
{ value: "none", label: "No required format" },
|
|
508
|
+
{ value: "custom", label: "Custom template" },
|
|
509
|
+
] });
|
|
510
|
+
if (isCancel(title))
|
|
511
|
+
cancelTicketInterview();
|
|
512
|
+
let template = null;
|
|
513
|
+
if (title === "custom") {
|
|
514
|
+
const answer = await text({ message: "Custom title template (use {id} and {title}):" });
|
|
515
|
+
if (isCancel(answer))
|
|
516
|
+
cancelTicketInterview();
|
|
517
|
+
template = String(answer);
|
|
518
|
+
}
|
|
519
|
+
buildPatch.review = {
|
|
520
|
+
...current.build.review,
|
|
521
|
+
title_style: title,
|
|
522
|
+
title_template: template,
|
|
523
|
+
description_sections: await promptRequirementList("required PR/MR description sections", current.build.review.description_sections),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
buildPatch.validation_checklist = await promptRequirementList("validation checklist", current.build.validation_checklist);
|
|
378
527
|
if (completion === "auto-merge") {
|
|
379
528
|
const wait = await confirm({
|
|
380
529
|
message: "Wait for dependency PR/MRs to merge before starting dependent tickets?",
|
|
381
530
|
initialValue: current.build.auto_merge_wait,
|
|
382
531
|
});
|
|
383
532
|
if (isCancel(wait))
|
|
384
|
-
|
|
533
|
+
cancelTicketInterview();
|
|
385
534
|
buildPatch.auto_merge_wait = Boolean(wait);
|
|
386
535
|
if (wait) {
|
|
387
536
|
const timeout = await text({
|
|
@@ -397,7 +546,7 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
397
546
|
},
|
|
398
547
|
});
|
|
399
548
|
if (isCancel(timeout))
|
|
400
|
-
|
|
549
|
+
cancelTicketInterview();
|
|
401
550
|
buildPatch.auto_merge_timeout_minutes = String(timeout).trim()
|
|
402
551
|
? Number.parseInt(String(timeout).trim(), 10)
|
|
403
552
|
: null;
|
|
@@ -413,10 +562,60 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
413
562
|
}
|
|
414
563
|
return {
|
|
415
564
|
sources,
|
|
565
|
+
limits,
|
|
416
566
|
populate: populatePatch,
|
|
417
567
|
build: buildPatch,
|
|
418
568
|
};
|
|
419
569
|
}
|
|
570
|
+
async function promptPositiveInteger(message, initial) {
|
|
571
|
+
const answer = await text({ message, initialValue: String(initial), defaultValue: String(initial), validate: (value) => Number.isInteger(Number(value)) && Number(value) > 0 ? undefined : "Enter a positive integer" });
|
|
572
|
+
if (isCancel(answer))
|
|
573
|
+
cancelTicketInterview();
|
|
574
|
+
return Number(answer);
|
|
575
|
+
}
|
|
576
|
+
async function promptNonNegativeInteger(message, initial) {
|
|
577
|
+
const answer = await text({ message, initialValue: String(initial), defaultValue: String(initial), validate: (value) => Number.isInteger(Number(value)) && Number(value) >= 0 ? undefined : "Enter zero or a positive integer" });
|
|
578
|
+
if (isCancel(answer))
|
|
579
|
+
cancelTicketInterview();
|
|
580
|
+
return Number(answer);
|
|
581
|
+
}
|
|
582
|
+
async function promptRequirementList(label, initial) {
|
|
583
|
+
console.log(`Current recommended ${label}: ${initial.join("; ") || "none"}`);
|
|
584
|
+
const mode = await select({
|
|
585
|
+
message: `How should Rafi configure the ${label}?`,
|
|
586
|
+
initialValue: "keep",
|
|
587
|
+
options: [
|
|
588
|
+
{ value: "keep", label: "Keep all listed requirements (Recommended)" },
|
|
589
|
+
{ value: "choose", label: "Choose individual requirements and optionally add custom ones" },
|
|
590
|
+
{ value: "replace", label: "Replace with a custom list" },
|
|
591
|
+
],
|
|
592
|
+
});
|
|
593
|
+
if (isCancel(mode))
|
|
594
|
+
cancelTicketInterview();
|
|
595
|
+
if (mode === "keep")
|
|
596
|
+
return [...initial];
|
|
597
|
+
if (mode === "replace") {
|
|
598
|
+
const replacement = await text({ message: `Custom ${label} (comma-separated):`, defaultValue: "" });
|
|
599
|
+
if (isCancel(replacement))
|
|
600
|
+
cancelTicketInterview();
|
|
601
|
+
return splitCommaList(String(replacement));
|
|
602
|
+
}
|
|
603
|
+
const selected = initial.length ? await multiselect({
|
|
604
|
+
message: `Select the ${label} to keep:`,
|
|
605
|
+
options: initial.map((value) => ({ value, label: value })),
|
|
606
|
+
initialValues: initial,
|
|
607
|
+
required: false,
|
|
608
|
+
}) : [];
|
|
609
|
+
if (isCancel(selected))
|
|
610
|
+
cancelTicketInterview();
|
|
611
|
+
const additions = await text({ message: `Additional custom ${label} (comma-separated, optional):`, defaultValue: "" });
|
|
612
|
+
if (isCancel(additions))
|
|
613
|
+
cancelTicketInterview();
|
|
614
|
+
return combineRequirementSelections(selected, String(additions));
|
|
615
|
+
}
|
|
616
|
+
export function combineRequirementSelections(selected, additions) {
|
|
617
|
+
return [...new Set([...selected, ...splitCommaList(additions)])];
|
|
618
|
+
}
|
|
420
619
|
async function promptTicketSources(dir, current) {
|
|
421
620
|
const kind = await select({
|
|
422
621
|
message: "Primary ticket source:",
|
|
@@ -429,7 +628,7 @@ async function promptTicketSources(dir, current) {
|
|
|
429
628
|
],
|
|
430
629
|
});
|
|
431
630
|
if (isCancel(kind))
|
|
432
|
-
|
|
631
|
+
cancelTicketInterview();
|
|
433
632
|
if (kind === "none")
|
|
434
633
|
return [];
|
|
435
634
|
if (kind === "local") {
|
|
@@ -440,16 +639,16 @@ async function promptTicketSources(dir, current) {
|
|
|
440
639
|
defaultValue: existing?.paths.join(", ") || `${configuredDocsPlanPath(dir)}`,
|
|
441
640
|
});
|
|
442
641
|
if (isCancel(answer))
|
|
443
|
-
|
|
642
|
+
cancelTicketInterview();
|
|
444
643
|
return [{ type: "local", paths: splitCommaList(String(answer)) }];
|
|
445
644
|
}
|
|
446
645
|
if (kind === "linear") {
|
|
447
646
|
const team = await text({ message: "Linear team key (optional):" });
|
|
448
647
|
if (isCancel(team))
|
|
449
|
-
|
|
648
|
+
cancelTicketInterview();
|
|
450
649
|
const filter = await text({ message: "Linear IssueFilter JSON or title search text (optional):" });
|
|
451
650
|
if (isCancel(filter))
|
|
452
|
-
|
|
651
|
+
cancelTicketInterview();
|
|
453
652
|
return [{
|
|
454
653
|
type: "linear",
|
|
455
654
|
api_key_env: "LINEAR_API_KEY",
|
|
@@ -465,7 +664,7 @@ async function promptTicketSources(dir, current) {
|
|
|
465
664
|
validate: (value) => /^https?:\/\//i.test(String(value ?? "")) ? undefined : "Enter an HTTP(S) URL",
|
|
466
665
|
});
|
|
467
666
|
if (isCancel(answer))
|
|
468
|
-
|
|
667
|
+
cancelTicketInterview();
|
|
469
668
|
return [{ type: "url", url: String(answer).trim() }];
|
|
470
669
|
}
|
|
471
670
|
const site = await text({
|
|
@@ -474,14 +673,14 @@ async function promptTicketSources(dir, current) {
|
|
|
474
673
|
validate: (value) => String(value ?? "").trim() ? undefined : "Enter a Jira Cloud site URL",
|
|
475
674
|
});
|
|
476
675
|
if (isCancel(site))
|
|
477
|
-
|
|
676
|
+
cancelTicketInterview();
|
|
478
677
|
const jql = await text({
|
|
479
678
|
message: "Jira JQL:",
|
|
480
679
|
initialValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
|
|
481
680
|
defaultValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
|
|
482
681
|
});
|
|
483
682
|
if (isCancel(jql))
|
|
484
|
-
|
|
683
|
+
cancelTicketInterview();
|
|
485
684
|
return [{
|
|
486
685
|
type: "jira",
|
|
487
686
|
site: String(site).trim(),
|
|
@@ -568,6 +767,11 @@ function parseAgentPreference(value) {
|
|
|
568
767
|
return value;
|
|
569
768
|
fail("--agent-preference must be one of: configured, claude, codex");
|
|
570
769
|
}
|
|
770
|
+
function parseResetScope(value) {
|
|
771
|
+
if (["all", "completed-and-unfinished", "unfinished"].includes(value))
|
|
772
|
+
return value;
|
|
773
|
+
throw new Error("--scope must be all, completed-and-unfinished, or unfinished");
|
|
774
|
+
}
|
|
571
775
|
function parseOptionalPositiveInteger(value, label) {
|
|
572
776
|
if (!value.trim())
|
|
573
777
|
return null;
|
|
@@ -599,19 +803,20 @@ function configuredDocsPlanPath(dir) {
|
|
|
599
803
|
return "docs/rafi-plan.md";
|
|
600
804
|
}
|
|
601
805
|
async function resolveInitAppName(dir, yes) {
|
|
602
|
-
const
|
|
603
|
-
if (
|
|
604
|
-
return
|
|
806
|
+
const established = configuredAppName(dir);
|
|
807
|
+
if (established)
|
|
808
|
+
return established;
|
|
809
|
+
const detected = detectPackageName(dir) ?? "My App";
|
|
605
810
|
if (yes || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
606
|
-
return
|
|
811
|
+
return detected;
|
|
607
812
|
const answer = await text({
|
|
608
813
|
message: "App name:",
|
|
609
|
-
initialValue:
|
|
610
|
-
defaultValue:
|
|
814
|
+
initialValue: detected,
|
|
815
|
+
defaultValue: detected,
|
|
611
816
|
});
|
|
612
817
|
if (isCancel(answer))
|
|
613
|
-
|
|
614
|
-
return String(answer).trim() ||
|
|
818
|
+
cancelTicketInterview();
|
|
819
|
+
return String(answer).trim() || detected;
|
|
615
820
|
}
|
|
616
821
|
async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, yes) {
|
|
617
822
|
if (explicitSources?.length)
|
|
@@ -629,7 +834,7 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
|
|
|
629
834
|
initialValue: true,
|
|
630
835
|
});
|
|
631
836
|
if (isCancel(usePlan))
|
|
632
|
-
|
|
837
|
+
return undefined;
|
|
633
838
|
return usePlan ? defaultPlan : undefined;
|
|
634
839
|
}
|
|
635
840
|
if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
@@ -639,7 +844,7 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
|
|
|
639
844
|
console.log(" rafi tickets setup:init");
|
|
640
845
|
console.log(" rafi tickets populate --sources <files-or-globs>");
|
|
641
846
|
console.log(" edit .tickets/tickets.yaml manually");
|
|
642
|
-
|
|
847
|
+
throw new Error("no ticket sources were found; create a plan, configure sources, or pass --sources");
|
|
643
848
|
}
|
|
644
849
|
const action = await select({
|
|
645
850
|
message: "No ticket sources were found. What should happen next?",
|
|
@@ -652,22 +857,22 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
|
|
|
652
857
|
});
|
|
653
858
|
if (isCancel(action) || action === "none") {
|
|
654
859
|
console.log("foreman tickets: cancelled");
|
|
655
|
-
|
|
860
|
+
return undefined;
|
|
656
861
|
}
|
|
657
862
|
if (action === "setup") {
|
|
658
863
|
await cmdSetupInitCli({ project: dir });
|
|
659
|
-
|
|
864
|
+
return undefined;
|
|
660
865
|
}
|
|
661
866
|
if (action === "plan") {
|
|
662
867
|
console.log(`foreman tickets: run \`rafi plan ${shellQuote(dir)}\`, then \`rafi tickets populate --project ${shellQuote(dir)}\`.`);
|
|
663
|
-
|
|
868
|
+
return undefined;
|
|
664
869
|
}
|
|
665
870
|
const entered = await text({
|
|
666
871
|
message: "Source paths or globs, comma-separated:",
|
|
667
872
|
validate: (value) => String(value ?? "").trim() ? undefined : "Enter at least one source path or glob",
|
|
668
873
|
});
|
|
669
874
|
if (isCancel(entered))
|
|
670
|
-
|
|
875
|
+
return undefined;
|
|
671
876
|
return splitCommaList(String(entered));
|
|
672
877
|
}
|
|
673
878
|
function reviewActionFromOptions(opts) {
|
|
@@ -733,7 +938,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
733
938
|
});
|
|
734
939
|
if (isCancel(action) || action === "cancel") {
|
|
735
940
|
console.log("foreman tickets: cancelled");
|
|
736
|
-
|
|
941
|
+
return;
|
|
737
942
|
}
|
|
738
943
|
}
|
|
739
944
|
const results = await importExternalSources(dir, configuredExternalSources, {
|
|
@@ -751,7 +956,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
751
956
|
console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
|
|
752
957
|
console.log(formatValidationIssues(validation.issues));
|
|
753
958
|
if (!validation.clean)
|
|
754
|
-
|
|
959
|
+
throw new Error("external ticket import failed tracker validation");
|
|
755
960
|
}
|
|
756
961
|
if (!sourceHints.length) {
|
|
757
962
|
console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
|
|
@@ -769,7 +974,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
769
974
|
});
|
|
770
975
|
if (isCancel(action) || action === "cancel") {
|
|
771
976
|
console.log("foreman tickets: cancelled");
|
|
772
|
-
|
|
977
|
+
return;
|
|
773
978
|
}
|
|
774
979
|
}
|
|
775
980
|
const logPath = makeLogPath(dir, "tickets-populate");
|
|
@@ -811,12 +1016,10 @@ export async function cmdPopulateCli(opts) {
|
|
|
811
1016
|
fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
|
|
812
1017
|
}
|
|
813
1018
|
if (turn.status.kind === "blocked") {
|
|
814
|
-
|
|
815
|
-
process.exit(2);
|
|
1019
|
+
throw new Error(`blocked — ${turn.status.reason ?? "builder reported blocked"}`);
|
|
816
1020
|
}
|
|
817
1021
|
if (turn.status.kind !== "done" && turn.status.kind !== "plan_complete") {
|
|
818
|
-
|
|
819
|
-
process.exit(2);
|
|
1022
|
+
throw new Error(`needs human — ${turn.status.error ?? "builder did not emit done"}`);
|
|
820
1023
|
}
|
|
821
1024
|
const existing = loadTickets(resolveTicketPaths(ticketsConfig, dir).tickets);
|
|
822
1025
|
const proposal = extractTicketPopulationProposal(turn.result.text);
|
|
@@ -874,8 +1077,37 @@ function loadPopulationPlan(projectDir, sources) {
|
|
|
874
1077
|
}
|
|
875
1078
|
throw new Error("ticket population requires the approved paired structured plan (normally docs/rafi-plan.json); run `rafi plan --validate` first");
|
|
876
1079
|
}
|
|
877
|
-
|
|
1080
|
+
function ticketOwnershipReceipts(projectDir, commandName, _afterWrite) {
|
|
1081
|
+
const candidates = [
|
|
1082
|
+
".tickets/config.yaml", ".tickets/tickets.yaml", ".tickets/delivery.yaml", ".tickets/tracker-rules.md",
|
|
1083
|
+
".tickets/schema/tickets.schema.json", ".tickets/migrations/001_init.sql", ".tickets/.gitignore",
|
|
1084
|
+
];
|
|
1085
|
+
if (isTicketsInitialized(projectDir)) {
|
|
1086
|
+
const config = loadTicketsConfig(projectDir);
|
|
1087
|
+
candidates.push(config.paths.progressDoc, config.paths.archiveDoc);
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
candidates.push(DEFAULT_TICKETS_CONFIG.paths.progressDoc, DEFAULT_TICKETS_CONFIG.paths.archiveDoc);
|
|
1091
|
+
}
|
|
1092
|
+
return unique(candidates).filter((path) => existsSync(join(projectDir, path))).map((path) => ({ path, category: "tickets", origin: `tickets:${commandName}` }));
|
|
1093
|
+
}
|
|
1094
|
+
export function buildTicketsCommand(options = {}) {
|
|
878
1095
|
const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
|
|
1096
|
+
tickets.hook("preAction", (_thisCommand, actionCommand) => {
|
|
1097
|
+
const commandOptions = actionCommand.opts();
|
|
1098
|
+
const projectDir = cwd(commandOptions);
|
|
1099
|
+
recoverTicketResetPublications(projectDir);
|
|
1100
|
+
if (options.prepareOwnership && !["show", "validate", "queue"].includes(actionCommand.name())) {
|
|
1101
|
+
options.prepareOwnership(projectDir, ticketOwnershipReceipts(projectDir, actionCommand.name(), false));
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
tickets.hook("postAction", (_thisCommand, actionCommand) => {
|
|
1105
|
+
if (!options.recordOwnership)
|
|
1106
|
+
return;
|
|
1107
|
+
const commandOptions = actionCommand.opts();
|
|
1108
|
+
const projectDir = cwd(commandOptions);
|
|
1109
|
+
options.recordOwnership(projectDir, ticketOwnershipReceipts(projectDir, actionCommand.name(), true));
|
|
1110
|
+
});
|
|
879
1111
|
// ── setup:init / setup:update ─────────────────────────────────────────────
|
|
880
1112
|
tickets
|
|
881
1113
|
.command("setup:init")
|
|
@@ -905,6 +1137,10 @@ export function buildTicketsCommand() {
|
|
|
905
1137
|
await cmdSetupInitCli(opts);
|
|
906
1138
|
}
|
|
907
1139
|
catch (err) {
|
|
1140
|
+
if (err instanceof TicketInterviewCancelled) {
|
|
1141
|
+
console.log("foreman tickets setup: cancelled; saved checkpoints were retained");
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
908
1144
|
fail(String(err instanceof Error ? err.message : err));
|
|
909
1145
|
}
|
|
910
1146
|
});
|
|
@@ -936,6 +1172,10 @@ export function buildTicketsCommand() {
|
|
|
936
1172
|
await cmdSetupUpdateCli(opts);
|
|
937
1173
|
}
|
|
938
1174
|
catch (err) {
|
|
1175
|
+
if (err instanceof TicketInterviewCancelled) {
|
|
1176
|
+
console.log("foreman tickets setup: cancelled; saved checkpoints were retained");
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
939
1179
|
fail(String(err instanceof Error ? err.message : err));
|
|
940
1180
|
}
|
|
941
1181
|
});
|
|
@@ -969,9 +1209,24 @@ export function buildTicketsCommand() {
|
|
|
969
1209
|
docsRoot: opts.docsRoot,
|
|
970
1210
|
});
|
|
971
1211
|
console.log(`foreman tickets: initialized .tickets/ in ${dir}`);
|
|
972
|
-
|
|
1212
|
+
if (shouldPrompt(opts)) {
|
|
1213
|
+
const populate = await confirm({ message: "Populate tickets now?", initialValue: true });
|
|
1214
|
+
if (!isCancel(populate) && populate)
|
|
1215
|
+
await runNestedTicketCommand(() => cmdPopulateCli({ project: dir }));
|
|
1216
|
+
else {
|
|
1217
|
+
console.log("foreman tickets: population is the final prerequisite before building.");
|
|
1218
|
+
console.log(`foreman tickets: run \`rafi tickets populate --project ${shellQuote(dir)}\` later.`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
else {
|
|
1222
|
+
console.log(`foreman tickets: next — run \`rafi tickets populate --project ${shellQuote(dir)}\``);
|
|
1223
|
+
}
|
|
973
1224
|
}
|
|
974
1225
|
catch (err) {
|
|
1226
|
+
if (err instanceof TicketInterviewCancelled) {
|
|
1227
|
+
console.log("foreman tickets: initialization cancelled; no process exit was forced by the nested interview");
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
975
1230
|
fail(String(err instanceof Error ? err.message : err));
|
|
976
1231
|
}
|
|
977
1232
|
});
|
|
@@ -996,6 +1251,90 @@ export function buildTicketsCommand() {
|
|
|
996
1251
|
}
|
|
997
1252
|
});
|
|
998
1253
|
// ── update ──────────────────────────────────────────────────────────────────
|
|
1254
|
+
tickets
|
|
1255
|
+
.command("show <ticketId>")
|
|
1256
|
+
.description("Show the complete canonical ticket, active state, validation, delivery, and history.")
|
|
1257
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1258
|
+
.option("--json", "write the complete stable record as JSON")
|
|
1259
|
+
.action((ticketId, opts) => {
|
|
1260
|
+
try {
|
|
1261
|
+
const details = getTicketDetails(cwd(opts), ticketId);
|
|
1262
|
+
if (opts.json)
|
|
1263
|
+
process.stdout.write(`${JSON.stringify(details, null, 2)}\n`);
|
|
1264
|
+
else
|
|
1265
|
+
for (const line of formatTicketDetails(details))
|
|
1266
|
+
console.log(line);
|
|
1267
|
+
}
|
|
1268
|
+
catch (err) {
|
|
1269
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
tickets
|
|
1273
|
+
.command("reset [ticketId]")
|
|
1274
|
+
.description("Reset one ticket or an explicit ticket scope to pristine active state while retaining history.")
|
|
1275
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1276
|
+
.option("--all", "open the interactive bulk reset scope chooser")
|
|
1277
|
+
.option("--scope <scope>", "bulk scope (all | completed-and-unfinished | unfinished)")
|
|
1278
|
+
.option("--actor <actor>", "who requested the reset", "user")
|
|
1279
|
+
.option("-y, --yes", "confirm an explicit ticket or --scope without prompting")
|
|
1280
|
+
.action(async (ticketId, opts) => {
|
|
1281
|
+
try {
|
|
1282
|
+
if (ticketId && (opts.all || opts.scope))
|
|
1283
|
+
throw new Error("provide a ticket ID or a bulk scope, not both");
|
|
1284
|
+
if (!ticketId && !opts.all && !opts.scope)
|
|
1285
|
+
throw new Error("provide <ticketId>, --all, or --scope <scope>");
|
|
1286
|
+
let target = ticketId;
|
|
1287
|
+
if (opts.scope)
|
|
1288
|
+
target = parseResetScope(String(opts.scope));
|
|
1289
|
+
if (opts.all && !opts.scope) {
|
|
1290
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1291
|
+
throw new Error("--all requires an interactive terminal; use --scope <scope> --yes for automation");
|
|
1292
|
+
const answer = await select({
|
|
1293
|
+
message: "Which tickets should return to an unworked state?",
|
|
1294
|
+
options: [
|
|
1295
|
+
{ value: "all", label: "All tickets" },
|
|
1296
|
+
{ value: "completed-and-unfinished", label: "Completed and unfinished (exclude canceled/obsolete)" },
|
|
1297
|
+
{ value: "unfinished", label: "Unfinished only (exclude done/canceled/obsolete)" },
|
|
1298
|
+
],
|
|
1299
|
+
});
|
|
1300
|
+
if (isCancel(answer)) {
|
|
1301
|
+
console.log("foreman tickets: reset cancelled; nothing changed");
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
target = answer;
|
|
1305
|
+
}
|
|
1306
|
+
if (!target)
|
|
1307
|
+
throw new Error("reset target was not selected");
|
|
1308
|
+
const preview = previewTicketReset(cwd(opts), target);
|
|
1309
|
+
console.log(`foreman tickets reset preview (${preview.scope}):`);
|
|
1310
|
+
if (!preview.tickets.length)
|
|
1311
|
+
console.log(" (no matching tickets)");
|
|
1312
|
+
for (const row of preview.tickets)
|
|
1313
|
+
console.log(` ${row.id}: ${row.title} [${row.status}] — clears ${row.cleared.join(", ")}`);
|
|
1314
|
+
if (preview.relatedRuns.length) {
|
|
1315
|
+
console.log(" ! Matching build/recovery metadata is not deleted. Use `rafi build:start-over` for a coherent whole-run restart.");
|
|
1316
|
+
for (const run of preview.relatedRuns)
|
|
1317
|
+
console.log(` ${run.runId.slice(0, 8)} [${run.status}] ${run.tickets.join(", ")}`);
|
|
1318
|
+
}
|
|
1319
|
+
if (!preview.tickets.length)
|
|
1320
|
+
return;
|
|
1321
|
+
const explicitlyConfirmed = Boolean(opts.yes && (ticketId || opts.scope));
|
|
1322
|
+
if (!explicitlyConfirmed) {
|
|
1323
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1324
|
+
throw new Error("confirmation requires a TTY; pass --yes with an explicit ticket or --scope");
|
|
1325
|
+
const approved = await confirm({ message: `Reset ${preview.tickets.length} ticket(s)? Historical events and validation remain available.`, initialValue: false });
|
|
1326
|
+
if (isCancel(approved) || !approved) {
|
|
1327
|
+
console.log("foreman tickets: reset cancelled; nothing changed");
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const result = resetTickets(cwd(opts), target, String(opts.actor));
|
|
1332
|
+
console.log(`foreman tickets: reset ${result.tickets.length} ticket(s) (audit ${result.resetId})`);
|
|
1333
|
+
}
|
|
1334
|
+
catch (err) {
|
|
1335
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
999
1338
|
tickets
|
|
1000
1339
|
.command("update <ticketId>")
|
|
1001
1340
|
.description("Update ticket status or progress fields.")
|