ai-foreman 1.7.5 → 1.7.7
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 +17 -2
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +29 -4
- package/dist/adapters/claude.d.ts +33 -2
- package/dist/adapters/claude.js +208 -17
- package/dist/adapters/codex.d.ts +11 -1
- package/dist/adapters/codex.js +195 -20
- package/dist/adapters/recovering.d.ts +8 -2
- package/dist/adapters/recovering.js +23 -3
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +52 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +34 -0
- package/dist/branch/currentGuard.js +65 -0
- package/dist/branch/github.js +3 -2
- package/dist/branch/gitlab.js +3 -2
- package/dist/branch/planner.d.ts +5 -0
- package/dist/branch/planner.js +37 -3
- package/dist/branch/prefix.d.ts +8 -0
- package/dist/branch/prefix.js +26 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +8 -0
- package/dist/branch/runner.d.ts +14 -2
- package/dist/branch/runner.js +77 -17
- package/dist/buildRuns.d.ts +51 -18
- package/dist/buildRuns.js +318 -31
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +1040 -62
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +615 -80
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -0
- package/dist/foreman.d.ts +9 -5
- package/dist/foreman.js +76 -20
- package/dist/handoffs.d.ts +69 -0
- package/dist/handoffs.js +326 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.d.ts +5 -1
- package/dist/permissions/policy.js +48 -1
- package/dist/qaReview.d.ts +7 -6
- package/dist/qaReview.js +31 -20
- package/dist/sessionAvailability.d.ts +15 -0
- package/dist/sessionAvailability.js +57 -0
- package/dist/sessionIdentity.d.ts +33 -0
- package/dist/sessionIdentity.js +132 -0
- package/dist/sessionLifecycle.d.ts +102 -3
- package/dist/sessionLifecycle.js +578 -20
- package/dist/statusReporter.d.ts +58 -8
- package/dist/statusReporter.js +210 -14
- package/dist/ticketPlanning.js +8 -0
- package/dist/ticketPopulation.js +88 -12
- package/dist/tickets/commands.js +20 -12
- 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/groupReset.d.ts +25 -0
- package/dist/tickets/groupReset.js +279 -0
- package/dist/tickets/groups.d.ts +36 -0
- package/dist/tickets/groups.js +68 -0
- package/dist/tickets/importer.js +27 -3
- package/dist/tickets/recommendations.js +31 -7
- package/dist/tickets/reset.d.ts +28 -0
- package/dist/tickets/reset.js +214 -0
- package/dist/tickets/reviewStandards.d.ts +7 -0
- package/dist/tickets/reviewStandards.js +53 -0
- package/dist/tickets/setupConfig.d.ts +23 -0
- package/dist/tickets/setupConfig.js +115 -3
- package/dist/tickets/stateDb.d.ts +42 -1
- package/dist/tickets/stateDb.js +261 -23
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +102 -2
- package/dist/workflowDb.js +460 -8
- package/package.json +35 -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,35 @@ 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
|
-
import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, TICKET_POPULATION_PROPOSAL_END, TICKET_POPULATION_PROPOSAL_START } from "../ticketPopulation.js";
|
|
20
|
+
import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, recoverTicketPublications, 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 { applyResolvedTicketReset, previewTicketReset, recoverTicketResetPublications, resetTickets, resolveTicketResetSelection, TicketResetDependencyConflictError } from "../tickets/reset.js";
|
|
25
|
+
import { validateBranchPrefix } from "../branch/prefix.js";
|
|
26
|
+
import { listTicketGroups, previewTicketGroupRepair, repairTicketGroups } from "../tickets/groups.js";
|
|
27
|
+
let nestedTicketCommandDepth = 0;
|
|
28
|
+
class TicketInterviewCancelled extends Error {
|
|
29
|
+
}
|
|
30
|
+
function cancelTicketInterview() { throw new TicketInterviewCancelled("ticket interview cancelled"); }
|
|
23
31
|
function fail(msg) {
|
|
32
|
+
if (nestedTicketCommandDepth > 0)
|
|
33
|
+
throw new Error(msg);
|
|
24
34
|
console.error(`foreman tickets: ${msg}`);
|
|
25
35
|
process.exit(1);
|
|
26
36
|
}
|
|
37
|
+
async function runNestedTicketCommand(action) {
|
|
38
|
+
nestedTicketCommandDepth += 1;
|
|
39
|
+
try {
|
|
40
|
+
return await action();
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
nestedTicketCommandDepth -= 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
27
46
|
function cwd(opts) {
|
|
28
47
|
return resolve(opts.project ?? ".");
|
|
29
48
|
}
|
|
@@ -148,6 +167,8 @@ async function cmdSetupInitCli(opts) {
|
|
|
148
167
|
if (hasTicketSetupConfig(dir)) {
|
|
149
168
|
console.log("foreman tickets setup: existing setup found; opening setup:update");
|
|
150
169
|
await cmdSetupUpdateCli(opts);
|
|
170
|
+
const existing = loadTicketSetupConfigWithDefaults(dir);
|
|
171
|
+
await continueTrackerSetup(dir, opts, existing);
|
|
151
172
|
if (interview)
|
|
152
173
|
completeInterview(dir, interview);
|
|
153
174
|
return;
|
|
@@ -168,41 +189,74 @@ async function cmdSetupInitCli(opts) {
|
|
|
168
189
|
targets: parseRuntimeTargets(opts.runtime),
|
|
169
190
|
});
|
|
170
191
|
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
|
-
}
|
|
192
|
+
if (interview)
|
|
193
|
+
interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
|
|
194
|
+
await continueTrackerSetup(dir, opts, answers);
|
|
197
195
|
if (interview)
|
|
198
196
|
completeInterview(dir, interview);
|
|
199
197
|
}
|
|
200
198
|
catch (error) {
|
|
201
|
-
if (interview)
|
|
199
|
+
if (interview && !(error instanceof TicketInterviewCancelled))
|
|
202
200
|
failInterview(dir, interview, interview.checkpoint, error);
|
|
203
201
|
throw error;
|
|
204
202
|
}
|
|
205
203
|
}
|
|
204
|
+
async function continueTrackerSetup(dir, opts, setup) {
|
|
205
|
+
if (!isTicketsInitialized(dir)) {
|
|
206
|
+
let initialize = true;
|
|
207
|
+
if (shouldPrompt(opts)) {
|
|
208
|
+
const answer = await confirm({ message: "Initialize the ticket tracker now?", initialValue: true });
|
|
209
|
+
if (isCancel(answer))
|
|
210
|
+
return;
|
|
211
|
+
initialize = Boolean(answer);
|
|
212
|
+
}
|
|
213
|
+
if (!initialize) {
|
|
214
|
+
console.log("foreman tickets setup: preferences were saved. Ticket infrastructure is still required before building.");
|
|
215
|
+
console.log(` rafi tickets init --project ${shellQuote(dir)}`);
|
|
216
|
+
console.log(` rafi tickets populate --project ${shellQuote(dir)}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const initialized = await retrySetupStage("ticket initialization", async () => {
|
|
220
|
+
const appName = opts.appName ?? await resolveInitAppName(dir, Boolean(opts.yes || opts.defaults));
|
|
221
|
+
cmdInit(dir, { appName, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, docsRoot: opts.docsRoot, implementationLimit: setup.limits.implementation, viewLimit: setup.limits.view });
|
|
222
|
+
syncTicketLimits(dir, setup.limits);
|
|
223
|
+
}, shouldPrompt(opts));
|
|
224
|
+
if (!initialized)
|
|
225
|
+
return;
|
|
226
|
+
console.log("foreman tickets setup: initialized .tickets/");
|
|
227
|
+
}
|
|
228
|
+
if (shouldPrompt(opts)) {
|
|
229
|
+
const populate = await confirm({ message: "Populate tickets now?", initialValue: true });
|
|
230
|
+
if (isCancel(populate))
|
|
231
|
+
return;
|
|
232
|
+
if (populate)
|
|
233
|
+
await retrySetupStage("ticket population", () => runNestedTicketCommand(() => cmdPopulateCli({ project: dir, yes: true })), true);
|
|
234
|
+
else {
|
|
235
|
+
console.log("foreman tickets setup: population is the final prerequisite before building.");
|
|
236
|
+
console.log(`foreman tickets setup: run \`rafi tickets populate --project ${shellQuote(dir)}\` later.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
console.log(`foreman tickets setup: next — run \`rafi tickets populate --project ${shellQuote(dir)}\``);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function retrySetupStage(label, action, interactive) {
|
|
244
|
+
while (true) {
|
|
245
|
+
try {
|
|
246
|
+
await action();
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (!interactive)
|
|
251
|
+
throw error;
|
|
252
|
+
console.error(`foreman tickets setup: ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
253
|
+
console.error("Fix the reported problem, then press Enter to retry; enter q to leave setup with saved progress.");
|
|
254
|
+
const answer = await text({ message: `Retry ${label}?`, defaultValue: "" });
|
|
255
|
+
if (isCancel(answer) || String(answer).trim().toLowerCase() === "q")
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
206
260
|
async function cmdSetupUpdateCli(opts) {
|
|
207
261
|
const dir = cwd(opts);
|
|
208
262
|
if (!existsSync(dir))
|
|
@@ -225,6 +279,7 @@ async function cmdSetupUpdateCli(opts) {
|
|
|
225
279
|
docsRoot: opts.docsRoot,
|
|
226
280
|
targets: parseRuntimeTargets(opts.runtime),
|
|
227
281
|
});
|
|
282
|
+
syncTicketLimits(dir, next.limits);
|
|
228
283
|
console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
229
284
|
if (isTicketsInitialized(dir)) {
|
|
230
285
|
const rows = cmdQueue(dir, 1);
|
|
@@ -236,7 +291,7 @@ async function cmdSetupUpdateCli(opts) {
|
|
|
236
291
|
completeInterview(dir, interview);
|
|
237
292
|
}
|
|
238
293
|
catch (error) {
|
|
239
|
-
if (interview)
|
|
294
|
+
if (interview && !(error instanceof TicketInterviewCancelled))
|
|
240
295
|
failInterview(dir, interview, interview.checkpoint, error);
|
|
241
296
|
throw error;
|
|
242
297
|
}
|
|
@@ -268,6 +323,8 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
268
323
|
populatePatch.agent_preference = parseAgentPreference(opts.agentPreference);
|
|
269
324
|
if (opts.branchStrategy)
|
|
270
325
|
buildPatch.branch_strategy = parseBranchStrategy(opts.branchStrategy);
|
|
326
|
+
if (opts.branchPrefix)
|
|
327
|
+
buildPatch.branch_prefix = validateBranchPrefix(opts.branchPrefix);
|
|
271
328
|
if (opts.completion)
|
|
272
329
|
buildPatch.completion = parseCompletionMode(opts.completion);
|
|
273
330
|
if (opts.provider)
|
|
@@ -302,38 +359,119 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
302
359
|
{ value: "sources", label: "Ticket sources" },
|
|
303
360
|
{ value: "populate", label: "Populate defaults" },
|
|
304
361
|
{ value: "build", label: "Build defaults" },
|
|
362
|
+
{ value: "limits", label: "Tracker limits" },
|
|
305
363
|
{ value: "all", label: "All sections" },
|
|
306
364
|
],
|
|
307
365
|
});
|
|
308
366
|
if (isCancel(section))
|
|
309
|
-
|
|
367
|
+
cancelTicketInterview();
|
|
310
368
|
// Do not silently persist planning hints over a completed ticket setup. They
|
|
311
369
|
// are only the initial local-source suggestion for a fresh setup.
|
|
312
370
|
const planningPrefill = current.sources.length === 0
|
|
313
371
|
? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
|
|
314
372
|
: current.sources;
|
|
315
373
|
let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : planningPrefill;
|
|
374
|
+
let limits = current.limits;
|
|
316
375
|
if (section === "sources" || section === "all") {
|
|
317
376
|
sources = await promptTicketSources(dir, current.sources);
|
|
318
377
|
}
|
|
378
|
+
if (section === "limits" || section === "all") {
|
|
379
|
+
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.");
|
|
380
|
+
const keep = await confirm({ message: "Keep both default limits?", initialValue: true });
|
|
381
|
+
if (isCancel(keep))
|
|
382
|
+
cancelTicketInterview();
|
|
383
|
+
limits = keep ? { implementation: 500, view: 20_000 } : {
|
|
384
|
+
implementation: await promptPositiveInteger("Implementation window limit:", current.limits.implementation),
|
|
385
|
+
view: await promptPositiveInteger("View limit:", current.limits.view),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
319
388
|
if (section === "populate" || section === "all") {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
389
|
+
const defaults = await confirm({ message: "Use the recommended population settings?", initialValue: true });
|
|
390
|
+
if (isCancel(defaults))
|
|
391
|
+
cancelTicketInterview();
|
|
392
|
+
if (defaults)
|
|
393
|
+
Object.assign(populatePatch, DEFAULT_TICKET_SETUP.populate);
|
|
394
|
+
else {
|
|
395
|
+
const handling = await select({ message: "How should saved sources be handled?", initialValue: current.populate.source_handling, options: [
|
|
396
|
+
{ value: "saved", label: "Use saved sources" }, { value: "prompt", label: "Ask each time" }, { value: "manual", label: "Manual only" },
|
|
397
|
+
] });
|
|
398
|
+
if (isCancel(handling))
|
|
399
|
+
cancelTicketInterview();
|
|
400
|
+
populatePatch.source_handling = handling;
|
|
401
|
+
const agent = await select({ message: "When both runtimes are configured, which should populate use?", initialValue: current.populate.agent_preference, options: [
|
|
402
|
+
{ value: "configured", label: "Configured project default" }, { value: "claude", label: "Claude" }, { value: "codex", label: "Codex" },
|
|
403
|
+
] });
|
|
404
|
+
if (isCancel(agent))
|
|
405
|
+
cancelTicketInterview();
|
|
406
|
+
populatePatch.agent_preference = agent;
|
|
407
|
+
populatePatch.import_cap = await promptPositiveInteger("Maximum tickets imported in one population run (separate from the implementation window):", current.populate.import_cap);
|
|
408
|
+
populatePatch.comment_limit = await promptNonNegativeInteger("Maximum comments imported per external ticket:", current.populate.comment_limit);
|
|
409
|
+
const enrichment = await select({ message: "Population enrichment policy:", initialValue: current.populate.enrichment, options: [
|
|
410
|
+
{ value: "recommendations", label: "Deterministic recommendations" }, { value: "agent", label: "Agent enrichment" }, { value: "none", label: "No enrichment" },
|
|
411
|
+
] });
|
|
412
|
+
if (isCancel(enrichment))
|
|
413
|
+
cancelTicketInterview();
|
|
414
|
+
populatePatch.enrichment = enrichment;
|
|
415
|
+
const split = await confirm({ message: "Recommend splitting XL tickets?", initialValue: current.populate.recommend_split_for_xl });
|
|
416
|
+
if (isCancel(split))
|
|
417
|
+
cancelTicketInterview();
|
|
418
|
+
populatePatch.recommend_split_for_xl = Boolean(split);
|
|
419
|
+
}
|
|
332
420
|
}
|
|
333
421
|
if (section === "build" || section === "all") {
|
|
334
422
|
const recommended = recommendedBuildDefaults(dir);
|
|
335
|
-
const
|
|
336
|
-
|
|
423
|
+
const base = await text({ message: "Base branch:", initialValue: current.build.base_branch || detectDefaultBranch(dir), defaultValue: detectDefaultBranch(dir) });
|
|
424
|
+
if (isCancel(base))
|
|
425
|
+
cancelTicketInterview();
|
|
426
|
+
buildPatch.base_branch = String(base).trim();
|
|
427
|
+
const prefix = await text({ message: "Branch prefix:", initialValue: current.build.branch_prefix, defaultValue: current.build.branch_prefix, validate: (value) => {
|
|
428
|
+
try {
|
|
429
|
+
validateBranchPrefix(String(value));
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
return error instanceof Error ? error.message : String(error);
|
|
434
|
+
}
|
|
435
|
+
} });
|
|
436
|
+
if (isCancel(prefix))
|
|
437
|
+
cancelTicketInterview();
|
|
438
|
+
buildPatch.branch_prefix = validateBranchPrefix(String(prefix));
|
|
439
|
+
const policyMode = await select({ message: "Use one global branch strategy or choose by inferred ticket size?", initialValue: current.build.branch_policy.mode, options: [
|
|
440
|
+
{ value: "size", label: "Size-based: XS/S shared, M/L/XL per ticket (Recommended)" },
|
|
441
|
+
{ value: "global", label: "One global strategy" },
|
|
442
|
+
] });
|
|
443
|
+
if (isCancel(policyMode))
|
|
444
|
+
cancelTicketInterview();
|
|
445
|
+
let sizeMapping = { XS: "shared", S: "shared", M: "per-ticket", L: "per-ticket", XL: "per-ticket" };
|
|
446
|
+
if (policyMode === "size") {
|
|
447
|
+
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.");
|
|
448
|
+
const keepMapping = await confirm({ message: "Keep the recommended size mapping?", initialValue: true });
|
|
449
|
+
if (isCancel(keepMapping))
|
|
450
|
+
cancelTicketInterview();
|
|
451
|
+
if (!keepMapping) {
|
|
452
|
+
sizeMapping = { ...sizeMapping };
|
|
453
|
+
for (const size of ["XS", "S", "M", "L", "XL"]) {
|
|
454
|
+
const allocation = await select({
|
|
455
|
+
message: `${size} ticket branch allocation:`,
|
|
456
|
+
initialValue: current.build.branch_policy.by_size[size],
|
|
457
|
+
options: [
|
|
458
|
+
{ value: "shared", label: "Share with compatible tickets selected in this run" },
|
|
459
|
+
{ value: "per-ticket", label: "Use a separate branch per ticket" },
|
|
460
|
+
],
|
|
461
|
+
});
|
|
462
|
+
if (isCancel(allocation))
|
|
463
|
+
cancelTicketInterview();
|
|
464
|
+
sizeMapping[size] = allocation;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
buildPatch.branch_policy = {
|
|
469
|
+
...current.build.branch_policy,
|
|
470
|
+
mode: policyMode,
|
|
471
|
+
by_size: policyMode === "size" ? sizeMapping : current.build.branch_policy.by_size,
|
|
472
|
+
};
|
|
473
|
+
const strategy = policyMode === "size" ? "branch-per-ticket" : await select({
|
|
474
|
+
message: "Default global ticket work mode:",
|
|
337
475
|
initialValue: current.build.branch_strategy,
|
|
338
476
|
options: [
|
|
339
477
|
{ value: "current", label: "One branch - work the queue on the current branch" },
|
|
@@ -342,8 +480,9 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
342
480
|
],
|
|
343
481
|
});
|
|
344
482
|
if (isCancel(strategy))
|
|
345
|
-
|
|
483
|
+
cancelTicketInterview();
|
|
346
484
|
buildPatch.branch_strategy = strategy;
|
|
485
|
+
buildPatch.branch_policy.global_strategy = buildPatch.branch_strategy;
|
|
347
486
|
if (strategy === "current") {
|
|
348
487
|
buildPatch.completion = "none";
|
|
349
488
|
buildPatch.provider = "local";
|
|
@@ -352,8 +491,10 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
352
491
|
buildPatch.cleanup = true;
|
|
353
492
|
buildPatch.auto_merge_wait = false;
|
|
354
493
|
buildPatch.auto_merge_timeout_minutes = null;
|
|
494
|
+
buildPatch.validation_checklist = await promptRequirementList("validation checklist", current.build.validation_checklist);
|
|
355
495
|
return {
|
|
356
496
|
sources,
|
|
497
|
+
limits,
|
|
357
498
|
populate: populatePatch,
|
|
358
499
|
build: buildPatch,
|
|
359
500
|
};
|
|
@@ -369,19 +510,43 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
369
510
|
],
|
|
370
511
|
});
|
|
371
512
|
if (isCancel(completion))
|
|
372
|
-
|
|
513
|
+
cancelTicketInterview();
|
|
373
514
|
buildPatch.completion = completion;
|
|
374
515
|
buildPatch.provider = recommended.provider;
|
|
375
516
|
buildPatch.pr_ready = completion === "auto-merge";
|
|
376
517
|
buildPatch.merge_method = "squash";
|
|
377
518
|
buildPatch.cleanup = true;
|
|
519
|
+
if (completion === "pr" || completion === "auto-merge") {
|
|
520
|
+
const title = await select({ message: "PR/MR title standard:", initialValue: current.build.review.title_style, options: [
|
|
521
|
+
{ value: "ticket-title", label: "T123: Add password reset (Recommended)" },
|
|
522
|
+
{ value: "conventional", label: "Conventional Commit" },
|
|
523
|
+
{ value: "none", label: "No required format" },
|
|
524
|
+
{ value: "custom", label: "Custom template" },
|
|
525
|
+
] });
|
|
526
|
+
if (isCancel(title))
|
|
527
|
+
cancelTicketInterview();
|
|
528
|
+
let template = null;
|
|
529
|
+
if (title === "custom") {
|
|
530
|
+
const answer = await text({ message: "Custom title template (use {id} and {title}):" });
|
|
531
|
+
if (isCancel(answer))
|
|
532
|
+
cancelTicketInterview();
|
|
533
|
+
template = String(answer);
|
|
534
|
+
}
|
|
535
|
+
buildPatch.review = {
|
|
536
|
+
...current.build.review,
|
|
537
|
+
title_style: title,
|
|
538
|
+
title_template: template,
|
|
539
|
+
description_sections: await promptRequirementList("required PR/MR description sections", current.build.review.description_sections),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
buildPatch.validation_checklist = await promptRequirementList("validation checklist", current.build.validation_checklist);
|
|
378
543
|
if (completion === "auto-merge") {
|
|
379
544
|
const wait = await confirm({
|
|
380
545
|
message: "Wait for dependency PR/MRs to merge before starting dependent tickets?",
|
|
381
546
|
initialValue: current.build.auto_merge_wait,
|
|
382
547
|
});
|
|
383
548
|
if (isCancel(wait))
|
|
384
|
-
|
|
549
|
+
cancelTicketInterview();
|
|
385
550
|
buildPatch.auto_merge_wait = Boolean(wait);
|
|
386
551
|
if (wait) {
|
|
387
552
|
const timeout = await text({
|
|
@@ -397,7 +562,7 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
397
562
|
},
|
|
398
563
|
});
|
|
399
564
|
if (isCancel(timeout))
|
|
400
|
-
|
|
565
|
+
cancelTicketInterview();
|
|
401
566
|
buildPatch.auto_merge_timeout_minutes = String(timeout).trim()
|
|
402
567
|
? Number.parseInt(String(timeout).trim(), 10)
|
|
403
568
|
: null;
|
|
@@ -413,10 +578,60 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
413
578
|
}
|
|
414
579
|
return {
|
|
415
580
|
sources,
|
|
581
|
+
limits,
|
|
416
582
|
populate: populatePatch,
|
|
417
583
|
build: buildPatch,
|
|
418
584
|
};
|
|
419
585
|
}
|
|
586
|
+
async function promptPositiveInteger(message, initial) {
|
|
587
|
+
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" });
|
|
588
|
+
if (isCancel(answer))
|
|
589
|
+
cancelTicketInterview();
|
|
590
|
+
return Number(answer);
|
|
591
|
+
}
|
|
592
|
+
async function promptNonNegativeInteger(message, initial) {
|
|
593
|
+
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" });
|
|
594
|
+
if (isCancel(answer))
|
|
595
|
+
cancelTicketInterview();
|
|
596
|
+
return Number(answer);
|
|
597
|
+
}
|
|
598
|
+
async function promptRequirementList(label, initial) {
|
|
599
|
+
console.log(`Current recommended ${label}: ${initial.join("; ") || "none"}`);
|
|
600
|
+
const mode = await select({
|
|
601
|
+
message: `How should Rafi configure the ${label}?`,
|
|
602
|
+
initialValue: "keep",
|
|
603
|
+
options: [
|
|
604
|
+
{ value: "keep", label: "Keep all listed requirements (Recommended)" },
|
|
605
|
+
{ value: "choose", label: "Choose individual requirements and optionally add custom ones" },
|
|
606
|
+
{ value: "replace", label: "Replace with a custom list" },
|
|
607
|
+
],
|
|
608
|
+
});
|
|
609
|
+
if (isCancel(mode))
|
|
610
|
+
cancelTicketInterview();
|
|
611
|
+
if (mode === "keep")
|
|
612
|
+
return [...initial];
|
|
613
|
+
if (mode === "replace") {
|
|
614
|
+
const replacement = await text({ message: `Custom ${label} (comma-separated):`, defaultValue: "" });
|
|
615
|
+
if (isCancel(replacement))
|
|
616
|
+
cancelTicketInterview();
|
|
617
|
+
return splitCommaList(String(replacement));
|
|
618
|
+
}
|
|
619
|
+
const selected = initial.length ? await multiselect({
|
|
620
|
+
message: `Select the ${label} to keep:`,
|
|
621
|
+
options: initial.map((value) => ({ value, label: value })),
|
|
622
|
+
initialValues: initial,
|
|
623
|
+
required: false,
|
|
624
|
+
}) : [];
|
|
625
|
+
if (isCancel(selected))
|
|
626
|
+
cancelTicketInterview();
|
|
627
|
+
const additions = await text({ message: `Additional custom ${label} (comma-separated, optional):`, defaultValue: "" });
|
|
628
|
+
if (isCancel(additions))
|
|
629
|
+
cancelTicketInterview();
|
|
630
|
+
return combineRequirementSelections(selected, String(additions));
|
|
631
|
+
}
|
|
632
|
+
export function combineRequirementSelections(selected, additions) {
|
|
633
|
+
return [...new Set([...selected, ...splitCommaList(additions)])];
|
|
634
|
+
}
|
|
420
635
|
async function promptTicketSources(dir, current) {
|
|
421
636
|
const kind = await select({
|
|
422
637
|
message: "Primary ticket source:",
|
|
@@ -429,7 +644,7 @@ async function promptTicketSources(dir, current) {
|
|
|
429
644
|
],
|
|
430
645
|
});
|
|
431
646
|
if (isCancel(kind))
|
|
432
|
-
|
|
647
|
+
cancelTicketInterview();
|
|
433
648
|
if (kind === "none")
|
|
434
649
|
return [];
|
|
435
650
|
if (kind === "local") {
|
|
@@ -440,16 +655,16 @@ async function promptTicketSources(dir, current) {
|
|
|
440
655
|
defaultValue: existing?.paths.join(", ") || `${configuredDocsPlanPath(dir)}`,
|
|
441
656
|
});
|
|
442
657
|
if (isCancel(answer))
|
|
443
|
-
|
|
658
|
+
cancelTicketInterview();
|
|
444
659
|
return [{ type: "local", paths: splitCommaList(String(answer)) }];
|
|
445
660
|
}
|
|
446
661
|
if (kind === "linear") {
|
|
447
662
|
const team = await text({ message: "Linear team key (optional):" });
|
|
448
663
|
if (isCancel(team))
|
|
449
|
-
|
|
664
|
+
cancelTicketInterview();
|
|
450
665
|
const filter = await text({ message: "Linear IssueFilter JSON or title search text (optional):" });
|
|
451
666
|
if (isCancel(filter))
|
|
452
|
-
|
|
667
|
+
cancelTicketInterview();
|
|
453
668
|
return [{
|
|
454
669
|
type: "linear",
|
|
455
670
|
api_key_env: "LINEAR_API_KEY",
|
|
@@ -465,7 +680,7 @@ async function promptTicketSources(dir, current) {
|
|
|
465
680
|
validate: (value) => /^https?:\/\//i.test(String(value ?? "")) ? undefined : "Enter an HTTP(S) URL",
|
|
466
681
|
});
|
|
467
682
|
if (isCancel(answer))
|
|
468
|
-
|
|
683
|
+
cancelTicketInterview();
|
|
469
684
|
return [{ type: "url", url: String(answer).trim() }];
|
|
470
685
|
}
|
|
471
686
|
const site = await text({
|
|
@@ -474,14 +689,14 @@ async function promptTicketSources(dir, current) {
|
|
|
474
689
|
validate: (value) => String(value ?? "").trim() ? undefined : "Enter a Jira Cloud site URL",
|
|
475
690
|
});
|
|
476
691
|
if (isCancel(site))
|
|
477
|
-
|
|
692
|
+
cancelTicketInterview();
|
|
478
693
|
const jql = await text({
|
|
479
694
|
message: "Jira JQL:",
|
|
480
695
|
initialValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
|
|
481
696
|
defaultValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
|
|
482
697
|
});
|
|
483
698
|
if (isCancel(jql))
|
|
484
|
-
|
|
699
|
+
cancelTicketInterview();
|
|
485
700
|
return [{
|
|
486
701
|
type: "jira",
|
|
487
702
|
site: String(site).trim(),
|
|
@@ -568,6 +783,27 @@ function parseAgentPreference(value) {
|
|
|
568
783
|
return value;
|
|
569
784
|
fail("--agent-preference must be one of: configured, claude, codex");
|
|
570
785
|
}
|
|
786
|
+
function parseResetScope(value) {
|
|
787
|
+
if (["all", "completed-and-unfinished", "unfinished"].includes(value))
|
|
788
|
+
return value;
|
|
789
|
+
throw new Error("--scope must be all, completed-and-unfinished, or unfinished");
|
|
790
|
+
}
|
|
791
|
+
function parsePositiveInteger(value, label) {
|
|
792
|
+
const parsed = Number.parseInt(value, 10);
|
|
793
|
+
if (Number.isInteger(parsed) && parsed > 0 && String(parsed) === value.trim())
|
|
794
|
+
return parsed;
|
|
795
|
+
throw new Error(`${label} must be a positive integer`);
|
|
796
|
+
}
|
|
797
|
+
function parseTicketGroupId(value) {
|
|
798
|
+
if (!/^TG-[1-9]\d*$/.test(value))
|
|
799
|
+
throw new Error("--group must use a stable ID such as TG-1");
|
|
800
|
+
return value;
|
|
801
|
+
}
|
|
802
|
+
function parseDeletedTicketPolicy(value) {
|
|
803
|
+
if (value === "ignore" || value === "restore")
|
|
804
|
+
return value;
|
|
805
|
+
throw new Error("--deleted-tickets must be ignore or restore");
|
|
806
|
+
}
|
|
571
807
|
function parseOptionalPositiveInteger(value, label) {
|
|
572
808
|
if (!value.trim())
|
|
573
809
|
return null;
|
|
@@ -599,19 +835,20 @@ function configuredDocsPlanPath(dir) {
|
|
|
599
835
|
return "docs/rafi-plan.md";
|
|
600
836
|
}
|
|
601
837
|
async function resolveInitAppName(dir, yes) {
|
|
602
|
-
const
|
|
603
|
-
if (
|
|
604
|
-
return
|
|
838
|
+
const established = configuredAppName(dir);
|
|
839
|
+
if (established)
|
|
840
|
+
return established;
|
|
841
|
+
const detected = detectPackageName(dir) ?? "My App";
|
|
605
842
|
if (yes || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
606
|
-
return
|
|
843
|
+
return detected;
|
|
607
844
|
const answer = await text({
|
|
608
845
|
message: "App name:",
|
|
609
|
-
initialValue:
|
|
610
|
-
defaultValue:
|
|
846
|
+
initialValue: detected,
|
|
847
|
+
defaultValue: detected,
|
|
611
848
|
});
|
|
612
849
|
if (isCancel(answer))
|
|
613
|
-
|
|
614
|
-
return String(answer).trim() ||
|
|
850
|
+
cancelTicketInterview();
|
|
851
|
+
return String(answer).trim() || detected;
|
|
615
852
|
}
|
|
616
853
|
async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, yes) {
|
|
617
854
|
if (explicitSources?.length)
|
|
@@ -629,7 +866,7 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
|
|
|
629
866
|
initialValue: true,
|
|
630
867
|
});
|
|
631
868
|
if (isCancel(usePlan))
|
|
632
|
-
|
|
869
|
+
return undefined;
|
|
633
870
|
return usePlan ? defaultPlan : undefined;
|
|
634
871
|
}
|
|
635
872
|
if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
@@ -639,7 +876,7 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
|
|
|
639
876
|
console.log(" rafi tickets setup:init");
|
|
640
877
|
console.log(" rafi tickets populate --sources <files-or-globs>");
|
|
641
878
|
console.log(" edit .tickets/tickets.yaml manually");
|
|
642
|
-
|
|
879
|
+
throw new Error("no ticket sources were found; create a plan, configure sources, or pass --sources");
|
|
643
880
|
}
|
|
644
881
|
const action = await select({
|
|
645
882
|
message: "No ticket sources were found. What should happen next?",
|
|
@@ -652,22 +889,22 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
|
|
|
652
889
|
});
|
|
653
890
|
if (isCancel(action) || action === "none") {
|
|
654
891
|
console.log("foreman tickets: cancelled");
|
|
655
|
-
|
|
892
|
+
return undefined;
|
|
656
893
|
}
|
|
657
894
|
if (action === "setup") {
|
|
658
895
|
await cmdSetupInitCli({ project: dir });
|
|
659
|
-
|
|
896
|
+
return undefined;
|
|
660
897
|
}
|
|
661
898
|
if (action === "plan") {
|
|
662
899
|
console.log(`foreman tickets: run \`rafi plan ${shellQuote(dir)}\`, then \`rafi tickets populate --project ${shellQuote(dir)}\`.`);
|
|
663
|
-
|
|
900
|
+
return undefined;
|
|
664
901
|
}
|
|
665
902
|
const entered = await text({
|
|
666
903
|
message: "Source paths or globs, comma-separated:",
|
|
667
904
|
validate: (value) => String(value ?? "").trim() ? undefined : "Enter at least one source path or glob",
|
|
668
905
|
});
|
|
669
906
|
if (isCancel(entered))
|
|
670
|
-
|
|
907
|
+
return undefined;
|
|
671
908
|
return splitCommaList(String(entered));
|
|
672
909
|
}
|
|
673
910
|
function reviewActionFromOptions(opts) {
|
|
@@ -733,7 +970,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
733
970
|
});
|
|
734
971
|
if (isCancel(action) || action === "cancel") {
|
|
735
972
|
console.log("foreman tickets: cancelled");
|
|
736
|
-
|
|
973
|
+
return;
|
|
737
974
|
}
|
|
738
975
|
}
|
|
739
976
|
const results = await importExternalSources(dir, configuredExternalSources, {
|
|
@@ -751,7 +988,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
751
988
|
console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
|
|
752
989
|
console.log(formatValidationIssues(validation.issues));
|
|
753
990
|
if (!validation.clean)
|
|
754
|
-
|
|
991
|
+
throw new Error("external ticket import failed tracker validation");
|
|
755
992
|
}
|
|
756
993
|
if (!sourceHints.length) {
|
|
757
994
|
console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
|
|
@@ -769,7 +1006,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
769
1006
|
});
|
|
770
1007
|
if (isCancel(action) || action === "cancel") {
|
|
771
1008
|
console.log("foreman tickets: cancelled");
|
|
772
|
-
|
|
1009
|
+
return;
|
|
773
1010
|
}
|
|
774
1011
|
}
|
|
775
1012
|
const logPath = makeLogPath(dir, "tickets-populate");
|
|
@@ -811,12 +1048,10 @@ export async function cmdPopulateCli(opts) {
|
|
|
811
1048
|
fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
|
|
812
1049
|
}
|
|
813
1050
|
if (turn.status.kind === "blocked") {
|
|
814
|
-
|
|
815
|
-
process.exit(2);
|
|
1051
|
+
throw new Error(`blocked — ${turn.status.reason ?? "builder reported blocked"}`);
|
|
816
1052
|
}
|
|
817
1053
|
if (turn.status.kind !== "done" && turn.status.kind !== "plan_complete") {
|
|
818
|
-
|
|
819
|
-
process.exit(2);
|
|
1054
|
+
throw new Error(`needs human — ${turn.status.error ?? "builder did not emit done"}`);
|
|
820
1055
|
}
|
|
821
1056
|
const existing = loadTickets(resolveTicketPaths(ticketsConfig, dir).tickets);
|
|
822
1057
|
const proposal = extractTicketPopulationProposal(turn.result.text);
|
|
@@ -874,8 +1109,126 @@ function loadPopulationPlan(projectDir, sources) {
|
|
|
874
1109
|
}
|
|
875
1110
|
throw new Error("ticket population requires the approved paired structured plan (normally docs/rafi-plan.json); run `rafi plan --validate` first");
|
|
876
1111
|
}
|
|
877
|
-
|
|
1112
|
+
function ticketOwnershipReceipts(projectDir, commandName, _afterWrite) {
|
|
1113
|
+
const candidates = [
|
|
1114
|
+
".tickets/config.yaml", ".tickets/tickets.yaml", ".tickets/delivery.yaml", ".tickets/tracker-rules.md",
|
|
1115
|
+
".tickets/schema/tickets.schema.json", ".tickets/migrations/001_init.sql", ".tickets/.gitignore",
|
|
1116
|
+
];
|
|
1117
|
+
if (isTicketsInitialized(projectDir)) {
|
|
1118
|
+
const config = loadTicketsConfig(projectDir);
|
|
1119
|
+
candidates.push(config.paths.progressDoc, config.paths.archiveDoc);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
candidates.push(DEFAULT_TICKETS_CONFIG.paths.progressDoc, DEFAULT_TICKETS_CONFIG.paths.archiveDoc);
|
|
1123
|
+
}
|
|
1124
|
+
return unique(candidates).filter((path) => existsSync(join(projectDir, path))).map((path) => ({ path, category: "tickets", origin: `tickets:${commandName}` }));
|
|
1125
|
+
}
|
|
1126
|
+
async function runGroupResetCli(projectDir, opts) {
|
|
1127
|
+
const requested = opts.allGroups
|
|
1128
|
+
? { kind: "all-groups" }
|
|
1129
|
+
: opts.recentGroups !== undefined
|
|
1130
|
+
? { kind: "recent-groups", count: parsePositiveInteger(String(opts.recentGroups), "--recent-groups") }
|
|
1131
|
+
: opts.group !== undefined
|
|
1132
|
+
? { kind: "group", groupId: parseTicketGroupId(String(opts.group)) }
|
|
1133
|
+
: { kind: "group-index", position: parsePositiveInteger(String(opts.groupIndex), "--group-index") };
|
|
1134
|
+
const explicitPolicy = opts.deletedTickets === undefined ? undefined : parseDeletedTicketPolicy(String(opts.deletedTickets));
|
|
1135
|
+
const initial = resolveTicketResetSelection(projectDir, requested, { deletedTickets: "ignore" });
|
|
1136
|
+
const missing = initial.previewRows.filter((row) => row.definitionMissing);
|
|
1137
|
+
if (missing.length && !explicitPolicy && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
1138
|
+
throw new Error("group reset includes deleted ticket definitions; automation must pass --deleted-tickets ignore|restore");
|
|
1139
|
+
}
|
|
1140
|
+
let policy = explicitPolicy;
|
|
1141
|
+
const perTicket = {};
|
|
1142
|
+
const restoreDependencies = [];
|
|
1143
|
+
if (missing.length && !policy) {
|
|
1144
|
+
const answer = await select({ message: `${missing.length} group member definition(s) are missing. What should reset do?`, options: [
|
|
1145
|
+
{ value: "ignore", label: "Ignore all missing definitions (Recommended)" },
|
|
1146
|
+
{ value: "restore", label: "Restore all from Rafi snapshots" },
|
|
1147
|
+
{ value: "each", label: "Decide each one" },
|
|
1148
|
+
] });
|
|
1149
|
+
if (isCancel(answer)) {
|
|
1150
|
+
console.log("foreman tickets: reset cancelled; nothing changed");
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (answer === "each") {
|
|
1154
|
+
for (const row of missing) {
|
|
1155
|
+
const decision = await select({ message: `${row.ticketId}: ${row.title}`, options: [
|
|
1156
|
+
{ value: "ignore", label: "Ignore missing definition (Recommended)" }, { value: "restore", label: "Restore definition" },
|
|
1157
|
+
] });
|
|
1158
|
+
if (isCancel(decision)) {
|
|
1159
|
+
console.log("foreman tickets: reset cancelled; nothing changed");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
perTicket[row.ticketId] = decision;
|
|
1163
|
+
}
|
|
1164
|
+
policy = "ignore";
|
|
1165
|
+
}
|
|
1166
|
+
else
|
|
1167
|
+
policy = answer;
|
|
1168
|
+
}
|
|
1169
|
+
let selection;
|
|
1170
|
+
while (true) {
|
|
1171
|
+
try {
|
|
1172
|
+
selection = resolveTicketResetSelection(projectDir, requested, { deletedTickets: policy ?? "ignore", perTicket, restoreDependencies });
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
catch (error) {
|
|
1176
|
+
if (!(error instanceof TicketResetDependencyConflictError) || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
1177
|
+
throw error;
|
|
1178
|
+
for (const conflict of error.conflicts) {
|
|
1179
|
+
const decision = await select({ message: `${conflict.ticketId} depends on deleted definition ${conflict.dependencyId}.`, options: [
|
|
1180
|
+
{ value: "dependency", label: `Restore ${conflict.dependencyId} without resetting it (Recommended)` },
|
|
1181
|
+
{ value: "skip", label: `Skip restoring ${conflict.ticketId}` },
|
|
1182
|
+
{ value: "cancel", label: "Cancel reset" },
|
|
1183
|
+
] });
|
|
1184
|
+
if (isCancel(decision) || decision === "cancel") {
|
|
1185
|
+
console.log("foreman tickets: reset cancelled; nothing changed");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (decision === "dependency")
|
|
1189
|
+
restoreDependencies.push(conflict.dependencyId);
|
|
1190
|
+
else
|
|
1191
|
+
perTicket[conflict.ticketId] = "ignore";
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
console.log(`foreman tickets reset preview (${selection.groups.map((group) => group.id).join(", ") || requested.kind}):`);
|
|
1196
|
+
for (const row of selection.previewRows)
|
|
1197
|
+
console.log(` ${row.ticketId}: ${row.title} [${row.status}]${row.definitionMissing ? row.restoreDefinition ? " — restore definition and reset" : " — missing definition, skip" : " — reset active state"}`);
|
|
1198
|
+
for (const dependency of selection.definitionRestorations.filter((item) => item.dependencyOnly))
|
|
1199
|
+
console.log(` dependency ${dependency.ticketId}: restore definition only (do not reset)`);
|
|
1200
|
+
if (!selection.previewRows.length)
|
|
1201
|
+
return;
|
|
1202
|
+
if (!opts.yes) {
|
|
1203
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1204
|
+
throw new Error("confirmation requires a TTY or --yes");
|
|
1205
|
+
const approved = await confirm({ message: `Apply this frozen ${selection.previewRows.length}-member reset selection?`, initialValue: false });
|
|
1206
|
+
if (isCancel(approved) || !approved) {
|
|
1207
|
+
console.log("foreman tickets: reset cancelled; nothing changed");
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const result = applyResolvedTicketReset(projectDir, selection, String(opts.actor ?? "user"));
|
|
1212
|
+
console.log(`foreman tickets: reset ${result.tickets.length} ticket(s), restored ${result.restored.length}, ignored ${result.ignoredMissing.length} missing (audit ${result.resetId})`);
|
|
1213
|
+
}
|
|
1214
|
+
export function buildTicketsCommand(options = {}) {
|
|
878
1215
|
const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
|
|
1216
|
+
tickets.hook("preAction", (_thisCommand, actionCommand) => {
|
|
1217
|
+
const commandOptions = actionCommand.opts();
|
|
1218
|
+
const projectDir = cwd(commandOptions);
|
|
1219
|
+
recoverTicketPublications(projectDir);
|
|
1220
|
+
recoverTicketResetPublications(projectDir);
|
|
1221
|
+
if (options.prepareOwnership && !["show", "validate", "queue"].includes(actionCommand.name())) {
|
|
1222
|
+
options.prepareOwnership(projectDir, ticketOwnershipReceipts(projectDir, actionCommand.name(), false));
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
tickets.hook("postAction", (_thisCommand, actionCommand) => {
|
|
1226
|
+
if (!options.recordOwnership)
|
|
1227
|
+
return;
|
|
1228
|
+
const commandOptions = actionCommand.opts();
|
|
1229
|
+
const projectDir = cwd(commandOptions);
|
|
1230
|
+
options.recordOwnership(projectDir, ticketOwnershipReceipts(projectDir, actionCommand.name(), true));
|
|
1231
|
+
});
|
|
879
1232
|
// ── setup:init / setup:update ─────────────────────────────────────────────
|
|
880
1233
|
tickets
|
|
881
1234
|
.command("setup:init")
|
|
@@ -895,6 +1248,7 @@ export function buildTicketsCommand() {
|
|
|
895
1248
|
.option("--url-source <urls...>", "add public HTTP(S) source URLs")
|
|
896
1249
|
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
897
1250
|
.option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
|
|
1251
|
+
.option("--branch-prefix <prefix>", "prefix for Rafi-generated branches (for example team/feature)")
|
|
898
1252
|
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
899
1253
|
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
900
1254
|
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
@@ -905,6 +1259,10 @@ export function buildTicketsCommand() {
|
|
|
905
1259
|
await cmdSetupInitCli(opts);
|
|
906
1260
|
}
|
|
907
1261
|
catch (err) {
|
|
1262
|
+
if (err instanceof TicketInterviewCancelled) {
|
|
1263
|
+
console.log("foreman tickets setup: cancelled; saved checkpoints were retained");
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
908
1266
|
fail(String(err instanceof Error ? err.message : err));
|
|
909
1267
|
}
|
|
910
1268
|
});
|
|
@@ -926,6 +1284,7 @@ export function buildTicketsCommand() {
|
|
|
926
1284
|
.option("--url-source <urls...>", "replace saved sources with public HTTP(S) URLs")
|
|
927
1285
|
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
928
1286
|
.option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
|
|
1287
|
+
.option("--branch-prefix <prefix>", "prefix for Rafi-generated branches (for example team/feature)")
|
|
929
1288
|
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
930
1289
|
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
931
1290
|
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
@@ -936,6 +1295,10 @@ export function buildTicketsCommand() {
|
|
|
936
1295
|
await cmdSetupUpdateCli(opts);
|
|
937
1296
|
}
|
|
938
1297
|
catch (err) {
|
|
1298
|
+
if (err instanceof TicketInterviewCancelled) {
|
|
1299
|
+
console.log("foreman tickets setup: cancelled; saved checkpoints were retained");
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
939
1302
|
fail(String(err instanceof Error ? err.message : err));
|
|
940
1303
|
}
|
|
941
1304
|
});
|
|
@@ -969,9 +1332,24 @@ export function buildTicketsCommand() {
|
|
|
969
1332
|
docsRoot: opts.docsRoot,
|
|
970
1333
|
});
|
|
971
1334
|
console.log(`foreman tickets: initialized .tickets/ in ${dir}`);
|
|
972
|
-
|
|
1335
|
+
if (shouldPrompt(opts)) {
|
|
1336
|
+
const populate = await confirm({ message: "Populate tickets now?", initialValue: true });
|
|
1337
|
+
if (!isCancel(populate) && populate)
|
|
1338
|
+
await runNestedTicketCommand(() => cmdPopulateCli({ project: dir }));
|
|
1339
|
+
else {
|
|
1340
|
+
console.log("foreman tickets: population is the final prerequisite before building.");
|
|
1341
|
+
console.log(`foreman tickets: run \`rafi tickets populate --project ${shellQuote(dir)}\` later.`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
console.log(`foreman tickets: next — run \`rafi tickets populate --project ${shellQuote(dir)}\``);
|
|
1346
|
+
}
|
|
973
1347
|
}
|
|
974
1348
|
catch (err) {
|
|
1349
|
+
if (err instanceof TicketInterviewCancelled) {
|
|
1350
|
+
console.log("foreman tickets: initialization cancelled; no process exit was forced by the nested interview");
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
975
1353
|
fail(String(err instanceof Error ? err.message : err));
|
|
976
1354
|
}
|
|
977
1355
|
});
|
|
@@ -996,6 +1374,163 @@ export function buildTicketsCommand() {
|
|
|
996
1374
|
}
|
|
997
1375
|
});
|
|
998
1376
|
// ── update ──────────────────────────────────────────────────────────────────
|
|
1377
|
+
tickets
|
|
1378
|
+
.command("show <ticketId>")
|
|
1379
|
+
.description("Show the complete canonical ticket, active state, validation, delivery, and history.")
|
|
1380
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1381
|
+
.option("--json", "write the complete stable record as JSON")
|
|
1382
|
+
.action((ticketId, opts) => {
|
|
1383
|
+
try {
|
|
1384
|
+
const details = getTicketDetails(cwd(opts), ticketId);
|
|
1385
|
+
if (opts.json)
|
|
1386
|
+
process.stdout.write(`${JSON.stringify(details, null, 2)}\n`);
|
|
1387
|
+
else
|
|
1388
|
+
for (const line of formatTicketDetails(details))
|
|
1389
|
+
console.log(line);
|
|
1390
|
+
}
|
|
1391
|
+
catch (err) {
|
|
1392
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
const groups = tickets.command("groups").description("List and repair durable ticket creation groups.");
|
|
1396
|
+
groups
|
|
1397
|
+
.command("list")
|
|
1398
|
+
.description("List immutable ticket creation groups newest first.")
|
|
1399
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1400
|
+
.option("--json", "write the versioned stable JSON envelope")
|
|
1401
|
+
.action((opts) => {
|
|
1402
|
+
try {
|
|
1403
|
+
const envelope = listTicketGroups(cwd(opts));
|
|
1404
|
+
if (opts.json) {
|
|
1405
|
+
process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (!envelope.groups.length) {
|
|
1409
|
+
console.log("foreman tickets groups: no creation groups");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
for (const group of envelope.groups) {
|
|
1413
|
+
const totals = Object.entries(group.statusTotals).map(([status, count]) => `${status}=${count}`).join(", ");
|
|
1414
|
+
console.log(`${group.id} index=${group.recencyPosition} origin=${group.origin} created=${group.createdAt}${group.legacy ? " legacy" : ""} ${totals}`);
|
|
1415
|
+
for (const member of group.members)
|
|
1416
|
+
console.log(` ${member.position}. ${member.ticketId} [${member.status}]${member.definitionMissing ? " — definition missing (recoverable)" : ""}`);
|
|
1417
|
+
for (const run of group.recoverableRuns)
|
|
1418
|
+
console.log(` recoverable run ${run.runId} [${run.status}] ${run.tickets.join(", ")}`);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
fail(String(error instanceof Error ? error.message : error));
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
groups
|
|
1426
|
+
.command("repair")
|
|
1427
|
+
.description("Preview and group tickets created outside Rafi after the legacy migration.")
|
|
1428
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1429
|
+
.option("-y, --yes", "approve the exact preview")
|
|
1430
|
+
.action(async (opts) => {
|
|
1431
|
+
try {
|
|
1432
|
+
const dir = cwd(opts);
|
|
1433
|
+
const preview = previewTicketGroupRepair(dir);
|
|
1434
|
+
console.log("foreman tickets groups repair preview:");
|
|
1435
|
+
console.log(preview.ticketIds.length ? ` new repair group: ${preview.ticketIds.join(", ")}` : " no ungrouped tickets");
|
|
1436
|
+
if (!preview.ticketIds.length)
|
|
1437
|
+
return;
|
|
1438
|
+
if (!opts.yes) {
|
|
1439
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1440
|
+
throw new Error("repair approval requires a TTY or --yes");
|
|
1441
|
+
const approved = await confirm({ message: `Create one repair group for exactly ${preview.ticketIds.length} ticket(s)?`, initialValue: false });
|
|
1442
|
+
if (isCancel(approved) || !approved) {
|
|
1443
|
+
console.log("foreman tickets groups: repair cancelled; nothing changed");
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
const group = repairTicketGroups(dir, preview.inputFingerprint);
|
|
1448
|
+
console.log(group ? `foreman tickets groups: created ${group.id} for ${group.members.map((member) => member.ticketId).join(", ")}` : "foreman tickets groups: nothing to repair");
|
|
1449
|
+
}
|
|
1450
|
+
catch (error) {
|
|
1451
|
+
fail(String(error instanceof Error ? error.message : error));
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
tickets
|
|
1455
|
+
.command("reset [ticketId]")
|
|
1456
|
+
.description("Reset one ticket or an explicit ticket scope to pristine active state while retaining history.")
|
|
1457
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1458
|
+
.option("--all", "open the interactive bulk reset scope chooser")
|
|
1459
|
+
.option("--scope <scope>", "bulk scope (all | completed-and-unfinished | unfinished)")
|
|
1460
|
+
.option("--all-groups", "reset the exact membership of every durable creation group")
|
|
1461
|
+
.option("--recent-groups <count>", "reset the exact membership of the newest N creation groups")
|
|
1462
|
+
.option("--group <TG-N>", "reset one durable creation group by stable ID")
|
|
1463
|
+
.option("--group-index <position>", "reset one creation group by current newest-first position")
|
|
1464
|
+
.option("--deleted-tickets <policy>", "missing-definition policy for automation (ignore | restore)")
|
|
1465
|
+
.option("--actor <actor>", "who requested the reset", "user")
|
|
1466
|
+
.option("-y, --yes", "confirm an explicit ticket or --scope without prompting")
|
|
1467
|
+
.action(async (ticketId, opts) => {
|
|
1468
|
+
try {
|
|
1469
|
+
const groupTargetCount = [opts.allGroups, opts.recentGroups, opts.group, opts.groupIndex].filter((value) => value !== undefined && value !== false).length;
|
|
1470
|
+
if (groupTargetCount > 0) {
|
|
1471
|
+
if (groupTargetCount !== 1)
|
|
1472
|
+
throw new Error("choose exactly one group reset target");
|
|
1473
|
+
if (ticketId || opts.all || opts.scope)
|
|
1474
|
+
throw new Error("group reset targets cannot be combined with a ticket or scope target");
|
|
1475
|
+
await runGroupResetCli(cwd(opts), opts);
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
if (ticketId && (opts.all || opts.scope))
|
|
1479
|
+
throw new Error("provide a ticket ID or a bulk scope, not both");
|
|
1480
|
+
if (!ticketId && !opts.all && !opts.scope)
|
|
1481
|
+
throw new Error("provide <ticketId>, --all, --scope <scope>, or one group target");
|
|
1482
|
+
let target = ticketId;
|
|
1483
|
+
if (opts.scope)
|
|
1484
|
+
target = parseResetScope(String(opts.scope));
|
|
1485
|
+
if (opts.all && !opts.scope) {
|
|
1486
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1487
|
+
throw new Error("--all requires an interactive terminal; use --scope <scope> --yes for automation");
|
|
1488
|
+
const answer = await select({
|
|
1489
|
+
message: "Which tickets should return to an unworked state?",
|
|
1490
|
+
options: [
|
|
1491
|
+
{ value: "all", label: "All tickets" },
|
|
1492
|
+
{ value: "completed-and-unfinished", label: "Completed and unfinished (exclude canceled/obsolete)" },
|
|
1493
|
+
{ value: "unfinished", label: "Unfinished only (exclude done/canceled/obsolete)" },
|
|
1494
|
+
],
|
|
1495
|
+
});
|
|
1496
|
+
if (isCancel(answer)) {
|
|
1497
|
+
console.log("foreman tickets: reset cancelled; nothing changed");
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
target = answer;
|
|
1501
|
+
}
|
|
1502
|
+
if (!target)
|
|
1503
|
+
throw new Error("reset target was not selected");
|
|
1504
|
+
const preview = previewTicketReset(cwd(opts), target);
|
|
1505
|
+
console.log(`foreman tickets reset preview (${preview.scope}):`);
|
|
1506
|
+
if (!preview.tickets.length)
|
|
1507
|
+
console.log(" (no matching tickets)");
|
|
1508
|
+
for (const row of preview.tickets)
|
|
1509
|
+
console.log(` ${row.id}: ${row.title} [${row.status}] — clears ${row.cleared.join(", ")}`);
|
|
1510
|
+
if (preview.relatedRuns.length) {
|
|
1511
|
+
console.log(" ! Matching build/recovery metadata is not deleted. Use `rafi build:start-over` for a coherent whole-run restart.");
|
|
1512
|
+
for (const run of preview.relatedRuns)
|
|
1513
|
+
console.log(` ${run.runId.slice(0, 8)} [${run.status}] ${run.tickets.join(", ")}`);
|
|
1514
|
+
}
|
|
1515
|
+
if (!preview.tickets.length)
|
|
1516
|
+
return;
|
|
1517
|
+
const explicitlyConfirmed = Boolean(opts.yes && (ticketId || opts.scope));
|
|
1518
|
+
if (!explicitlyConfirmed) {
|
|
1519
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1520
|
+
throw new Error("confirmation requires a TTY; pass --yes with an explicit ticket or --scope");
|
|
1521
|
+
const approved = await confirm({ message: `Reset ${preview.tickets.length} ticket(s)? Historical events and validation remain available.`, initialValue: false });
|
|
1522
|
+
if (isCancel(approved) || !approved) {
|
|
1523
|
+
console.log("foreman tickets: reset cancelled; nothing changed");
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
const result = resetTickets(cwd(opts), target, String(opts.actor));
|
|
1528
|
+
console.log(`foreman tickets: reset ${result.tickets.length} ticket(s) (audit ${result.resetId})`);
|
|
1529
|
+
}
|
|
1530
|
+
catch (err) {
|
|
1531
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
999
1534
|
tickets
|
|
1000
1535
|
.command("update <ticketId>")
|
|
1001
1536
|
.description("Update ticket status or progress fields.")
|