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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
|
|
3
|
+
import { loadDeliveryConfig } from "./delivery.js";
|
|
4
|
+
import { resolveBlockers } from "./blockers.js";
|
|
5
|
+
import { StateDb, pristineTicketState } from "./stateDb.js";
|
|
6
|
+
import { loadTickets } from "./ticketLoader.js";
|
|
7
|
+
export function getTicketDetails(projectDir, ticketId, now = new Date()) {
|
|
8
|
+
const config = loadTicketsConfig(projectDir);
|
|
9
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
10
|
+
if (!existsSync(paths.tickets))
|
|
11
|
+
throw new Error("ticket tracker is not initialized; run `rafi tickets init`");
|
|
12
|
+
const tickets = loadTickets(paths.tickets);
|
|
13
|
+
const definition = tickets.find((ticket) => ticket.id === ticketId);
|
|
14
|
+
if (!definition)
|
|
15
|
+
throw new Error(`Ticket ${ticketId} not found. Run \`rafi tickets queue\` to list tickets.`);
|
|
16
|
+
const db = new StateDb(paths.stateDb);
|
|
17
|
+
try {
|
|
18
|
+
const states = db.getAllStates();
|
|
19
|
+
const state = states.get(ticketId) ?? pristineTicketState(ticketId, now.toISOString());
|
|
20
|
+
const delivery = loadDeliveryConfig(projectDir);
|
|
21
|
+
const units = delivery?.units.filter((unit) => unit.tickets.includes(ticketId)) ?? [];
|
|
22
|
+
const unitIds = new Set(units.map((unit) => unit.id));
|
|
23
|
+
const stacks = delivery?.stacks?.filter((stack) => stack.units.some((unit) => unitIds.has(unit))) ?? [];
|
|
24
|
+
return {
|
|
25
|
+
definition,
|
|
26
|
+
state,
|
|
27
|
+
effective_blockers: resolveBlockers(definition, states),
|
|
28
|
+
validation_history: db.getValidationSnapshots(ticketId),
|
|
29
|
+
events: db.getTicketEvents(ticketId),
|
|
30
|
+
delivery: { units, stacks },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
db.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function formatTicketDetails(details) {
|
|
38
|
+
const lines = [`${details.definition.id}: ${details.definition.title}`, "", "Canonical ticket"];
|
|
39
|
+
const preferred = [
|
|
40
|
+
"id", "order", "title", "area", "priority", "size", "risk", "summary", "depends_on",
|
|
41
|
+
"acceptance", "required_tests", "likely_files", "rollback", "notes", "external_refs",
|
|
42
|
+
"source_refs", "plan_ref", "supersedes", "superseded_by",
|
|
43
|
+
];
|
|
44
|
+
const keys = [...preferred.filter((key) => key in details.definition), ...Object.keys(details.definition).filter((key) => !preferred.includes(key)).sort()];
|
|
45
|
+
for (const key of keys)
|
|
46
|
+
lines.push(...formatField(key, details.definition[key]));
|
|
47
|
+
lines.push("", "Active state");
|
|
48
|
+
for (const [key, value] of Object.entries(details.state))
|
|
49
|
+
lines.push(...formatField(key, parseJsonField(key, value)));
|
|
50
|
+
lines.push(...formatField("effective_blockers", details.effective_blockers));
|
|
51
|
+
lines.push("", "Active validation and evidence");
|
|
52
|
+
lines.push(...formatField("validation_result", details.state.validation_result));
|
|
53
|
+
lines.push(...formatField("validation_commands", details.state.validation_commands));
|
|
54
|
+
lines.push(...formatField("validation_notes", details.state.validation_notes));
|
|
55
|
+
lines.push(...formatField("evidence", details.state.evidence));
|
|
56
|
+
lines.push("", "Historical validation");
|
|
57
|
+
lines.push(...formatRecords(details.validation_history));
|
|
58
|
+
lines.push("", "Delivery");
|
|
59
|
+
lines.push(...formatField("units", details.delivery.units));
|
|
60
|
+
lines.push(...formatField("stacks", details.delivery.stacks));
|
|
61
|
+
lines.push("", "History");
|
|
62
|
+
lines.push(...formatRecords(details.events));
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
function formatField(label, value) {
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
if (value.length === 0)
|
|
68
|
+
return [`${label}: (none)`];
|
|
69
|
+
if (value.every((item) => typeof item !== "object" || item === null)) {
|
|
70
|
+
return [`${label}:`, ...value.map((item) => ` - ${String(item)}`)];
|
|
71
|
+
}
|
|
72
|
+
return [`${label}:`, ...JSON.stringify(value, null, 2).split("\n").map((line) => ` ${line}`)];
|
|
73
|
+
}
|
|
74
|
+
if (value && typeof value === "object") {
|
|
75
|
+
return [`${label}:`, ...JSON.stringify(value, null, 2).split("\n").map((line) => ` ${line}`)];
|
|
76
|
+
}
|
|
77
|
+
const rendered = value === undefined || value === null || value === "" ? "(none)" : String(value);
|
|
78
|
+
if (!rendered.includes("\n"))
|
|
79
|
+
return [`${label}: ${rendered}`];
|
|
80
|
+
return [`${label}:`, ...rendered.split("\n").map((line) => ` ${line}`)];
|
|
81
|
+
}
|
|
82
|
+
function formatRecords(records) {
|
|
83
|
+
return records.length ? JSON.stringify(records, null, 2).split("\n").map((line) => ` ${line}`) : [" (none)"];
|
|
84
|
+
}
|
|
85
|
+
function parseJsonField(key, value) {
|
|
86
|
+
if (key !== "blocked_by_json" || typeof value !== "string")
|
|
87
|
+
return value;
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(value);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DeliveryConfig } from "./delivery.js";
|
|
2
|
+
import type { TicketDef } from "./ticketSchema.js";
|
|
3
|
+
import type { TicketState } from "./stateDb.js";
|
|
4
|
+
export type TicketIneligibilityCode = "missing" | "terminal" | "outside-window" | "dependencies" | "explicit-blocker" | "delivery-constraint" | "active-lease" | "resumable-owner";
|
|
5
|
+
export interface TicketEligibility {
|
|
6
|
+
ticket?: TicketDef;
|
|
7
|
+
eligible: boolean;
|
|
8
|
+
recommended: boolean;
|
|
9
|
+
blockers: Array<{
|
|
10
|
+
code: TicketIneligibilityCode;
|
|
11
|
+
detail: string;
|
|
12
|
+
tickets?: string[];
|
|
13
|
+
}>;
|
|
14
|
+
eligibleDependencies: TicketDef[];
|
|
15
|
+
}
|
|
16
|
+
export interface EligibilityContext {
|
|
17
|
+
tickets: TicketDef[];
|
|
18
|
+
states: Map<string, TicketState>;
|
|
19
|
+
implementationLimit: number;
|
|
20
|
+
delivery?: DeliveryConfig;
|
|
21
|
+
activeLease?: {
|
|
22
|
+
runId: string;
|
|
23
|
+
};
|
|
24
|
+
allowOwnedResume?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function evaluateTicketEligibility(context: EligibilityContext, ticketId: string): TicketEligibility;
|
|
27
|
+
export declare function eligibleTickets(context: EligibilityContext): TicketDef[];
|
|
28
|
+
export declare function implementationWindow(tickets: TicketDef[], states: Map<string, TicketState>, limit: number): TicketDef[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { resolveBlockers } from "./blockers.js";
|
|
2
|
+
export function evaluateTicketEligibility(context, ticketId) {
|
|
3
|
+
const ticket = context.tickets.find((candidate) => candidate.id === ticketId);
|
|
4
|
+
if (!ticket)
|
|
5
|
+
return { eligible: false, recommended: false, blockers: [{ code: "missing", detail: `ticket ${ticketId} does not exist` }], eligibleDependencies: [] };
|
|
6
|
+
const state = context.states.get(ticket.id);
|
|
7
|
+
const blockers = [];
|
|
8
|
+
if (["done", "canceled", "obsolete"].includes(state?.status ?? "planned"))
|
|
9
|
+
blockers.push({ code: "terminal", detail: `ticket is ${state?.status}` });
|
|
10
|
+
const window = implementationWindow(context.tickets, context.states, context.implementationLimit);
|
|
11
|
+
if (!window.some((candidate) => candidate.id === ticket.id))
|
|
12
|
+
blockers.push({ code: "outside-window", detail: `ticket is outside the ${context.implementationLimit}-ticket implementation window` });
|
|
13
|
+
const unfinishedDependencies = ticket.depends_on.filter((id) => context.states.get(id)?.status !== "done");
|
|
14
|
+
if (unfinishedDependencies.length)
|
|
15
|
+
blockers.push({ code: "dependencies", detail: `unfinished dependencies: ${unfinishedDependencies.join(", ")}`, tickets: unfinishedDependencies });
|
|
16
|
+
const explicit = resolveBlockers(ticket, context.states).filter((id) => !unfinishedDependencies.includes(id));
|
|
17
|
+
if (explicit.length)
|
|
18
|
+
blockers.push({ code: "explicit-blocker", detail: `explicit blockers: ${explicit.join(", ")}`, tickets: explicit });
|
|
19
|
+
const deliveryReason = deliveryConstraint(ticket.id, context.delivery, context.states);
|
|
20
|
+
if (deliveryReason)
|
|
21
|
+
blockers.push({ code: "delivery-constraint", detail: deliveryReason });
|
|
22
|
+
if (context.activeLease)
|
|
23
|
+
blockers.push({ code: "active-lease", detail: `project has an active workflow lease for run ${context.activeLease.runId}` });
|
|
24
|
+
if (!context.allowOwnedResume && (state?.status === "in_progress" || state?.owner))
|
|
25
|
+
blockers.push({ code: "resumable-owner", detail: `ticket has resumable ownership${state.owner ? ` by ${state.owner}` : ""}` });
|
|
26
|
+
const eligibleDependencies = unfinishedDependencies
|
|
27
|
+
.map((id) => context.tickets.find((candidate) => candidate.id === id))
|
|
28
|
+
.filter((candidate) => Boolean(candidate))
|
|
29
|
+
.filter((candidate) => evaluateTicketEligibility({ ...context, activeLease: undefined }, candidate.id).eligible)
|
|
30
|
+
.sort(ticketOrder);
|
|
31
|
+
const ranked = eligibleTickets({ ...context, activeLease: undefined });
|
|
32
|
+
return { ticket, eligible: blockers.length === 0, recommended: ranked[0]?.id === ticket.id, blockers, eligibleDependencies };
|
|
33
|
+
}
|
|
34
|
+
export function eligibleTickets(context) {
|
|
35
|
+
return implementationWindow(context.tickets, context.states, context.implementationLimit)
|
|
36
|
+
.filter((ticket) => {
|
|
37
|
+
const state = context.states.get(ticket.id);
|
|
38
|
+
if (["done", "canceled", "obsolete", "in_progress"].includes(state?.status ?? "planned") || state?.owner)
|
|
39
|
+
return false;
|
|
40
|
+
if (resolveBlockers(ticket, context.states).length)
|
|
41
|
+
return false;
|
|
42
|
+
return !deliveryConstraint(ticket.id, context.delivery, context.states);
|
|
43
|
+
})
|
|
44
|
+
.sort(ticketOrder);
|
|
45
|
+
}
|
|
46
|
+
export function implementationWindow(tickets, states, limit) {
|
|
47
|
+
return tickets.filter((ticket) => !["done", "canceled", "obsolete"].includes(states.get(ticket.id)?.status ?? "planned")).sort(ticketOrder).slice(0, limit);
|
|
48
|
+
}
|
|
49
|
+
function ticketOrder(a, b) {
|
|
50
|
+
return a.order - b.order || a.id.localeCompare(b.id);
|
|
51
|
+
}
|
|
52
|
+
function deliveryConstraint(ticketId, delivery, states) {
|
|
53
|
+
if (!delivery)
|
|
54
|
+
return undefined;
|
|
55
|
+
const unit = delivery.units.find((candidate) => candidate.tickets.includes(ticketId));
|
|
56
|
+
if (!unit)
|
|
57
|
+
return undefined;
|
|
58
|
+
const stack = delivery.stacks?.find((candidate) => candidate.units.includes(unit.id));
|
|
59
|
+
if (!stack)
|
|
60
|
+
return undefined;
|
|
61
|
+
const firstUnfinishedUnit = stack.units.find((unitId) => {
|
|
62
|
+
const candidate = delivery.units.find((item) => item.id === unitId);
|
|
63
|
+
return candidate?.tickets.some((id) => !["done", "canceled", "obsolete"].includes(states.get(id)?.status ?? "planned"));
|
|
64
|
+
});
|
|
65
|
+
if (firstUnfinishedUnit && firstUnfinishedUnit !== unit.id)
|
|
66
|
+
return `delivery stack ${stack.id} must continue with unit ${firstUnfinishedUnit}`;
|
|
67
|
+
const firstUnfinishedTicket = unit.tickets.find((id) => !["done", "canceled", "obsolete"].includes(states.get(id)?.status ?? "planned"));
|
|
68
|
+
if (firstUnfinishedTicket && firstUnfinishedTicket !== ticketId && unit.dependency_mode === "stack")
|
|
69
|
+
return `delivery unit ${unit.id} must continue with ${firstUnfinishedTicket}`;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
package/dist/tickets/importer.js
CHANGED
|
@@ -5,6 +5,7 @@ import { StateDb } from "./stateDb.js";
|
|
|
5
5
|
import { nowTimestamp } from "./events.js";
|
|
6
6
|
import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
|
|
7
7
|
import { insertXlSplitRecommendations } from "./recommendations.js";
|
|
8
|
+
import { currentActivity, withActivityPhase } from "../activity.js";
|
|
8
9
|
export function importFromMarkdown(_progressDocPath) {
|
|
9
10
|
throw new Error("foreman tickets import is not yet implemented.\n\n" +
|
|
10
11
|
"To start fresh:\n" +
|
|
@@ -19,9 +20,9 @@ export function importFromMarkdown(_progressDocPath) {
|
|
|
19
20
|
export async function importExternalSources(projectDir, sources, opts) {
|
|
20
21
|
const results = [];
|
|
21
22
|
for (const source of sources) {
|
|
22
|
-
const items = source.type === "linear"
|
|
23
|
-
?
|
|
24
|
-
:
|
|
23
|
+
const items = await withActivityPhase(`importing ${source.type} tickets`, () => source.type === "linear"
|
|
24
|
+
? fetchLinearIssues(source, opts)
|
|
25
|
+
: fetchJiraIssues(source, opts));
|
|
25
26
|
const snapshotPath = writeImportSnapshot(projectDir, source.type, {
|
|
26
27
|
source: redactSource(source),
|
|
27
28
|
fetched_at: new Date().toISOString(),
|
|
@@ -42,11 +43,13 @@ export async function importExternalSources(projectDir, sources, opts) {
|
|
|
42
43
|
return results;
|
|
43
44
|
}
|
|
44
45
|
export async function validateExternalSourceAccess(source) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
await withActivityPhase(`checking ${source.type} access`, async () => {
|
|
47
|
+
if (source.type === "linear") {
|
|
48
|
+
await fetchLinearIssues(source, { importCap: 1, commentLimit: 0 });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await fetchJiraIssues(source, { importCap: 1, commentLimit: 0 });
|
|
52
|
+
});
|
|
50
53
|
}
|
|
51
54
|
export async function fetchLinearIssues(source, opts) {
|
|
52
55
|
const apiKey = process.env[source.api_key_env];
|
|
@@ -84,6 +87,7 @@ export async function fetchLinearIssues(source, opts) {
|
|
|
84
87
|
if (items.length >= opts.importCap)
|
|
85
88
|
break;
|
|
86
89
|
}
|
|
90
|
+
currentActivity()?.update("importing Linear tickets", `fetched ${items.length}`);
|
|
87
91
|
if (!issues?.pageInfo?.hasNextPage || !issues.pageInfo.endCursor || nodes.length === 0)
|
|
88
92
|
break;
|
|
89
93
|
after = issues.pageInfo.endCursor;
|
|
@@ -137,6 +141,7 @@ export async function fetchJiraIssues(source, opts) {
|
|
|
137
141
|
if (items.length >= opts.importCap)
|
|
138
142
|
break;
|
|
139
143
|
}
|
|
144
|
+
currentActivity()?.update("importing Jira tickets", `fetched ${items.length}`);
|
|
140
145
|
nextPageToken = typeof json.nextPageToken === "string" ? json.nextPageToken : undefined;
|
|
141
146
|
if (!nextPageToken || issues.length === 0 || json.isLast === true)
|
|
142
147
|
break;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type TicketStatus } from "./stateDb.js";
|
|
2
|
+
export type TicketResetScope = "all" | "completed-and-unfinished" | "unfinished";
|
|
3
|
+
export interface TicketResetPreviewRow {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
status: TicketStatus;
|
|
7
|
+
cleared: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface TicketResetPreview {
|
|
10
|
+
scope: TicketResetScope | "single" | "run";
|
|
11
|
+
tickets: TicketResetPreviewRow[];
|
|
12
|
+
relatedRuns: Array<{
|
|
13
|
+
runId: string;
|
|
14
|
+
status: string;
|
|
15
|
+
tickets: string[];
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export declare function previewTicketReset(projectDir: string, target: string | TicketResetScope | string[]): TicketResetPreview;
|
|
19
|
+
export declare function resetTickets(projectDir: string, target: string | TicketResetScope | string[], actor?: string, now?: Date, hooks?: {
|
|
20
|
+
beforePublish?: () => void;
|
|
21
|
+
resetId?: string;
|
|
22
|
+
}): {
|
|
23
|
+
resetId: string;
|
|
24
|
+
tickets: string[];
|
|
25
|
+
};
|
|
26
|
+
/** Finish or roll back a reset publication left incomplete by process interruption. */
|
|
27
|
+
export declare function recoverTicketResetPublications(projectDir: string): string[];
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { recoverableBuildRuns } from "../buildRuns.js";
|
|
5
|
+
import { WorkflowDb, WORKFLOW_DB_FILE } from "../workflowDb.js";
|
|
6
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
|
|
7
|
+
import { renderProgressDoc, writeProgressDoc } from "./renderMarkdown.js";
|
|
8
|
+
import { StateDb, pristineTicketState } from "./stateDb.js";
|
|
9
|
+
import { loadTickets } from "./ticketLoader.js";
|
|
10
|
+
const SCOPE_STATUSES = {
|
|
11
|
+
all: ["planned", "next", "in_progress", "blocked", "done", "canceled", "obsolete"],
|
|
12
|
+
"completed-and-unfinished": ["planned", "next", "in_progress", "blocked", "done"],
|
|
13
|
+
unfinished: ["planned", "next", "in_progress", "blocked"],
|
|
14
|
+
};
|
|
15
|
+
export function previewTicketReset(projectDir, target) {
|
|
16
|
+
const { tickets, db } = open(projectDir);
|
|
17
|
+
try {
|
|
18
|
+
const states = db.getAllStates();
|
|
19
|
+
let selected;
|
|
20
|
+
let scope;
|
|
21
|
+
if (Array.isArray(target)) {
|
|
22
|
+
scope = "run";
|
|
23
|
+
const wanted = new Set(target);
|
|
24
|
+
selected = tickets.filter((ticket) => wanted.has(ticket.id));
|
|
25
|
+
const missing = target.filter((id) => !selected.some((ticket) => ticket.id === id));
|
|
26
|
+
if (missing.length)
|
|
27
|
+
throw new Error(`Tickets not found: ${missing.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
else if (target in SCOPE_STATUSES) {
|
|
30
|
+
scope = target;
|
|
31
|
+
const statuses = new Set(SCOPE_STATUSES[scope]);
|
|
32
|
+
selected = tickets.filter((ticket) => statuses.has(states.get(ticket.id)?.status ?? "planned"));
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
scope = "single";
|
|
36
|
+
const ticket = tickets.find((candidate) => candidate.id === target);
|
|
37
|
+
if (!ticket)
|
|
38
|
+
throw new Error(`Ticket ${target} not found. Run \`rafi tickets queue\` to list tickets.`);
|
|
39
|
+
selected = [ticket];
|
|
40
|
+
}
|
|
41
|
+
const selectedIds = new Set(selected.map((ticket) => ticket.id));
|
|
42
|
+
const relatedRuns = recoverableBuildRuns(projectDir)
|
|
43
|
+
.filter((run) => run.tickets.some((id) => selectedIds.has(id)))
|
|
44
|
+
.map((run) => ({ runId: run.runId, status: run.status, tickets: run.tickets }));
|
|
45
|
+
return {
|
|
46
|
+
scope,
|
|
47
|
+
tickets: selected.map((ticket) => {
|
|
48
|
+
const state = states.get(ticket.id) ?? pristineTicketState(ticket.id, new Date(0).toISOString());
|
|
49
|
+
return { id: ticket.id, title: ticket.title, status: state.status, cleared: activeFieldCategories(state) };
|
|
50
|
+
}),
|
|
51
|
+
relatedRuns,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
db.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function resetTickets(projectDir, target, actor = "user", now = new Date(), hooks = {}) {
|
|
59
|
+
const preview = previewTicketReset(projectDir, target);
|
|
60
|
+
const ids = preview.tickets.map((ticket) => ticket.id);
|
|
61
|
+
if (ids.length === 0)
|
|
62
|
+
return { resetId: randomUUID(), tickets: [] };
|
|
63
|
+
const { config, paths, tickets, db } = open(projectDir);
|
|
64
|
+
const workflow = new WorkflowDb(projectDir);
|
|
65
|
+
const resetId = hooks.resetId ?? randomUUID();
|
|
66
|
+
if (ids.every((id) => db.getTicketEvents(id).some((event) => event.event_type === "ticket_reset" && event.payload_json?.includes(resetId)))) {
|
|
67
|
+
workflow.close();
|
|
68
|
+
db.close();
|
|
69
|
+
return { resetId, tickets: ids };
|
|
70
|
+
}
|
|
71
|
+
const stagePath = join(projectDir, ".rafi", "reset-stage", resetId, "progress.md");
|
|
72
|
+
const oldStates = new Map(ids.map((id) => [id, db.getState(id)]));
|
|
73
|
+
const oldRecentCompleted = db.getRecentCompleted().filter((row) => ids.includes(row.ticket_id));
|
|
74
|
+
const insertedEventIds = [];
|
|
75
|
+
const progressBefore = existsSync(paths.progressDoc) ? readFileSync(paths.progressDoc) : undefined;
|
|
76
|
+
const run = workflow.createRun({
|
|
77
|
+
kind: "recovery",
|
|
78
|
+
checkpoint: "ticket-reset-intent",
|
|
79
|
+
originalWork: { resetId, scope: preview.scope, tickets: ids, states: Object.fromEntries(oldStates) },
|
|
80
|
+
remainingWork: { tickets: ids },
|
|
81
|
+
state: { operation: "ticket-reset", progressDigest: digest(progressBefore) },
|
|
82
|
+
}, now);
|
|
83
|
+
const publication = workflow.beginPublication(run.runId, { operation: "ticket-reset", resetId, tickets: ids, stagePath, targetPath: paths.progressDoc }, { progressDigest: digest(progressBefore) }, now);
|
|
84
|
+
try {
|
|
85
|
+
const stamp = now.toISOString();
|
|
86
|
+
db.transaction(() => {
|
|
87
|
+
for (const row of preview.tickets) {
|
|
88
|
+
db.upsertState(row.id, pristineTicketState(row.id, stamp, actor), stamp);
|
|
89
|
+
db.deleteRecentCompleted(row.id);
|
|
90
|
+
insertedEventIds.push(db.insertEvent({
|
|
91
|
+
timestamp: stamp,
|
|
92
|
+
actor,
|
|
93
|
+
ticket_id: row.id,
|
|
94
|
+
event_type: "ticket_reset",
|
|
95
|
+
old_status: row.status,
|
|
96
|
+
new_status: "planned",
|
|
97
|
+
summary: `Reset ${row.id} to pristine active state`,
|
|
98
|
+
validation: null,
|
|
99
|
+
evidence: null,
|
|
100
|
+
payload_json: JSON.stringify({ reset_id: resetId, scope: preview.scope, cleared: row.cleared }),
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
mkdirSync(dirname(stagePath), { recursive: true });
|
|
105
|
+
writeFileSync(stagePath, renderProgressDoc({ config, projectDir, ticketDefs: tickets, states: db.getAllStates(), db, now: stamp }), "utf8");
|
|
106
|
+
workflow.updatePublication(publication.transactionId, "staged", now);
|
|
107
|
+
workflow.updatePublication(publication.transactionId, "tracker_committed", now);
|
|
108
|
+
hooks.beforePublish?.();
|
|
109
|
+
writeProgressDoc(paths.progressDoc, readFileSync(stagePath, "utf8"), true);
|
|
110
|
+
workflow.updatePublication(publication.transactionId, "published", now);
|
|
111
|
+
workflow.updatePublication(publication.transactionId, "committed", now);
|
|
112
|
+
workflow.transition(run.runId, { status: "completed", checkpoint: "ticket-reset-committed", remainingWork: {}, state: { operation: "ticket-reset", resetId, tickets: ids } }, now);
|
|
113
|
+
rmSync(dirname(stagePath), { recursive: true, force: true });
|
|
114
|
+
return { resetId, tickets: ids };
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
db.transaction(() => {
|
|
118
|
+
db.deleteEvents(insertedEventIds);
|
|
119
|
+
for (const [id, state] of oldStates) {
|
|
120
|
+
if (state)
|
|
121
|
+
db.upsertState(id, state, state.updated_at);
|
|
122
|
+
else
|
|
123
|
+
db.deleteState(id);
|
|
124
|
+
}
|
|
125
|
+
for (const row of oldRecentCompleted)
|
|
126
|
+
db.upsertRecentCompleted(row);
|
|
127
|
+
});
|
|
128
|
+
if (progressBefore)
|
|
129
|
+
writeProgressDoc(paths.progressDoc, progressBefore.toString("utf8"), true);
|
|
130
|
+
else
|
|
131
|
+
rmSync(paths.progressDoc, { force: true });
|
|
132
|
+
rmSync(dirname(stagePath), { recursive: true, force: true });
|
|
133
|
+
workflow.updatePublication(publication.transactionId, "rolled_back", now);
|
|
134
|
+
workflow.transition(run.runId, { status: "failed", checkpoint: "ticket-reset-rolled-back", state: { operation: "ticket-reset", resetId, error: error instanceof Error ? error.message : String(error) } }, now);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
workflow.close();
|
|
139
|
+
db.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Finish or roll back a reset publication left incomplete by process interruption. */
|
|
143
|
+
export function recoverTicketResetPublications(projectDir) {
|
|
144
|
+
if (!existsSync(join(projectDir, WORKFLOW_DB_FILE)))
|
|
145
|
+
return [];
|
|
146
|
+
const workflow = new WorkflowDb(projectDir);
|
|
147
|
+
const recovered = [];
|
|
148
|
+
try {
|
|
149
|
+
for (const tx of workflow.incompletePublications()) {
|
|
150
|
+
const intent = tx.intent;
|
|
151
|
+
if (intent.operation !== "ticket-reset" || !intent.resetId || !intent.tickets || !intent.stagePath || !intent.targetPath)
|
|
152
|
+
continue;
|
|
153
|
+
const { config, tickets, db } = open(projectDir);
|
|
154
|
+
try {
|
|
155
|
+
const resetCommitted = intent.tickets.every((id) => db.getTicketEvents(id).some((event) => event.event_type === "ticket_reset" && event.payload_json?.includes(intent.resetId)));
|
|
156
|
+
if (resetCommitted || ["staged", "tracker_committed", "published"].includes(tx.status)) {
|
|
157
|
+
if (!existsSync(intent.stagePath)) {
|
|
158
|
+
mkdirSync(dirname(intent.stagePath), { recursive: true });
|
|
159
|
+
writeFileSync(intent.stagePath, renderProgressDoc({ config, projectDir, ticketDefs: tickets, states: db.getAllStates(), db }), "utf8");
|
|
160
|
+
}
|
|
161
|
+
if (tx.status !== "published")
|
|
162
|
+
writeProgressDoc(intent.targetPath, readFileSync(intent.stagePath, "utf8"), true);
|
|
163
|
+
workflow.updatePublication(tx.transactionId, "committed");
|
|
164
|
+
const run = workflow.getRun(tx.runId);
|
|
165
|
+
if (run && run.status !== "completed")
|
|
166
|
+
workflow.transition(run.runId, { status: "completed", checkpoint: "ticket-reset-recovered", remainingWork: {}, state: { ...run.state, recoveredPublication: tx.transactionId } });
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
workflow.updatePublication(tx.transactionId, "rolled_back");
|
|
170
|
+
}
|
|
171
|
+
rmSync(dirname(intent.stagePath), { recursive: true, force: true });
|
|
172
|
+
recovered.push(tx.transactionId);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
db.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return recovered;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
workflow.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function open(projectDir) {
|
|
185
|
+
const config = loadTicketsConfig(projectDir);
|
|
186
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
187
|
+
const tickets = loadTickets(paths.tickets);
|
|
188
|
+
const db = new StateDb(paths.stateDb);
|
|
189
|
+
return { config, paths, tickets, db };
|
|
190
|
+
}
|
|
191
|
+
function activeFieldCategories(state) {
|
|
192
|
+
const out = [];
|
|
193
|
+
if (state.status !== "planned")
|
|
194
|
+
out.push("status");
|
|
195
|
+
if (state.owner)
|
|
196
|
+
out.push("owner");
|
|
197
|
+
if (state.current_step || state.next_action)
|
|
198
|
+
out.push("steps");
|
|
199
|
+
if (state.blocked_by_json !== "[]" || state.blocker_type || state.blocker_notes || state.first_blocked_at || state.last_checked_at)
|
|
200
|
+
out.push("temporary blockers");
|
|
201
|
+
if (state.last_worked_at || state.completed_at)
|
|
202
|
+
out.push("work timestamps");
|
|
203
|
+
if (state.attempt_count)
|
|
204
|
+
out.push("attempt count");
|
|
205
|
+
if (state.last_error)
|
|
206
|
+
out.push("last error");
|
|
207
|
+
if (state.evidence || state.validation_result || state.validation_commands || state.validation_notes)
|
|
208
|
+
out.push("active validation/evidence");
|
|
209
|
+
return out.length ? out : ["no active progress"];
|
|
210
|
+
}
|
|
211
|
+
function digest(value) {
|
|
212
|
+
return value ? createHash("sha256").update(value).digest("hex") : null;
|
|
213
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BranchPlanNode } from "../branch/types.js";
|
|
2
|
+
import { type TicketBuildSetupConfig } from "./setupConfig.js";
|
|
3
|
+
import type { TicketDef } from "./ticketSchema.js";
|
|
4
|
+
export declare function configuredReviewTitle(projectDir: string, ticket: TicketDef): string;
|
|
5
|
+
export declare function renderReviewTitle(build: TicketBuildSetupConfig, ticket: TicketDef): string;
|
|
6
|
+
export declare function configuredReviewBody(projectDir: string, node: BranchPlanNode, qaEvidence?: string, commit?: string): string;
|
|
7
|
+
export declare function renderReviewBody(build: TicketBuildSetupConfig, node: BranchPlanNode, qaEvidence?: string, commit?: string): string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { branchNodeFooter } from "../branch/presentation.js";
|
|
2
|
+
import { loadTicketSetupConfigWithDefaults } from "./setupConfig.js";
|
|
3
|
+
export function configuredReviewTitle(projectDir, ticket) {
|
|
4
|
+
const build = loadTicketSetupConfigWithDefaults(projectDir).build;
|
|
5
|
+
return renderReviewTitle(build, ticket);
|
|
6
|
+
}
|
|
7
|
+
export function renderReviewTitle(build, ticket) {
|
|
8
|
+
const standard = build.review;
|
|
9
|
+
if (standard.title_style === "ticket-title")
|
|
10
|
+
return `${ticket.id}: ${ticket.title}`;
|
|
11
|
+
if (standard.title_style === "conventional")
|
|
12
|
+
return `feat(${ticket.area.toLowerCase().replace(/[^a-z0-9]+/g, "-")}): ${ticket.title}`;
|
|
13
|
+
if (standard.title_style === "custom" && standard.title_template)
|
|
14
|
+
return interpolate(standard.title_template, ticket);
|
|
15
|
+
return ticket.title;
|
|
16
|
+
}
|
|
17
|
+
export function configuredReviewBody(projectDir, node, qaEvidence, commit) {
|
|
18
|
+
const build = loadTicketSetupConfigWithDefaults(projectDir).build;
|
|
19
|
+
return renderReviewBody(build, node, qaEvidence, commit);
|
|
20
|
+
}
|
|
21
|
+
export function renderReviewBody(build, node, qaEvidence, commit) {
|
|
22
|
+
const ticket = node.ticket;
|
|
23
|
+
const sections = build.review.description_sections.flatMap((section) => {
|
|
24
|
+
const key = section.toLowerCase();
|
|
25
|
+
let body;
|
|
26
|
+
if (key === "summary")
|
|
27
|
+
body = [ticket.summary];
|
|
28
|
+
else if (key === "linked ticket")
|
|
29
|
+
body = [`${ticket.id}: ${ticket.title}`];
|
|
30
|
+
else if (key === "changes made")
|
|
31
|
+
body = ticket.likely_files.length ? ticket.likely_files.map((path) => `- ${path}`) : ["- See branch diff."];
|
|
32
|
+
else if (key === "tests and validation performed")
|
|
33
|
+
body = [...ticket.required_tests.map((test) => `- ${test}`), `- Evidence: ${qaEvidence ?? "Foreman QA emitted qa_pass."}`];
|
|
34
|
+
else if (key === "risks or rollback notes")
|
|
35
|
+
body = [`- Risk: ${ticket.risk}`, `- Rollback: ${ticket.rollback ?? "Revert the review commit."}`];
|
|
36
|
+
else if (key === "checklist")
|
|
37
|
+
body = build.validation_checklist.map((item) => `- [ ] ${item}`);
|
|
38
|
+
else
|
|
39
|
+
body = ["_Complete before review._"];
|
|
40
|
+
return [`## ${section}`, "", ...body, ""];
|
|
41
|
+
});
|
|
42
|
+
return [
|
|
43
|
+
...sections,
|
|
44
|
+
"## Branch Metadata", "",
|
|
45
|
+
`- Base: ${node.baseBranch}`, `- Head: ${node.branch}`,
|
|
46
|
+
`- Dependencies: ${node.dependencies.length ? node.dependencies.join(", ") : "None"}`,
|
|
47
|
+
`- Commit: ${commit ?? "N/A"}`, "",
|
|
48
|
+
branchNodeFooter(node), "",
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
function interpolate(template, ticket) {
|
|
52
|
+
return template.replaceAll("{id}", ticket.id).replaceAll("{title}", ticket.title).replaceAll("{area}", ticket.area);
|
|
53
|
+
}
|
|
@@ -4,6 +4,8 @@ export type TicketBuildCompletionMode = "pr" | "auto-merge" | "direct-merge" | "
|
|
|
4
4
|
export type TicketBuildProvider = "auto" | "github" | "gitlab" | "local";
|
|
5
5
|
export type TicketBuildMergeMethod = "squash" | "merge" | "rebase";
|
|
6
6
|
export type TicketBuildBranchStrategy = "current" | "batch" | "branch-per-ticket";
|
|
7
|
+
export type TicketBranchPolicyMode = "global" | "size";
|
|
8
|
+
export type TicketTitleStyle = "ticket-title" | "conventional" | "none" | "custom";
|
|
7
9
|
export type TicketPopulateAgentPreference = "configured" | "claude" | "codex";
|
|
8
10
|
export type TicketPopulateEnrichmentPolicy = "none" | "recommendations" | "agent";
|
|
9
11
|
export type TicketSourceConfig = {
|
|
@@ -41,11 +43,27 @@ export interface TicketBuildSetupConfig {
|
|
|
41
43
|
cleanup: boolean;
|
|
42
44
|
auto_merge_wait: boolean;
|
|
43
45
|
auto_merge_timeout_minutes: number | null;
|
|
46
|
+
base_branch: string;
|
|
47
|
+
branch_policy: {
|
|
48
|
+
mode: TicketBranchPolicyMode;
|
|
49
|
+
global_strategy: TicketBuildBranchStrategy;
|
|
50
|
+
by_size: Record<"XS" | "S" | "M" | "L" | "XL", "shared" | "per-ticket">;
|
|
51
|
+
};
|
|
52
|
+
review: {
|
|
53
|
+
title_style: TicketTitleStyle;
|
|
54
|
+
title_template: string | null;
|
|
55
|
+
description_sections: string[];
|
|
56
|
+
};
|
|
57
|
+
validation_checklist: string[];
|
|
44
58
|
}
|
|
45
59
|
export interface TicketsSetupConfig {
|
|
46
60
|
sources: TicketSourceConfig[];
|
|
47
61
|
populate: TicketPopulateSetupConfig;
|
|
48
62
|
build: TicketBuildSetupConfig;
|
|
63
|
+
limits: {
|
|
64
|
+
implementation: number;
|
|
65
|
+
view: number;
|
|
66
|
+
};
|
|
49
67
|
}
|
|
50
68
|
export interface MinimalRafiConfigOptions {
|
|
51
69
|
appName?: string;
|
|
@@ -59,11 +77,14 @@ export declare function loadTicketSetupConfig(projectDir: string): TicketsSetupC
|
|
|
59
77
|
export declare function loadTicketSetupConfigWithDefaults(projectDir: string): TicketsSetupConfig;
|
|
60
78
|
export declare function hasTicketSetupConfig(projectDir: string): boolean;
|
|
61
79
|
export declare function saveTicketSetupConfig(projectDir: string, setup: TicketsSetupConfig, opts?: MinimalRafiConfigOptions): void;
|
|
80
|
+
/** Synchronize pending setup limits into the tracker, once the tracker exists. */
|
|
81
|
+
export declare function syncTicketLimits(projectDir: string, limits: TicketsSetupConfig["limits"]): void;
|
|
62
82
|
export declare function ensureRafiConfigForTicketSetup(projectDir: string, opts?: MinimalRafiConfigOptions): Record<string, unknown>;
|
|
63
83
|
export declare function normalizeTicketsSetupConfig(value: unknown, label?: string): TicketsSetupConfig;
|
|
64
84
|
export declare function denormalizeTicketsSetupConfig(setup: TicketsSetupConfig): Record<string, unknown>;
|
|
65
85
|
export declare function mergeTicketSetup(current: TicketsSetupConfig | undefined, patch: Partial<{
|
|
66
86
|
sources: TicketSourceConfig[];
|
|
87
|
+
limits: TicketsSetupConfig["limits"];
|
|
67
88
|
populate: Partial<TicketPopulateSetupConfig>;
|
|
68
89
|
build: Partial<TicketBuildSetupConfig>;
|
|
69
90
|
}>): TicketsSetupConfig;
|
|
@@ -88,4 +109,5 @@ export declare function configuredHarnessTargets(projectDir: string): HarnessTar
|
|
|
88
109
|
export declare function detectPackageName(projectDir: string): string | undefined;
|
|
89
110
|
export declare function defaultAppName(projectDir: string): string;
|
|
90
111
|
export declare function detectGitProvider(projectDir: string): Exclude<TicketBuildProvider, "auto" | "local"> | undefined;
|
|
112
|
+
export declare function detectDefaultBranch(projectDir: string): string;
|
|
91
113
|
export declare function recommendedBuildDefaults(projectDir: string): TicketBuildSetupConfig;
|