ai-foreman 1.7.5 → 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 +1 -1
- 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 +42 -3
- package/dist/buildRuns.d.ts +41 -17
- package/dist/buildRuns.js +243 -22
- package/dist/cli/start.js +104 -24
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +418 -79
- package/dist/foreman.d.ts +4 -4
- package/dist/foreman.js +35 -14
- package/dist/qaReview.d.ts +1 -0
- package/dist/qaReview.js +11 -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/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/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 +7 -3
|
@@ -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;
|
|
@@ -16,6 +16,7 @@ const SKILL_NAMES = [
|
|
|
16
16
|
];
|
|
17
17
|
export const DEFAULT_TICKET_SETUP = {
|
|
18
18
|
sources: [],
|
|
19
|
+
limits: { implementation: 500, view: 20_000 },
|
|
19
20
|
populate: {
|
|
20
21
|
source_handling: "saved",
|
|
21
22
|
agent_preference: "configured",
|
|
@@ -33,6 +34,25 @@ export const DEFAULT_TICKET_SETUP = {
|
|
|
33
34
|
cleanup: true,
|
|
34
35
|
auto_merge_wait: false,
|
|
35
36
|
auto_merge_timeout_minutes: null,
|
|
37
|
+
base_branch: "main",
|
|
38
|
+
branch_policy: {
|
|
39
|
+
mode: "global",
|
|
40
|
+
global_strategy: "branch-per-ticket",
|
|
41
|
+
by_size: { XS: "shared", S: "shared", M: "per-ticket", L: "per-ticket", XL: "per-ticket" },
|
|
42
|
+
},
|
|
43
|
+
review: {
|
|
44
|
+
title_style: "ticket-title",
|
|
45
|
+
title_template: null,
|
|
46
|
+
description_sections: ["Summary", "Linked ticket", "Changes made", "Tests and validation performed", "Risks or rollback notes", "Checklist"],
|
|
47
|
+
},
|
|
48
|
+
validation_checklist: [
|
|
49
|
+
"Acceptance criteria satisfied",
|
|
50
|
+
"Required tests pass",
|
|
51
|
+
"Configured project checks pass",
|
|
52
|
+
"Documentation updated when needed",
|
|
53
|
+
"Database and deployment changes reviewed when present",
|
|
54
|
+
"Validation evidence attached",
|
|
55
|
+
],
|
|
36
56
|
},
|
|
37
57
|
};
|
|
38
58
|
export function loadRafiConfigObject(projectDir) {
|
|
@@ -77,6 +97,18 @@ export function saveTicketSetupConfig(projectDir, setup, opts = {}) {
|
|
|
77
97
|
mkdirSync(projectDir, { recursive: true });
|
|
78
98
|
writeFileSync(join(projectDir, RAFI_CONFIG_FILE), stringify(config, { lineWidth: 100 }), "utf8");
|
|
79
99
|
}
|
|
100
|
+
/** Synchronize pending setup limits into the tracker, once the tracker exists. */
|
|
101
|
+
export function syncTicketLimits(projectDir, limits) {
|
|
102
|
+
const path = join(projectDir, ".tickets", "config.yaml");
|
|
103
|
+
if (!existsSync(path))
|
|
104
|
+
return;
|
|
105
|
+
const raw = parse(readFileSync(path, "utf8"));
|
|
106
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
107
|
+
throw new Error(`${path}: expected a YAML object`);
|
|
108
|
+
raw.implementation_limit = positiveInteger(limits.implementation, 500, "tickets.limits.implementation");
|
|
109
|
+
raw.view_limit = positiveInteger(limits.view, 20_000, "tickets.limits.view");
|
|
110
|
+
writeFileSync(path, stringify(raw, { lineWidth: 100 }), "utf8");
|
|
111
|
+
}
|
|
80
112
|
export function ensureRafiConfigForTicketSetup(projectDir, opts = {}) {
|
|
81
113
|
const existing = loadRafiConfigObject(projectDir);
|
|
82
114
|
if (existing)
|
|
@@ -95,6 +127,7 @@ export function normalizeTicketsSetupConfig(value, label = "tickets") {
|
|
|
95
127
|
const raw = value;
|
|
96
128
|
const setup = {
|
|
97
129
|
sources: normalizeSources(raw.sources, `${label}.sources`),
|
|
130
|
+
limits: normalizeLimits(raw.limits, `${label}.limits`),
|
|
98
131
|
populate: normalizePopulate(raw.populate, `${label}.populate`),
|
|
99
132
|
build: normalizeBuild(raw.build, `${label}.build`),
|
|
100
133
|
};
|
|
@@ -103,6 +136,7 @@ export function normalizeTicketsSetupConfig(value, label = "tickets") {
|
|
|
103
136
|
export function denormalizeTicketsSetupConfig(setup) {
|
|
104
137
|
return {
|
|
105
138
|
sources: setup.sources.map((source) => ({ ...source })),
|
|
139
|
+
limits: { ...setup.limits },
|
|
106
140
|
populate: { ...setup.populate },
|
|
107
141
|
build: { ...setup.build },
|
|
108
142
|
};
|
|
@@ -111,6 +145,7 @@ export function mergeTicketSetup(current, patch) {
|
|
|
111
145
|
const base = current ? cloneTicketSetup(current) : cloneTicketSetup(DEFAULT_TICKET_SETUP);
|
|
112
146
|
return normalizeTicketsSetupConfig({
|
|
113
147
|
sources: patch.sources ?? base.sources,
|
|
148
|
+
limits: patch.limits ?? base.limits,
|
|
114
149
|
populate: { ...base.populate, ...(patch.populate ?? {}) },
|
|
115
150
|
build: { ...base.build, ...(patch.build ?? {}) },
|
|
116
151
|
});
|
|
@@ -212,6 +247,22 @@ export function detectGitProvider(projectDir) {
|
|
|
212
247
|
}
|
|
213
248
|
return undefined;
|
|
214
249
|
}
|
|
250
|
+
export function detectDefaultBranch(projectDir) {
|
|
251
|
+
const attempts = [
|
|
252
|
+
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
|
253
|
+
["config", "--get", "init.defaultBranch"],
|
|
254
|
+
["branch", "--show-current"],
|
|
255
|
+
];
|
|
256
|
+
for (const args of attempts) {
|
|
257
|
+
try {
|
|
258
|
+
const value = execFileSync("git", args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
259
|
+
if (value)
|
|
260
|
+
return value.replace(/^origin\//, "");
|
|
261
|
+
}
|
|
262
|
+
catch { /* try the next safe source */ }
|
|
263
|
+
}
|
|
264
|
+
return "main";
|
|
265
|
+
}
|
|
215
266
|
export function recommendedBuildDefaults(projectDir) {
|
|
216
267
|
const provider = detectGitProvider(projectDir);
|
|
217
268
|
return {
|
|
@@ -221,6 +272,7 @@ export function recommendedBuildDefaults(projectDir) {
|
|
|
221
272
|
pr_ready: Boolean(provider),
|
|
222
273
|
merge_method: "squash",
|
|
223
274
|
cleanup: true,
|
|
275
|
+
base_branch: detectDefaultBranch(projectDir),
|
|
224
276
|
};
|
|
225
277
|
}
|
|
226
278
|
function minimalRafiConfig(projectDir, opts) {
|
|
@@ -354,6 +406,45 @@ function normalizeBuild(value, label) {
|
|
|
354
406
|
cleanup: booleanField(raw.cleanup, DEFAULT_TICKET_SETUP.build.cleanup, `${label}.cleanup`),
|
|
355
407
|
auto_merge_wait: booleanField(raw.auto_merge_wait, DEFAULT_TICKET_SETUP.build.auto_merge_wait, `${label}.auto_merge_wait`),
|
|
356
408
|
auto_merge_timeout_minutes: nullablePositiveInteger(raw.auto_merge_timeout_minutes, DEFAULT_TICKET_SETUP.build.auto_merge_timeout_minutes, `${label}.auto_merge_timeout_minutes`),
|
|
409
|
+
base_branch: stringField(raw.base_branch, DEFAULT_TICKET_SETUP.build.base_branch),
|
|
410
|
+
branch_policy: normalizeBranchPolicy(raw.branch_policy, raw.branch_strategy, `${label}.branch_policy`),
|
|
411
|
+
review: normalizeReview(raw.review, `${label}.review`),
|
|
412
|
+
validation_checklist: stringArray(raw.validation_checklist, DEFAULT_TICKET_SETUP.build.validation_checklist, `${label}.validation_checklist`),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function normalizeLimits(value, label) {
|
|
416
|
+
const raw = objectOrEmpty(value, label);
|
|
417
|
+
return {
|
|
418
|
+
implementation: positiveInteger(raw.implementation, DEFAULT_TICKET_SETUP.limits.implementation, `${label}.implementation`),
|
|
419
|
+
view: positiveInteger(raw.view, DEFAULT_TICKET_SETUP.limits.view, `${label}.view`),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
function normalizeBranchPolicy(value, legacyStrategy, label) {
|
|
423
|
+
const raw = objectOrEmpty(value, label);
|
|
424
|
+
const sizes = objectOrEmpty(raw.by_size, `${label}.by_size`);
|
|
425
|
+
const globalStrategy = enumField(raw.global_strategy ?? legacyStrategy, ["current", "batch", "branch-per-ticket"], DEFAULT_TICKET_SETUP.build.branch_policy.global_strategy, `${label}.global_strategy`);
|
|
426
|
+
return {
|
|
427
|
+
mode: enumField(raw.mode, ["global", "size"], DEFAULT_TICKET_SETUP.build.branch_policy.mode, `${label}.mode`),
|
|
428
|
+
global_strategy: globalStrategy,
|
|
429
|
+
by_size: {
|
|
430
|
+
XS: enumField(sizes.XS, ["shared", "per-ticket"], "shared", `${label}.by_size.XS`),
|
|
431
|
+
S: enumField(sizes.S, ["shared", "per-ticket"], "shared", `${label}.by_size.S`),
|
|
432
|
+
M: enumField(sizes.M, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.M`),
|
|
433
|
+
L: enumField(sizes.L, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.L`),
|
|
434
|
+
XL: enumField(sizes.XL, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.XL`),
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function normalizeReview(value, label) {
|
|
439
|
+
const raw = objectOrEmpty(value, label);
|
|
440
|
+
const titleStyle = enumField(raw.title_style, ["ticket-title", "conventional", "none", "custom"], DEFAULT_TICKET_SETUP.build.review.title_style, `${label}.title_style`);
|
|
441
|
+
const titleTemplate = nullableString(raw.title_template);
|
|
442
|
+
if (titleStyle === "custom" && !titleTemplate)
|
|
443
|
+
throw new Error(`${label}.title_template: required for custom title style`);
|
|
444
|
+
return {
|
|
445
|
+
title_style: titleStyle,
|
|
446
|
+
title_template: titleTemplate,
|
|
447
|
+
description_sections: stringArray(raw.description_sections, DEFAULT_TICKET_SETUP.build.review.description_sections, `${label}.description_sections`),
|
|
357
448
|
};
|
|
358
449
|
}
|
|
359
450
|
function objectOrEmpty(value, label) {
|
|
@@ -418,6 +509,13 @@ function nullablePositiveInteger(value, fallback, label) {
|
|
|
418
509
|
return Number(value);
|
|
419
510
|
throw new Error(`${label}: expected a positive integer or null`);
|
|
420
511
|
}
|
|
512
|
+
function stringArray(value, fallback, label) {
|
|
513
|
+
if (value === undefined)
|
|
514
|
+
return [...fallback];
|
|
515
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim()))
|
|
516
|
+
throw new Error(`${label}: expected non-empty strings`);
|
|
517
|
+
return [...new Set(value.map((item) => String(item).trim()))];
|
|
518
|
+
}
|
|
421
519
|
function normalizeTargets(value) {
|
|
422
520
|
if (!Array.isArray(value))
|
|
423
521
|
return [];
|
|
@@ -431,8 +529,14 @@ function normalizeTargets(value) {
|
|
|
431
529
|
function cloneTicketSetup(setup) {
|
|
432
530
|
return {
|
|
433
531
|
sources: setup.sources.map((source) => ({ ...source, ...(source.type === "local" ? { paths: [...source.paths] } : {}) })),
|
|
532
|
+
limits: { ...setup.limits },
|
|
434
533
|
populate: { ...setup.populate },
|
|
435
|
-
build: {
|
|
534
|
+
build: {
|
|
535
|
+
...setup.build,
|
|
536
|
+
branch_policy: { ...setup.build.branch_policy, by_size: { ...setup.build.branch_policy.by_size } },
|
|
537
|
+
review: { ...setup.build.review, description_sections: [...setup.build.review.description_sections] },
|
|
538
|
+
validation_checklist: [...setup.build.validation_checklist],
|
|
539
|
+
},
|
|
436
540
|
};
|
|
437
541
|
}
|
|
438
542
|
function unique(values) {
|
|
@@ -89,6 +89,8 @@ export interface OperationReceipt {
|
|
|
89
89
|
completed_at: string;
|
|
90
90
|
payload_json: string;
|
|
91
91
|
}
|
|
92
|
+
/** The only valid initial/fully-reset active state for a ticket. */
|
|
93
|
+
export declare function pristineTicketState(ticketId: string, now: string, actor?: string | null): TicketState;
|
|
92
94
|
export declare class StateDb {
|
|
93
95
|
private db;
|
|
94
96
|
constructor(dbPath: string);
|
|
@@ -96,17 +98,22 @@ export declare class StateDb {
|
|
|
96
98
|
close(): void;
|
|
97
99
|
getState(ticketId: string): TicketState | undefined;
|
|
98
100
|
getAllStates(): Map<string, TicketState>;
|
|
101
|
+
deleteState(ticketId: string): void;
|
|
99
102
|
upsertState(ticketId: string, patch: Partial<Omit<TicketState, "ticket_id">>, now: string): void;
|
|
100
|
-
insertEvent(event: Omit<TicketEvent, "id">):
|
|
103
|
+
insertEvent(event: Omit<TicketEvent, "id">): number;
|
|
104
|
+
deleteEvents(ids: number[]): void;
|
|
101
105
|
getRecentEvents(limit: number): TicketEvent[];
|
|
106
|
+
getTicketEvents(ticketId: string): TicketEvent[];
|
|
102
107
|
insertValidationSnapshot(snap: Omit<ValidationSnapshot, "id">): void;
|
|
103
108
|
getRecentValidationSnapshot(): ValidationSnapshot | undefined;
|
|
109
|
+
getValidationSnapshots(scope?: string): ValidationSnapshot[];
|
|
104
110
|
insertFutureWork(item: Omit<FutureWorkItem, "id">): number;
|
|
105
111
|
getFutureWork(): FutureWorkItem[];
|
|
106
112
|
getFutureWorkById(id: number): FutureWorkItem | undefined;
|
|
107
113
|
updateFutureWorkDisposition(id: number, disposition: string): void;
|
|
108
114
|
getRecentCompleted(): RecentCompletedContext[];
|
|
109
115
|
upsertRecentCompleted(row: RecentCompletedContext): void;
|
|
116
|
+
deleteRecentCompleted(ticketId: string): void;
|
|
110
117
|
getArchiveIndex(): ArchiveIndexEntry[];
|
|
111
118
|
insertReviewRecommendation(item: Omit<ReviewRecommendation, "id">): number;
|
|
112
119
|
getReviewRecommendations(status?: ReviewRecommendationStatus): ReviewRecommendation[];
|
package/dist/tickets/stateDb.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
+
/** The only valid initial/fully-reset active state for a ticket. */
|
|
5
|
+
export function pristineTicketState(ticketId, now, actor = null) {
|
|
6
|
+
return {
|
|
7
|
+
ticket_id: ticketId,
|
|
8
|
+
status: "planned",
|
|
9
|
+
owner: null,
|
|
10
|
+
current_step: null,
|
|
11
|
+
next_action: null,
|
|
12
|
+
blocked_by_json: "[]",
|
|
13
|
+
blocker_type: null,
|
|
14
|
+
blocker_notes: null,
|
|
15
|
+
first_blocked_at: null,
|
|
16
|
+
last_checked_at: null,
|
|
17
|
+
last_worked_at: null,
|
|
18
|
+
completed_at: null,
|
|
19
|
+
attempt_count: 0,
|
|
20
|
+
last_error: null,
|
|
21
|
+
evidence: null,
|
|
22
|
+
validation_result: null,
|
|
23
|
+
validation_commands: null,
|
|
24
|
+
validation_notes: null,
|
|
25
|
+
updated_at: now,
|
|
26
|
+
updated_by: actor,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
4
29
|
const INIT_SQL = `
|
|
5
30
|
PRAGMA foreign_keys = ON;
|
|
6
31
|
|
|
@@ -160,30 +185,12 @@ export class StateDb {
|
|
|
160
185
|
m.set(row.ticket_id, row);
|
|
161
186
|
return m;
|
|
162
187
|
}
|
|
188
|
+
deleteState(ticketId) {
|
|
189
|
+
this.db.prepare("DELETE FROM ticket_state WHERE ticket_id = ?").run(ticketId);
|
|
190
|
+
}
|
|
163
191
|
upsertState(ticketId, patch, now) {
|
|
164
192
|
const existing = this.getState(ticketId);
|
|
165
|
-
const base = existing ??
|
|
166
|
-
ticket_id: ticketId,
|
|
167
|
-
status: "planned",
|
|
168
|
-
owner: null,
|
|
169
|
-
current_step: null,
|
|
170
|
-
next_action: null,
|
|
171
|
-
blocked_by_json: "[]",
|
|
172
|
-
blocker_type: null,
|
|
173
|
-
blocker_notes: null,
|
|
174
|
-
first_blocked_at: null,
|
|
175
|
-
last_checked_at: null,
|
|
176
|
-
last_worked_at: null,
|
|
177
|
-
completed_at: null,
|
|
178
|
-
attempt_count: 0,
|
|
179
|
-
last_error: null,
|
|
180
|
-
evidence: null,
|
|
181
|
-
validation_result: null,
|
|
182
|
-
validation_commands: null,
|
|
183
|
-
validation_notes: null,
|
|
184
|
-
updated_at: now,
|
|
185
|
-
updated_by: null,
|
|
186
|
-
};
|
|
193
|
+
const base = existing ?? pristineTicketState(ticketId, now);
|
|
187
194
|
const merged = { ...base, ...patch, ticket_id: ticketId, updated_at: now };
|
|
188
195
|
if (existing) {
|
|
189
196
|
this.db.prepare(`
|
|
@@ -218,7 +225,7 @@ export class StateDb {
|
|
|
218
225
|
}
|
|
219
226
|
// ── ticket_events ─────────────────────────────────────────────────────────
|
|
220
227
|
insertEvent(event) {
|
|
221
|
-
this.db.prepare(`
|
|
228
|
+
const result = this.db.prepare(`
|
|
222
229
|
INSERT INTO ticket_events (
|
|
223
230
|
timestamp,actor,ticket_id,event_type,old_status,new_status,
|
|
224
231
|
summary,validation,evidence,payload_json
|
|
@@ -227,12 +234,23 @@ export class StateDb {
|
|
|
227
234
|
@summary,@validation,@evidence,@payload_json
|
|
228
235
|
)
|
|
229
236
|
`).run(event);
|
|
237
|
+
return Number(result.lastInsertRowid);
|
|
238
|
+
}
|
|
239
|
+
deleteEvents(ids) {
|
|
240
|
+
const remove = this.db.prepare("DELETE FROM ticket_events WHERE id = ?");
|
|
241
|
+
for (const id of ids)
|
|
242
|
+
remove.run(id);
|
|
230
243
|
}
|
|
231
244
|
getRecentEvents(limit) {
|
|
232
245
|
return this.db
|
|
233
246
|
.prepare("SELECT * FROM ticket_events ORDER BY timestamp DESC, id DESC LIMIT ?")
|
|
234
247
|
.all(limit);
|
|
235
248
|
}
|
|
249
|
+
getTicketEvents(ticketId) {
|
|
250
|
+
return this.db
|
|
251
|
+
.prepare("SELECT * FROM ticket_events WHERE ticket_id = ? ORDER BY timestamp, id")
|
|
252
|
+
.all(ticketId);
|
|
253
|
+
}
|
|
236
254
|
// ── validation_snapshots ──────────────────────────────────────────────────
|
|
237
255
|
insertValidationSnapshot(snap) {
|
|
238
256
|
this.db.prepare(`
|
|
@@ -245,6 +263,16 @@ export class StateDb {
|
|
|
245
263
|
.prepare("SELECT * FROM validation_snapshots ORDER BY timestamp DESC, id DESC LIMIT 1")
|
|
246
264
|
.get();
|
|
247
265
|
}
|
|
266
|
+
getValidationSnapshots(scope) {
|
|
267
|
+
if (scope !== undefined) {
|
|
268
|
+
return this.db
|
|
269
|
+
.prepare("SELECT * FROM validation_snapshots WHERE scope = ? ORDER BY timestamp, id")
|
|
270
|
+
.all(scope);
|
|
271
|
+
}
|
|
272
|
+
return this.db
|
|
273
|
+
.prepare("SELECT * FROM validation_snapshots ORDER BY timestamp, id")
|
|
274
|
+
.all();
|
|
275
|
+
}
|
|
248
276
|
// ── future_work ───────────────────────────────────────────────────────────
|
|
249
277
|
insertFutureWork(item) {
|
|
250
278
|
const r = this.db.prepare(`
|
|
@@ -287,6 +315,9 @@ export class StateDb {
|
|
|
287
315
|
updated_at=excluded.updated_at
|
|
288
316
|
`).run(row);
|
|
289
317
|
}
|
|
318
|
+
deleteRecentCompleted(ticketId) {
|
|
319
|
+
this.db.prepare("DELETE FROM recent_completed_context WHERE ticket_id = ?").run(ticketId);
|
|
320
|
+
}
|
|
290
321
|
// ── archive_index ─────────────────────────────────────────────────────────
|
|
291
322
|
getArchiveIndex() {
|
|
292
323
|
return this.db
|