ai-foreman 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/branch/git.d.ts +2 -0
- package/dist/branch/git.js +18 -1
- package/dist/branch/github.d.ts +3 -1
- package/dist/branch/github.js +32 -0
- package/dist/branch/gitlab.d.ts +15 -0
- package/dist/branch/gitlab.js +290 -0
- package/dist/branch/planner.d.ts +1 -0
- package/dist/branch/planner.js +3 -1
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +7 -1
- package/dist/branch/runner.d.ts +6 -1
- package/dist/branch/runner.js +155 -20
- package/dist/branch/types.d.ts +10 -2
- package/dist/cli/start.js +135 -7
- package/dist/cli/tickets.d.ts +11 -0
- package/dist/cli/tickets.js +736 -92
- package/dist/foreman.js +2 -2
- package/dist/log.d.ts +1 -1
- package/dist/tickets/commands.d.ts +6 -0
- package/dist/tickets/commands.js +40 -3
- package/dist/tickets/config.d.ts +2 -1
- package/dist/tickets/config.js +20 -4
- package/dist/tickets/importer.d.ts +55 -0
- package/dist/tickets/importer.js +480 -0
- package/dist/tickets/recommendations.d.ts +32 -0
- package/dist/tickets/recommendations.js +183 -0
- package/dist/tickets/renderMarkdown.js +26 -2
- package/dist/tickets/setupConfig.d.ts +77 -0
- package/dist/tickets/setupConfig.js +369 -0
- package/dist/tickets/stateDb.d.ts +17 -0
- package/dist/tickets/stateDb.js +46 -0
- package/dist/tickets/ticketSchema.d.ts +55 -0
- package/dist/tickets/ticketSchema.js +14 -0
- package/dist/tickets/validate.js +2 -2
- package/package.json +2 -2
package/dist/tickets/importer.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { loadTickets, saveTickets, validateTicketDefs } from "./ticketLoader.js";
|
|
4
|
+
import { StateDb } from "./stateDb.js";
|
|
5
|
+
import { nowTimestamp } from "./events.js";
|
|
6
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
|
|
7
|
+
import { insertXlSplitRecommendations } from "./recommendations.js";
|
|
1
8
|
export function importFromMarkdown(_progressDocPath) {
|
|
2
9
|
throw new Error("foreman tickets import is not yet implemented.\n\n" +
|
|
3
10
|
"To start fresh:\n" +
|
|
@@ -9,3 +16,476 @@ export function importFromMarkdown(_progressDocPath) {
|
|
|
9
16
|
" 2. Run `foreman tickets validate` to check the structure.\n" +
|
|
10
17
|
" 3. Use `foreman tickets update <id> --status <status>` to restore active ticket states.");
|
|
11
18
|
}
|
|
19
|
+
export async function importExternalSources(projectDir, sources, opts) {
|
|
20
|
+
const results = [];
|
|
21
|
+
for (const source of sources) {
|
|
22
|
+
const items = source.type === "linear"
|
|
23
|
+
? await fetchLinearIssues(source, opts)
|
|
24
|
+
: await fetchJiraIssues(source, opts);
|
|
25
|
+
const snapshotPath = writeImportSnapshot(projectDir, source.type, {
|
|
26
|
+
source: redactSource(source),
|
|
27
|
+
fetched_at: new Date().toISOString(),
|
|
28
|
+
items: items.map((item) => item.raw),
|
|
29
|
+
});
|
|
30
|
+
const applied = applyImportedItems(projectDir, items);
|
|
31
|
+
if (opts.recommendSplitForXl)
|
|
32
|
+
insertXlSplitRecommendations(projectDir, applied.tickets);
|
|
33
|
+
results.push({
|
|
34
|
+
provider: source.type,
|
|
35
|
+
sourceLabel: sourceLabel(source),
|
|
36
|
+
fetched: items.length,
|
|
37
|
+
created: applied.created,
|
|
38
|
+
updated: applied.updated,
|
|
39
|
+
snapshotPath,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
export async function validateExternalSourceAccess(source) {
|
|
45
|
+
if (source.type === "linear") {
|
|
46
|
+
await fetchLinearIssues(source, { importCap: 1, commentLimit: 0 });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await fetchJiraIssues(source, { importCap: 1, commentLimit: 0 });
|
|
50
|
+
}
|
|
51
|
+
export async function fetchLinearIssues(source, opts) {
|
|
52
|
+
const apiKey = process.env[source.api_key_env];
|
|
53
|
+
if (!apiKey)
|
|
54
|
+
throw new Error(`Linear API key env var is not set: ${source.api_key_env}`);
|
|
55
|
+
const items = [];
|
|
56
|
+
let after = null;
|
|
57
|
+
while (items.length < opts.importCap) {
|
|
58
|
+
const first = Math.min(100, opts.importCap - items.length);
|
|
59
|
+
const body = {
|
|
60
|
+
query: LINEAR_ISSUES_QUERY,
|
|
61
|
+
variables: {
|
|
62
|
+
first,
|
|
63
|
+
after,
|
|
64
|
+
commentLimit: opts.commentLimit,
|
|
65
|
+
filter: linearFilter(source),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const response = await fetch("https://api.linear.app/graphql", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
"Authorization": apiKey,
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
const json = await readJsonResponse(response, "Linear GraphQL");
|
|
77
|
+
if (Array.isArray(json.errors) && json.errors.length > 0) {
|
|
78
|
+
throw new Error(`Linear GraphQL error: ${json.errors.map((err) => err.message ?? "unknown").join("; ")}`);
|
|
79
|
+
}
|
|
80
|
+
const issues = json.data?.issues;
|
|
81
|
+
const nodes = Array.isArray(issues?.nodes) ? issues.nodes : [];
|
|
82
|
+
for (const node of nodes) {
|
|
83
|
+
items.push(linearIssueToImportedItem(node, opts.commentLimit));
|
|
84
|
+
if (items.length >= opts.importCap)
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (!issues?.pageInfo?.hasNextPage || !issues.pageInfo.endCursor || nodes.length === 0)
|
|
88
|
+
break;
|
|
89
|
+
after = issues.pageInfo.endCursor;
|
|
90
|
+
}
|
|
91
|
+
return items;
|
|
92
|
+
}
|
|
93
|
+
export async function fetchJiraIssues(source, opts) {
|
|
94
|
+
const email = process.env[source.email_env];
|
|
95
|
+
const token = process.env[source.token_env];
|
|
96
|
+
if (!email)
|
|
97
|
+
throw new Error(`Jira email env var is not set: ${source.email_env}`);
|
|
98
|
+
if (!token)
|
|
99
|
+
throw new Error(`Jira API token env var is not set: ${source.token_env}`);
|
|
100
|
+
const items = [];
|
|
101
|
+
let nextPageToken;
|
|
102
|
+
while (items.length < opts.importCap) {
|
|
103
|
+
const maxResults = Math.min(100, opts.importCap - items.length);
|
|
104
|
+
const body = {
|
|
105
|
+
jql: source.jql,
|
|
106
|
+
maxResults,
|
|
107
|
+
fieldsByKeys: true,
|
|
108
|
+
fields: [
|
|
109
|
+
"summary",
|
|
110
|
+
"description",
|
|
111
|
+
"priority",
|
|
112
|
+
"issuetype",
|
|
113
|
+
"status",
|
|
114
|
+
"labels",
|
|
115
|
+
"comment",
|
|
116
|
+
"assignee",
|
|
117
|
+
"created",
|
|
118
|
+
"updated",
|
|
119
|
+
"project",
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
if (nextPageToken)
|
|
123
|
+
body.nextPageToken = nextPageToken;
|
|
124
|
+
const response = await fetch(`${source.site.replace(/\/+$/, "")}/rest/api/3/search/jql`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
"Accept": "application/json",
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
"Authorization": `Basic ${Buffer.from(`${email}:${token}`).toString("base64")}`,
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
});
|
|
133
|
+
const json = await readJsonResponse(response, "Jira JQL search");
|
|
134
|
+
const issues = Array.isArray(json.issues) ? json.issues : [];
|
|
135
|
+
for (const issue of issues) {
|
|
136
|
+
items.push(jiraIssueToImportedItem(source.site, issue, opts.commentLimit));
|
|
137
|
+
if (items.length >= opts.importCap)
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
nextPageToken = typeof json.nextPageToken === "string" ? json.nextPageToken : undefined;
|
|
141
|
+
if (!nextPageToken || issues.length === 0 || json.isLast === true)
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
return items;
|
|
145
|
+
}
|
|
146
|
+
export function applyImportedItems(projectDir, items) {
|
|
147
|
+
const config = loadTicketsConfig(projectDir);
|
|
148
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
149
|
+
const existing = loadTickets(paths.tickets);
|
|
150
|
+
const tickets = [...existing];
|
|
151
|
+
const now = nowTimestamp(config.timezone);
|
|
152
|
+
const db = new StateDb(paths.stateDb);
|
|
153
|
+
let created = 0;
|
|
154
|
+
let updated = 0;
|
|
155
|
+
try {
|
|
156
|
+
let nextNumber = nextTicketNumber(tickets);
|
|
157
|
+
let nextOrder = nextTicketOrder(tickets);
|
|
158
|
+
for (const item of items) {
|
|
159
|
+
const matchIndex = tickets.findIndex((ticket) => hasExternalRef(ticket, item));
|
|
160
|
+
if (matchIndex >= 0) {
|
|
161
|
+
const current = tickets[matchIndex];
|
|
162
|
+
tickets[matchIndex] = importedItemToTicket(item, current.id, current.order, current);
|
|
163
|
+
updated++;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const id = `T${String(nextNumber++).padStart(3, "0")}`;
|
|
167
|
+
const order = nextOrder;
|
|
168
|
+
nextOrder += 1000;
|
|
169
|
+
tickets.push(importedItemToTicket(item, id, order));
|
|
170
|
+
created++;
|
|
171
|
+
}
|
|
172
|
+
const ticket = matchIndex >= 0 ? tickets[matchIndex] : tickets[tickets.length - 1];
|
|
173
|
+
const status = importedStatus(item);
|
|
174
|
+
if (status) {
|
|
175
|
+
const patch = {
|
|
176
|
+
status,
|
|
177
|
+
completed_at: status === "done" || status === "canceled" ? now : null,
|
|
178
|
+
};
|
|
179
|
+
if (status === "done") {
|
|
180
|
+
patch.evidence = `Imported ${item.provider} status: ${item.status ?? item.statusCategory ?? "done"}`;
|
|
181
|
+
patch.validation_result = "not_applicable";
|
|
182
|
+
patch.validation_notes = "External tracker marked this item complete before import.";
|
|
183
|
+
}
|
|
184
|
+
db.upsertState(ticket.id, patch, now);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const errors = validateTicketDefs(tickets).filter((issue) => issue.message);
|
|
188
|
+
if (errors.length > 0) {
|
|
189
|
+
throw new Error(`import produced invalid tickets:\n${errors.map((err) => `- ${err.path}: ${err.message}`).join("\n")}`);
|
|
190
|
+
}
|
|
191
|
+
saveTickets(paths.tickets, tickets);
|
|
192
|
+
return { created, updated, tickets };
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
db.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const LINEAR_ISSUES_QUERY = `
|
|
199
|
+
query RafiImportIssues($first: Int!, $after: String, $commentLimit: Int!, $filter: IssueFilter) {
|
|
200
|
+
issues(first: $first, after: $after, filter: $filter) {
|
|
201
|
+
nodes {
|
|
202
|
+
id
|
|
203
|
+
identifier
|
|
204
|
+
title
|
|
205
|
+
description
|
|
206
|
+
priority
|
|
207
|
+
estimate
|
|
208
|
+
url
|
|
209
|
+
createdAt
|
|
210
|
+
updatedAt
|
|
211
|
+
state { name type }
|
|
212
|
+
team { key name }
|
|
213
|
+
project { name }
|
|
214
|
+
labels { nodes { name } }
|
|
215
|
+
comments(first: $commentLimit) {
|
|
216
|
+
nodes {
|
|
217
|
+
id
|
|
218
|
+
body
|
|
219
|
+
createdAt
|
|
220
|
+
user { name }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
pageInfo { hasNextPage endCursor }
|
|
225
|
+
}
|
|
226
|
+
}`;
|
|
227
|
+
async function readJsonResponse(response, label) {
|
|
228
|
+
const text = await response.text();
|
|
229
|
+
let json;
|
|
230
|
+
try {
|
|
231
|
+
json = text ? JSON.parse(text) : {};
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
throw new Error(`${label} returned non-JSON response (${response.status}): ${text.slice(0, 300)}`);
|
|
235
|
+
}
|
|
236
|
+
if (!response.ok) {
|
|
237
|
+
const message = json?.errorMessages?.join("; ") ?? json?.message ?? json?.errors?.[0]?.message ?? text.slice(0, 300);
|
|
238
|
+
throw new Error(`${label} request failed (${response.status}): ${message}`);
|
|
239
|
+
}
|
|
240
|
+
return json;
|
|
241
|
+
}
|
|
242
|
+
function linearFilter(source) {
|
|
243
|
+
const parts = [];
|
|
244
|
+
if (source.team_key)
|
|
245
|
+
parts.push({ team: { key: { eq: source.team_key } } });
|
|
246
|
+
if (source.filter) {
|
|
247
|
+
try {
|
|
248
|
+
const parsed = JSON.parse(source.filter);
|
|
249
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
250
|
+
parts.push(parsed);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
parts.push({ title: { containsIgnoreCase: source.filter } });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (parts.length === 0)
|
|
257
|
+
return undefined;
|
|
258
|
+
if (parts.length === 1)
|
|
259
|
+
return parts[0];
|
|
260
|
+
return { and: parts };
|
|
261
|
+
}
|
|
262
|
+
function linearIssueToImportedItem(issue, commentLimit) {
|
|
263
|
+
const labels = Array.isArray(issue.labels?.nodes)
|
|
264
|
+
? issue.labels.nodes.map((label) => String(label.name ?? "")).filter(Boolean)
|
|
265
|
+
: [];
|
|
266
|
+
const comments = Array.isArray(issue.comments?.nodes)
|
|
267
|
+
? issue.comments.nodes.slice(0, commentLimit).map((comment) => ({
|
|
268
|
+
author: typeof comment.user?.name === "string" ? comment.user.name : null,
|
|
269
|
+
body: String(comment.body ?? ""),
|
|
270
|
+
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : null,
|
|
271
|
+
})).filter((comment) => comment.body.trim())
|
|
272
|
+
: [];
|
|
273
|
+
return {
|
|
274
|
+
provider: "linear",
|
|
275
|
+
providerId: String(issue.id),
|
|
276
|
+
key: typeof issue.identifier === "string" ? issue.identifier : null,
|
|
277
|
+
url: typeof issue.url === "string" ? issue.url : null,
|
|
278
|
+
title: String(issue.title ?? issue.identifier ?? issue.id),
|
|
279
|
+
description: typeof issue.description === "string" ? issue.description : null,
|
|
280
|
+
area: issue.project?.name ?? issue.team?.name ?? issue.team?.key ?? null,
|
|
281
|
+
priority: typeof issue.priority === "number" ? issue.priority : null,
|
|
282
|
+
size: typeof issue.estimate === "number" ? issue.estimate : null,
|
|
283
|
+
status: issue.state?.name ?? null,
|
|
284
|
+
statusCategory: issue.state?.type ?? null,
|
|
285
|
+
labels,
|
|
286
|
+
comments,
|
|
287
|
+
createdAt: issue.createdAt ?? null,
|
|
288
|
+
updatedAt: issue.updatedAt ?? null,
|
|
289
|
+
raw: issue,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function jiraIssueToImportedItem(site, issue, commentLimit) {
|
|
293
|
+
const fields = issue.fields ?? {};
|
|
294
|
+
const comments = Array.isArray(fields.comment?.comments)
|
|
295
|
+
? fields.comment.comments.slice(0, commentLimit).map((comment) => ({
|
|
296
|
+
author: comment.author?.displayName ?? null,
|
|
297
|
+
body: adfToText(comment.body),
|
|
298
|
+
createdAt: typeof comment.created === "string" ? comment.created : null,
|
|
299
|
+
})).filter((comment) => comment.body.trim())
|
|
300
|
+
: [];
|
|
301
|
+
return {
|
|
302
|
+
provider: "jira",
|
|
303
|
+
providerId: String(issue.id ?? issue.key),
|
|
304
|
+
key: typeof issue.key === "string" ? issue.key : null,
|
|
305
|
+
url: issue.key ? `${site.replace(/\/+$/, "")}/browse/${issue.key}` : null,
|
|
306
|
+
title: String(fields.summary ?? issue.key ?? issue.id),
|
|
307
|
+
description: adfToText(fields.description),
|
|
308
|
+
area: fields.project?.name ?? fields.issuetype?.name ?? null,
|
|
309
|
+
priority: fields.priority?.name ?? null,
|
|
310
|
+
size: null,
|
|
311
|
+
status: fields.status?.name ?? null,
|
|
312
|
+
statusCategory: fields.status?.statusCategory?.name ?? null,
|
|
313
|
+
labels: Array.isArray(fields.labels) ? fields.labels.map(String) : [],
|
|
314
|
+
comments,
|
|
315
|
+
createdAt: fields.created ?? null,
|
|
316
|
+
updatedAt: fields.updated ?? null,
|
|
317
|
+
raw: issue,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function importedItemToTicket(item, id, order, existing) {
|
|
321
|
+
const priority = mapPriority(item);
|
|
322
|
+
const size = mapSize(item);
|
|
323
|
+
const risk = mapRisk(priority, item);
|
|
324
|
+
const notes = buildImportedNotes(item);
|
|
325
|
+
return {
|
|
326
|
+
id,
|
|
327
|
+
order,
|
|
328
|
+
title: item.key ? `${item.key}: ${item.title}` : item.title,
|
|
329
|
+
area: item.area || existing?.area || "Imported",
|
|
330
|
+
priority,
|
|
331
|
+
size,
|
|
332
|
+
risk,
|
|
333
|
+
depends_on: existing?.depends_on ?? [],
|
|
334
|
+
summary: item.description?.trim() || item.title,
|
|
335
|
+
acceptance: existing?.acceptance?.length ? existing.acceptance : [
|
|
336
|
+
`Imported ${item.provider} item remains represented in Rafi.`,
|
|
337
|
+
item.url ? `Provider reference is preserved: ${item.url}` : "Provider reference is preserved.",
|
|
338
|
+
],
|
|
339
|
+
required_tests: existing?.required_tests?.length ? existing.required_tests : [
|
|
340
|
+
"Review provider details before implementation.",
|
|
341
|
+
],
|
|
342
|
+
likely_files: existing?.likely_files ?? [],
|
|
343
|
+
rollback: existing?.rollback ?? (risk === "Low" ? null : "Revert the imported implementation changes for this provider item."),
|
|
344
|
+
notes,
|
|
345
|
+
external_refs: mergeExternalRefs(existing?.external_refs, {
|
|
346
|
+
provider: item.provider,
|
|
347
|
+
id: item.providerId,
|
|
348
|
+
key: item.key ?? null,
|
|
349
|
+
url: item.url ?? null,
|
|
350
|
+
}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function buildImportedNotes(item) {
|
|
354
|
+
const lines = [
|
|
355
|
+
`Imported from ${item.provider}${item.key ? ` ${item.key}` : ""}.`,
|
|
356
|
+
item.url ? `URL: ${item.url}` : null,
|
|
357
|
+
item.status ? `Status: ${item.status}${item.statusCategory ? ` (${item.statusCategory})` : ""}` : null,
|
|
358
|
+
item.labels?.length ? `Labels: ${item.labels.join(", ")}` : null,
|
|
359
|
+
item.comments?.length ? "Comments:" : null,
|
|
360
|
+
...(item.comments ?? []).map((comment) => {
|
|
361
|
+
const author = comment.author ? `${comment.author}: ` : "";
|
|
362
|
+
return `- ${author}${comment.body.replace(/\s+/g, " ").trim()}`;
|
|
363
|
+
}),
|
|
364
|
+
].filter(Boolean);
|
|
365
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
366
|
+
}
|
|
367
|
+
function mergeExternalRefs(existing, next) {
|
|
368
|
+
const refs = [...(existing ?? [])];
|
|
369
|
+
const index = refs.findIndex((ref) => ref.provider === next.provider && (ref.id === next.id || (next.key && ref.key === next.key)));
|
|
370
|
+
if (index >= 0)
|
|
371
|
+
refs[index] = { ...refs[index], ...next };
|
|
372
|
+
else
|
|
373
|
+
refs.push(next);
|
|
374
|
+
return refs;
|
|
375
|
+
}
|
|
376
|
+
function mapPriority(item) {
|
|
377
|
+
if (item.provider === "linear" && typeof item.priority === "number") {
|
|
378
|
+
if (item.priority <= 1 && item.priority > 0)
|
|
379
|
+
return "P0";
|
|
380
|
+
if (item.priority === 2)
|
|
381
|
+
return "P1";
|
|
382
|
+
if (item.priority === 3)
|
|
383
|
+
return "P2";
|
|
384
|
+
return "P3";
|
|
385
|
+
}
|
|
386
|
+
const raw = String(item.priority ?? "").toLowerCase();
|
|
387
|
+
if (/highest|blocker|urgent/.test(raw))
|
|
388
|
+
return "P0";
|
|
389
|
+
if (/high|critical/.test(raw))
|
|
390
|
+
return "P1";
|
|
391
|
+
if (/medium|normal/.test(raw))
|
|
392
|
+
return "P2";
|
|
393
|
+
if (/low|lowest|minor/.test(raw))
|
|
394
|
+
return "P3";
|
|
395
|
+
return "P2";
|
|
396
|
+
}
|
|
397
|
+
function mapSize(item) {
|
|
398
|
+
if (typeof item.size === "number") {
|
|
399
|
+
if (item.size >= 8)
|
|
400
|
+
return "XL";
|
|
401
|
+
if (item.size >= 5)
|
|
402
|
+
return "L";
|
|
403
|
+
if (item.size >= 3)
|
|
404
|
+
return "M";
|
|
405
|
+
if (item.size >= 1)
|
|
406
|
+
return "S";
|
|
407
|
+
}
|
|
408
|
+
const raw = String(item.size ?? "").toUpperCase();
|
|
409
|
+
if (["XS", "S", "M", "L", "XL"].includes(raw))
|
|
410
|
+
return raw;
|
|
411
|
+
return "M";
|
|
412
|
+
}
|
|
413
|
+
function mapRisk(priority, item) {
|
|
414
|
+
const labels = (item.labels ?? []).join(" ").toLowerCase();
|
|
415
|
+
if (priority === "P0" || /\b(security|data|migration|billing|auth)\b/.test(labels))
|
|
416
|
+
return "High";
|
|
417
|
+
if (priority === "P1")
|
|
418
|
+
return "Medium";
|
|
419
|
+
return "Low";
|
|
420
|
+
}
|
|
421
|
+
function importedStatus(item) {
|
|
422
|
+
const text = `${item.status ?? ""} ${item.statusCategory ?? ""}`.toLowerCase();
|
|
423
|
+
if (/\b(done|completed|complete|closed|resolved)\b/.test(text))
|
|
424
|
+
return "done";
|
|
425
|
+
if (/\b(canceled|cancelled|duplicate|wontfix|won't fix)\b/.test(text))
|
|
426
|
+
return "canceled";
|
|
427
|
+
if (/\b(started|in progress|indeterminate)\b/.test(text))
|
|
428
|
+
return "in_progress";
|
|
429
|
+
if (/\b(blocked)\b/.test(text))
|
|
430
|
+
return "blocked";
|
|
431
|
+
return "planned";
|
|
432
|
+
}
|
|
433
|
+
function hasExternalRef(ticket, item) {
|
|
434
|
+
return (ticket.external_refs ?? []).some((ref) => ref.provider === item.provider && (ref.id === item.providerId || Boolean(item.key && ref.key === item.key)));
|
|
435
|
+
}
|
|
436
|
+
function nextTicketNumber(tickets) {
|
|
437
|
+
let max = 0;
|
|
438
|
+
for (const ticket of tickets) {
|
|
439
|
+
const match = /^T(\d+)$/i.exec(ticket.id);
|
|
440
|
+
if (match)
|
|
441
|
+
max = Math.max(max, Number.parseInt(match[1], 10));
|
|
442
|
+
}
|
|
443
|
+
return max + 1;
|
|
444
|
+
}
|
|
445
|
+
function nextTicketOrder(tickets) {
|
|
446
|
+
const max = tickets.reduce((highest, ticket) => Math.max(highest, ticket.order), 0);
|
|
447
|
+
return Math.max(1000, Math.ceil(max / 1000) * 1000 + 1000);
|
|
448
|
+
}
|
|
449
|
+
function writeImportSnapshot(projectDir, provider, payload) {
|
|
450
|
+
const dir = join(projectDir, ".tickets", "imports");
|
|
451
|
+
mkdirSync(dir, { recursive: true });
|
|
452
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
453
|
+
const path = join(dir, `${stamp}-${provider}.json`);
|
|
454
|
+
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
455
|
+
return relative(projectDir, path).replace(/\\/g, "/");
|
|
456
|
+
}
|
|
457
|
+
function redactSource(source) {
|
|
458
|
+
if (source.type === "linear") {
|
|
459
|
+
return { type: source.type, api_key_env: source.api_key_env, team_key: source.team_key, filter: source.filter };
|
|
460
|
+
}
|
|
461
|
+
if (source.type === "jira") {
|
|
462
|
+
return { type: source.type, site: source.site, email_env: source.email_env, token_env: source.token_env, jql: source.jql };
|
|
463
|
+
}
|
|
464
|
+
return source;
|
|
465
|
+
}
|
|
466
|
+
function sourceLabel(source) {
|
|
467
|
+
if (source.type === "linear")
|
|
468
|
+
return source.team_key ? `Linear team ${source.team_key}` : "Linear";
|
|
469
|
+
if (source.type === "jira")
|
|
470
|
+
return `Jira ${source.site}`;
|
|
471
|
+
return "local";
|
|
472
|
+
}
|
|
473
|
+
function adfToText(value) {
|
|
474
|
+
if (!value)
|
|
475
|
+
return "";
|
|
476
|
+
if (typeof value === "string")
|
|
477
|
+
return value;
|
|
478
|
+
if (Array.isArray(value))
|
|
479
|
+
return value.map(adfToText).filter(Boolean).join("\n").trim();
|
|
480
|
+
if (typeof value !== "object")
|
|
481
|
+
return "";
|
|
482
|
+
const node = value;
|
|
483
|
+
if (typeof node.text === "string")
|
|
484
|
+
return node.text;
|
|
485
|
+
const content = adfToText(node.content);
|
|
486
|
+
if (node.type === "paragraph" || node.type === "heading" || node.type === "listItem")
|
|
487
|
+
return content;
|
|
488
|
+
if (node.type === "bulletList" || node.type === "orderedList" || node.type === "doc")
|
|
489
|
+
return content;
|
|
490
|
+
return content;
|
|
491
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { TicketDef } from "./ticketSchema.js";
|
|
2
|
+
import { StateDb, type ReviewRecommendation } from "./stateDb.js";
|
|
3
|
+
export interface TicketRecommendationPatch {
|
|
4
|
+
add?: TicketDef[];
|
|
5
|
+
update?: Array<{
|
|
6
|
+
id: string;
|
|
7
|
+
set: Partial<TicketDef>;
|
|
8
|
+
}>;
|
|
9
|
+
remove?: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface CreateRecommendationInput {
|
|
12
|
+
kind: string;
|
|
13
|
+
summary: string;
|
|
14
|
+
rationale?: string | null;
|
|
15
|
+
ticketIds?: string[];
|
|
16
|
+
patch: TicketRecommendationPatch;
|
|
17
|
+
source?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
export interface ReviewCommandOptions {
|
|
20
|
+
action?: "accept" | "dismiss" | "defer";
|
|
21
|
+
ids?: number[];
|
|
22
|
+
all?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface ReviewCommandResult {
|
|
25
|
+
pending: ReviewRecommendation[];
|
|
26
|
+
changed: number;
|
|
27
|
+
action?: "accept" | "dismiss" | "defer";
|
|
28
|
+
}
|
|
29
|
+
export declare function createReviewRecommendation(db: StateDb, now: string, input: CreateRecommendationInput): number;
|
|
30
|
+
export declare function cmdReviewRecommendations(projectDir: string, opts?: ReviewCommandOptions): ReviewCommandResult;
|
|
31
|
+
export declare function applyRecommendationPatch(tickets: TicketDef[], patch: TicketRecommendationPatch): TicketDef[];
|
|
32
|
+
export declare function insertXlSplitRecommendations(projectDir: string, tickets: TicketDef[]): number;
|