agentplane 0.1.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.
Files changed (61) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +19 -0
  3. package/assets/AGENTS.md +274 -0
  4. package/assets/agents/CODER.json +28 -0
  5. package/assets/agents/CREATOR.json +25 -0
  6. package/assets/agents/DOCS.json +25 -0
  7. package/assets/agents/INTEGRATOR.json +27 -0
  8. package/assets/agents/ORCHESTRATOR.json +31 -0
  9. package/assets/agents/PLANNER.json +28 -0
  10. package/assets/agents/REDMINE.json +26 -0
  11. package/assets/agents/REVIEWER.json +22 -0
  12. package/assets/agents/TESTER.json +27 -0
  13. package/assets/agents/UPDATER.json +23 -0
  14. package/assets/agents/UPGRADER.json +27 -0
  15. package/bin/agentplane.js +2 -0
  16. package/dist/agents-template.d.ts +10 -0
  17. package/dist/agents-template.d.ts.map +1 -0
  18. package/dist/agents-template.js +66 -0
  19. package/dist/bundled-recipes.d.ts +13 -0
  20. package/dist/bundled-recipes.d.ts.map +1 -0
  21. package/dist/bundled-recipes.js +4 -0
  22. package/dist/cli/fs-utils.d.ts +4 -0
  23. package/dist/cli/fs-utils.d.ts.map +1 -0
  24. package/dist/cli/fs-utils.js +28 -0
  25. package/dist/cli/prompts.d.ts +4 -0
  26. package/dist/cli/prompts.d.ts.map +1 -0
  27. package/dist/cli/prompts.js +31 -0
  28. package/dist/cli/recipes-bundled.d.ts +9 -0
  29. package/dist/cli/recipes-bundled.d.ts.map +1 -0
  30. package/dist/cli/recipes-bundled.js +33 -0
  31. package/dist/cli.d.ts +3 -0
  32. package/dist/cli.d.ts.map +1 -0
  33. package/dist/cli.js +5 -0
  34. package/dist/command-guide.d.ts +4 -0
  35. package/dist/command-guide.d.ts.map +1 -0
  36. package/dist/command-guide.js +244 -0
  37. package/dist/comment-format.d.ts +8 -0
  38. package/dist/comment-format.d.ts.map +1 -0
  39. package/dist/comment-format.js +80 -0
  40. package/dist/env.d.ts +3 -0
  41. package/dist/env.d.ts.map +1 -0
  42. package/dist/env.js +49 -0
  43. package/dist/errors.d.ts +16 -0
  44. package/dist/errors.d.ts.map +1 -0
  45. package/dist/errors.js +22 -0
  46. package/dist/help.d.ts +2 -0
  47. package/dist/help.d.ts.map +1 -0
  48. package/dist/help.js +124 -0
  49. package/dist/run-cli.d.ts +2 -0
  50. package/dist/run-cli.d.ts.map +1 -0
  51. package/dist/run-cli.js +8412 -0
  52. package/dist/run-cli.test-helpers.d.ts +44 -0
  53. package/dist/run-cli.test-helpers.d.ts.map +1 -0
  54. package/dist/run-cli.test-helpers.js +280 -0
  55. package/dist/task-backend.d.ts +175 -0
  56. package/dist/task-backend.d.ts.map +1 -0
  57. package/dist/task-backend.js +1235 -0
  58. package/dist/version.d.ts +2 -0
  59. package/dist/version.d.ts.map +1 -0
  60. package/dist/version.js +3 -0
  61. package/package.json +59 -0
@@ -0,0 +1,1235 @@
1
+ import { createHash, randomInt } from "node:crypto";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { canonicalizeJson, loadConfig, parseTaskReadme, renderTaskReadme, resolveProject, taskReadmePath, } from "@agentplane/core";
5
+ import { loadDotEnv } from "./env.js";
6
+ const ID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
7
+ const TASK_ID_RE = new RegExp(String.raw `^\d{12}-[${ID_ALPHABET}]{4,}$`);
8
+ const DOC_SECTION_HEADER = "## Summary";
9
+ const AUTO_SUMMARY_HEADER = "## Changes Summary (auto)";
10
+ const DEFAULT_DOC_UPDATED_BY = "agentplane";
11
+ const DOC_VERSION = 2;
12
+ function nowIso() {
13
+ return new Date().toISOString();
14
+ }
15
+ function isRecord(value) {
16
+ return !!value && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+ function toStringSafe(value) {
19
+ if (typeof value === "string")
20
+ return value;
21
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
22
+ return String(value);
23
+ }
24
+ return "";
25
+ }
26
+ function firstNonEmptyString(...values) {
27
+ for (const value of values) {
28
+ if (typeof value === "string" && value.trim().length > 0) {
29
+ return value.trim();
30
+ }
31
+ }
32
+ return "";
33
+ }
34
+ function sleep(ms) {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+ function normalizeDoc(text) {
38
+ return (text ?? "")
39
+ .split("\n")
40
+ .map((line) => line.replaceAll(/\s+$/gu, ""))
41
+ .join("\n")
42
+ .trim();
43
+ }
44
+ function docChanged(existing, updated) {
45
+ return normalizeDoc(existing) !== normalizeDoc(updated);
46
+ }
47
+ function ensureDocMetadata(task, updatedBy) {
48
+ task.doc_version = DOC_VERSION;
49
+ task.doc_updated_at = nowIso();
50
+ task.doc_updated_by = updatedBy ?? DEFAULT_DOC_UPDATED_BY;
51
+ }
52
+ export function extractTaskDoc(body) {
53
+ if (!body)
54
+ return "";
55
+ const lines = body.split("\n");
56
+ let startIdx = null;
57
+ for (const [idx, line] of lines.entries()) {
58
+ if (line.trim() === DOC_SECTION_HEADER) {
59
+ startIdx = idx;
60
+ break;
61
+ }
62
+ }
63
+ if (startIdx === null)
64
+ return "";
65
+ let endIdx = lines.length;
66
+ for (let idx = startIdx + 1; idx < lines.length; idx++) {
67
+ if (lines[idx]?.trim() === AUTO_SUMMARY_HEADER) {
68
+ endIdx = idx;
69
+ break;
70
+ }
71
+ }
72
+ return lines.slice(startIdx, endIdx).join("\n").trimEnd();
73
+ }
74
+ export function mergeTaskDoc(body, doc) {
75
+ const docText = String(doc ?? "").replaceAll(/^\n+|\n+$/g, "");
76
+ if (docText) {
77
+ const lines = body ? body.split("\n") : [];
78
+ let prefixIdx = null;
79
+ for (const [idx, line] of lines.entries()) {
80
+ if (line.trim() === DOC_SECTION_HEADER) {
81
+ prefixIdx = idx;
82
+ break;
83
+ }
84
+ }
85
+ const prefixText = prefixIdx === null ? "" : lines.slice(0, prefixIdx).join("\n").trimEnd();
86
+ let autoIdx = null;
87
+ for (const [idx, line] of lines.entries()) {
88
+ if (line.trim() === AUTO_SUMMARY_HEADER) {
89
+ autoIdx = idx;
90
+ break;
91
+ }
92
+ }
93
+ const autoBlock = autoIdx === null ? "" : lines.slice(autoIdx).join("\n").trimEnd();
94
+ const parts = [];
95
+ if (prefixText) {
96
+ parts.push(prefixText, "");
97
+ }
98
+ parts.push(docText.trimEnd());
99
+ if (autoBlock) {
100
+ parts.push("", autoBlock);
101
+ }
102
+ return `${parts.join("\n").trimEnd()}\n`;
103
+ }
104
+ return body;
105
+ }
106
+ function validateTaskId(taskId) {
107
+ if (TASK_ID_RE.test(taskId))
108
+ return;
109
+ throw new Error(`Invalid task id: ${taskId}`);
110
+ }
111
+ export class BackendError extends Error {
112
+ code;
113
+ constructor(message, code) {
114
+ super(message);
115
+ this.code = code;
116
+ }
117
+ }
118
+ export class RedmineUnavailable extends BackendError {
119
+ constructor(message) {
120
+ super(message, "E_NETWORK");
121
+ }
122
+ }
123
+ function toStringArray(value) {
124
+ if (!Array.isArray(value))
125
+ return [];
126
+ return value.filter((v) => typeof v === "string");
127
+ }
128
+ function normalizePriority(value) {
129
+ const raw = toStringSafe(value).trim().toLowerCase();
130
+ if (!raw)
131
+ return "med";
132
+ if (raw === "low")
133
+ return "low";
134
+ if (raw === "normal")
135
+ return "normal";
136
+ if (raw === "medium" || raw === "med")
137
+ return "med";
138
+ if (raw === "high")
139
+ return "high";
140
+ if (raw === "urgent" || raw === "immediate")
141
+ return "high";
142
+ return "med";
143
+ }
144
+ export function taskRecordToData(record) {
145
+ const fm = record.frontmatter;
146
+ const comments = Array.isArray(fm.comments)
147
+ ? fm.comments
148
+ .filter((item) => isRecord(item))
149
+ .filter((item) => typeof item.author === "string" && typeof item.body === "string")
150
+ .map((item) => ({ author: item.author, body: item.body }))
151
+ : [];
152
+ const commit = isRecord(fm.commit) &&
153
+ typeof fm.commit.hash === "string" &&
154
+ typeof fm.commit.message === "string"
155
+ ? { hash: fm.commit.hash, message: fm.commit.message }
156
+ : null;
157
+ const baseId = typeof fm.id === "string" ? fm.id : typeof record.id === "string" ? record.id : "";
158
+ const task = {
159
+ id: baseId.trim(),
160
+ title: typeof fm.title === "string" ? fm.title : "",
161
+ description: typeof fm.description === "string" ? fm.description : "",
162
+ status: typeof fm.status === "string" ? fm.status : "TODO",
163
+ priority: typeof fm.priority === "string" || typeof fm.priority === "number" ? fm.priority : "",
164
+ owner: typeof fm.owner === "string" ? fm.owner : "",
165
+ depends_on: toStringArray(fm.depends_on),
166
+ tags: toStringArray(fm.tags),
167
+ verify: toStringArray(fm.verify),
168
+ commit,
169
+ comments,
170
+ doc_version: typeof fm.doc_version === "number" ? fm.doc_version : undefined,
171
+ doc_updated_at: typeof fm.doc_updated_at === "string" ? fm.doc_updated_at : undefined,
172
+ doc_updated_by: typeof fm.doc_updated_by === "string" ? fm.doc_updated_by : undefined,
173
+ dirty: typeof fm.dirty === "boolean" ? fm.dirty : undefined,
174
+ id_source: typeof fm.id_source === "string" ? fm.id_source : undefined,
175
+ };
176
+ const doc = extractTaskDoc(record.body);
177
+ if (doc)
178
+ task.doc = doc;
179
+ return task;
180
+ }
181
+ function taskDataToExport(task) {
182
+ return {
183
+ ...task,
184
+ id: task.id,
185
+ title: task.title ?? "",
186
+ description: task.description ?? "",
187
+ status: task.status ?? "",
188
+ priority: typeof task.priority === "number" ? String(task.priority) : (task.priority ?? ""),
189
+ owner: task.owner ?? "",
190
+ depends_on: toStringArray(task.depends_on),
191
+ tags: toStringArray(task.tags),
192
+ verify: toStringArray(task.verify),
193
+ commit: task.commit ?? null,
194
+ comments: Array.isArray(task.comments)
195
+ ? task.comments.filter((item) => !!item && typeof item.author === "string" && typeof item.body === "string")
196
+ : [],
197
+ doc_version: task.doc_version ?? DOC_VERSION,
198
+ doc_updated_at: task.doc_updated_at ?? "",
199
+ doc_updated_by: task.doc_updated_by ?? DEFAULT_DOC_UPDATED_BY,
200
+ dirty: Boolean(task.dirty),
201
+ id_source: task.id_source ?? "generated",
202
+ };
203
+ }
204
+ export function buildTasksExportSnapshotFromTasks(tasks) {
205
+ const exportTasks = tasks.map((task) => taskDataToExport(task));
206
+ const sorted = exportTasks.toSorted((a, b) => a.id.localeCompare(b.id));
207
+ const canonical = JSON.stringify(canonicalizeJson({ tasks: sorted }));
208
+ const checksum = createHash("sha256").update(canonical, "utf8").digest("hex");
209
+ return {
210
+ tasks: sorted,
211
+ meta: {
212
+ schema_version: 1,
213
+ managed_by: "agentplane",
214
+ checksum_algo: "sha256",
215
+ checksum,
216
+ },
217
+ };
218
+ }
219
+ export async function writeTasksExportFromTasks(opts) {
220
+ const snapshot = buildTasksExportSnapshotFromTasks(opts.tasks);
221
+ await mkdir(path.dirname(opts.outputPath), { recursive: true });
222
+ await writeFile(opts.outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
223
+ }
224
+ export class LocalBackend {
225
+ id = "local";
226
+ root;
227
+ updatedBy;
228
+ constructor(settings) {
229
+ this.root = path.resolve(settings?.dir ?? ".agentplane/tasks");
230
+ this.updatedBy = settings?.updatedBy ?? DEFAULT_DOC_UPDATED_BY;
231
+ }
232
+ async generateTaskId(opts) {
233
+ const length = opts.length;
234
+ if (length < 4)
235
+ throw new Error("length must be >= 4");
236
+ const attempts = Math.max(1, opts.attempts);
237
+ for (let i = 0; i < attempts; i++) {
238
+ const now = new Date();
239
+ const yyyy = String(now.getUTCFullYear()).padStart(4, "0");
240
+ const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
241
+ const dd = String(now.getUTCDate()).padStart(2, "0");
242
+ const hh = String(now.getUTCHours()).padStart(2, "0");
243
+ const min = String(now.getUTCMinutes()).padStart(2, "0");
244
+ let suffix = "";
245
+ for (let j = 0; j < length; j++) {
246
+ suffix += ID_ALPHABET[randomInt(0, ID_ALPHABET.length)];
247
+ }
248
+ const taskId = `${yyyy}${mm}${dd}${hh}${min}-${suffix}`;
249
+ const readmePath = taskReadmePath(this.root, taskId);
250
+ try {
251
+ await readFile(readmePath, "utf8");
252
+ continue;
253
+ }
254
+ catch (err) {
255
+ const code = err?.code;
256
+ if (code === "ENOENT")
257
+ return taskId;
258
+ throw err;
259
+ }
260
+ }
261
+ throw new Error("Failed to generate a unique task id");
262
+ }
263
+ async listTasks() {
264
+ const tasks = [];
265
+ const entries = await readdir(this.root, { withFileTypes: true }).catch(() => []);
266
+ const seen = new Set();
267
+ for (const entry of entries) {
268
+ if (!entry.isDirectory())
269
+ continue;
270
+ const readme = path.join(this.root, entry.name, "README.md");
271
+ let text = "";
272
+ try {
273
+ text = await readFile(readme, "utf8");
274
+ }
275
+ catch {
276
+ continue;
277
+ }
278
+ let parsed;
279
+ try {
280
+ parsed = parseTaskReadme(text);
281
+ }
282
+ catch {
283
+ continue;
284
+ }
285
+ const fm = parsed.frontmatter;
286
+ if (!isRecord(fm) || Object.keys(fm).length === 0)
287
+ continue;
288
+ const taskId = (typeof fm.id === "string" ? fm.id : entry.name).trim();
289
+ if (taskId) {
290
+ validateTaskId(taskId);
291
+ if (seen.has(taskId)) {
292
+ throw new Error(`Duplicate task id in local backend: ${taskId}`);
293
+ }
294
+ seen.add(taskId);
295
+ }
296
+ const task = taskRecordToData({
297
+ id: taskId,
298
+ frontmatter: fm,
299
+ body: parsed.body,
300
+ readmePath: readme,
301
+ });
302
+ tasks.push(task);
303
+ }
304
+ return tasks;
305
+ }
306
+ async getTask(taskId) {
307
+ const readme = taskReadmePath(this.root, taskId);
308
+ let text = "";
309
+ try {
310
+ text = await readFile(readme, "utf8");
311
+ }
312
+ catch (err) {
313
+ const code = err?.code;
314
+ if (code === "ENOENT")
315
+ return null;
316
+ throw err;
317
+ }
318
+ const parsed = parseTaskReadme(text);
319
+ const task = taskRecordToData({
320
+ id: taskId,
321
+ frontmatter: parsed.frontmatter,
322
+ body: parsed.body,
323
+ readmePath: readme,
324
+ });
325
+ return task;
326
+ }
327
+ async getTaskDoc(taskId) {
328
+ const readme = taskReadmePath(this.root, taskId);
329
+ const text = await readFile(readme, "utf8");
330
+ const parsed = parseTaskReadme(text);
331
+ return extractTaskDoc(parsed.body);
332
+ }
333
+ async writeTask(task) {
334
+ const taskId = task.id.trim();
335
+ if (!taskId)
336
+ throw new Error("Task id is required");
337
+ validateTaskId(taskId);
338
+ const readme = taskReadmePath(this.root, taskId);
339
+ let body = "";
340
+ let existingDoc = "";
341
+ let existingFrontmatter = {};
342
+ try {
343
+ const text = await readFile(readme, "utf8");
344
+ const parsed = parseTaskReadme(text);
345
+ body = parsed.body;
346
+ existingDoc = extractTaskDoc(parsed.body);
347
+ existingFrontmatter = parsed.frontmatter;
348
+ }
349
+ catch (err) {
350
+ const code = err?.code;
351
+ if (code !== "ENOENT")
352
+ throw err;
353
+ }
354
+ const payload = { ...task };
355
+ delete payload.doc;
356
+ for (const [key, value] of Object.entries(payload)) {
357
+ if (value === undefined)
358
+ delete payload[key];
359
+ }
360
+ for (const key of ["doc_version", "doc_updated_at", "doc_updated_by"]) {
361
+ if (payload[key] === undefined && existingFrontmatter[key] !== undefined) {
362
+ payload[key] = existingFrontmatter[key];
363
+ }
364
+ }
365
+ if (task.doc !== undefined) {
366
+ const docText = String(task.doc ?? "");
367
+ body = mergeTaskDoc(body, docText);
368
+ if (docChanged(existingDoc, docText)) {
369
+ payload.doc_version = DOC_VERSION;
370
+ payload.doc_updated_at = nowIso();
371
+ payload.doc_updated_by = this.updatedBy;
372
+ }
373
+ }
374
+ if (payload.doc_version !== DOC_VERSION) {
375
+ payload.doc_version = DOC_VERSION;
376
+ }
377
+ if (payload.doc_updated_at === undefined || payload.doc_updated_at === "") {
378
+ payload.doc_updated_at = nowIso();
379
+ }
380
+ if (payload.doc_updated_by === undefined || payload.doc_updated_by === "") {
381
+ payload.doc_updated_by = this.updatedBy;
382
+ }
383
+ await mkdir(path.dirname(readme), { recursive: true });
384
+ const text = renderTaskReadme(payload, body || "");
385
+ await writeFile(readme, text.endsWith("\n") ? text : `${text}\n`, "utf8");
386
+ }
387
+ async setTaskDoc(taskId, doc, updatedBy) {
388
+ const readme = taskReadmePath(this.root, taskId);
389
+ const text = await readFile(readme, "utf8");
390
+ const parsed = parseTaskReadme(text);
391
+ const docText = String(doc ?? "");
392
+ const body = mergeTaskDoc(parsed.body, docText);
393
+ const frontmatter = { ...parsed.frontmatter };
394
+ if (docChanged(extractTaskDoc(parsed.body), docText) || !frontmatter.doc_updated_at) {
395
+ frontmatter.doc_version = DOC_VERSION;
396
+ frontmatter.doc_updated_at = nowIso();
397
+ frontmatter.doc_updated_by = updatedBy ?? this.updatedBy;
398
+ }
399
+ if (frontmatter.doc_version !== DOC_VERSION) {
400
+ frontmatter.doc_version = DOC_VERSION;
401
+ }
402
+ const next = renderTaskReadme(frontmatter, body);
403
+ await writeFile(readme, next.endsWith("\n") ? next : `${next}\n`, "utf8");
404
+ }
405
+ async touchTaskDocMetadata(taskId, updatedBy) {
406
+ const readme = taskReadmePath(this.root, taskId);
407
+ const text = await readFile(readme, "utf8");
408
+ const parsed = parseTaskReadme(text);
409
+ const frontmatter = { ...parsed.frontmatter };
410
+ frontmatter.doc_version = DOC_VERSION;
411
+ frontmatter.doc_updated_at = nowIso();
412
+ frontmatter.doc_updated_by = updatedBy ?? this.updatedBy;
413
+ const next = renderTaskReadme(frontmatter, parsed.body || "");
414
+ await writeFile(readme, next.endsWith("\n") ? next : `${next}\n`, "utf8");
415
+ }
416
+ async writeTasks(tasks) {
417
+ for (const task of tasks) {
418
+ await this.writeTask(task);
419
+ }
420
+ }
421
+ async exportTasksJson(outputPath) {
422
+ const tasks = await this.listTasks();
423
+ await writeTasksExportFromTasks({ outputPath, tasks });
424
+ }
425
+ }
426
+ export class RedmineBackend {
427
+ id = "redmine";
428
+ baseUrl;
429
+ apiKey;
430
+ projectId;
431
+ assigneeId;
432
+ ownerAgent;
433
+ statusMap;
434
+ customFields;
435
+ batchSize;
436
+ batchPause;
437
+ cache;
438
+ issueCache = new Map();
439
+ reverseStatus = new Map();
440
+ constructor(settings, opts) {
441
+ const envUrl = firstNonEmptyString(process.env.CODEXSWARM_REDMINE_URL);
442
+ const envApiKey = firstNonEmptyString(process.env.CODEXSWARM_REDMINE_API_KEY);
443
+ const envProjectId = firstNonEmptyString(process.env.CODEXSWARM_REDMINE_PROJECT_ID);
444
+ const envAssignee = (process.env.CODEXSWARM_REDMINE_ASSIGNEE_ID ?? "").trim();
445
+ const envOwner = firstNonEmptyString(process.env.CODEXSWARM_REDMINE_OWNER, process.env.CODEXSWARM_REDMINE_OWNER_AGENT);
446
+ this.baseUrl = firstNonEmptyString(envUrl, settings.url).replaceAll(/\/+$/gu, "");
447
+ this.apiKey = firstNonEmptyString(envApiKey, settings.api_key);
448
+ this.projectId = firstNonEmptyString(envProjectId, settings.project_id);
449
+ this.assigneeId = envAssignee && /^\d+$/u.test(envAssignee) ? Number(envAssignee) : null;
450
+ this.statusMap = isRecord(settings.status_map) ? settings.status_map : {};
451
+ this.customFields = isRecord(settings.custom_fields) ? settings.custom_fields : {};
452
+ this.batchSize = typeof settings.batch_size === "number" ? settings.batch_size : 20;
453
+ this.batchPause = typeof settings.batch_pause === "number" ? settings.batch_pause : 0.5;
454
+ this.ownerAgent = firstNonEmptyString(envOwner, settings.owner_agent, "REDMINE");
455
+ this.cache = opts.cache ?? null;
456
+ if (!this.baseUrl || !this.apiKey || !this.projectId) {
457
+ throw new BackendError("Redmine backend requires url, api_key, and project_id", "E_BACKEND");
458
+ }
459
+ if (!this.customFields?.task_id) {
460
+ throw new BackendError("Redmine backend requires custom_fields.task_id", "E_BACKEND");
461
+ }
462
+ for (const [key, value] of Object.entries(this.statusMap)) {
463
+ if (typeof value === "number")
464
+ this.reverseStatus.set(value, key);
465
+ }
466
+ }
467
+ async generateTaskId(opts) {
468
+ const length = opts.length;
469
+ const attempts = opts.attempts;
470
+ let existingIds = new Set();
471
+ try {
472
+ const tasks = await this.listTasksRemote();
473
+ existingIds = new Set(tasks.map((task) => toStringSafe(task.id)).filter(Boolean));
474
+ }
475
+ catch (err) {
476
+ if (!(err instanceof RedmineUnavailable))
477
+ throw err;
478
+ if (!this.cache)
479
+ throw err;
480
+ const cached = await this.cache.listTasks();
481
+ existingIds = new Set(cached.map((task) => toStringSafe(task.id)).filter(Boolean));
482
+ }
483
+ return generateTaskId(existingIds, length, attempts);
484
+ }
485
+ async listTasks() {
486
+ try {
487
+ const tasks = await this.listTasksRemote();
488
+ for (const task of tasks) {
489
+ await this.cacheTask(task, false);
490
+ }
491
+ return tasks;
492
+ }
493
+ catch (err) {
494
+ if (err instanceof RedmineUnavailable) {
495
+ if (!this.cache)
496
+ throw err;
497
+ return await this.cache.listTasks();
498
+ }
499
+ throw err;
500
+ }
501
+ }
502
+ async exportTasksJson(outputPath) {
503
+ const tasks = await this.listTasks();
504
+ await writeTasksExportFromTasks({ outputPath, tasks });
505
+ }
506
+ async getTask(taskId) {
507
+ try {
508
+ const issue = await this.findIssueByTaskId(taskId);
509
+ if (!issue)
510
+ return null;
511
+ const task = this.issueToTask(issue, taskId);
512
+ if (task)
513
+ await this.cacheTask(task, false);
514
+ return task;
515
+ }
516
+ catch (err) {
517
+ if (err instanceof RedmineUnavailable) {
518
+ if (!this.cache)
519
+ throw err;
520
+ const cached = await this.cache.getTask(taskId);
521
+ return cached ?? null;
522
+ }
523
+ throw err;
524
+ }
525
+ }
526
+ async getTaskDoc(taskId) {
527
+ const task = await this.getTask(taskId);
528
+ if (!task)
529
+ throw new Error(`Unknown task id: ${taskId}`);
530
+ return toStringSafe(task.doc);
531
+ }
532
+ async setTaskDoc(taskId, doc, updatedBy) {
533
+ if (!this.customFields.doc) {
534
+ throw new BackendError("Redmine backend requires custom_fields.doc to set task docs", "E_BACKEND");
535
+ }
536
+ try {
537
+ const issue = await this.findIssueByTaskId(taskId);
538
+ if (!issue)
539
+ throw new Error(`Unknown task id: ${taskId}`);
540
+ const issueIdText = toStringSafe(issue.id);
541
+ if (!issueIdText)
542
+ throw new Error("Missing Redmine issue id for task");
543
+ const taskDoc = { doc: String(doc ?? "") };
544
+ ensureDocMetadata(taskDoc, updatedBy);
545
+ const customFields = [];
546
+ this.appendCustomField(customFields, "doc", taskDoc.doc);
547
+ this.appendCustomField(customFields, "doc_version", taskDoc.doc_version);
548
+ this.appendCustomField(customFields, "doc_updated_at", taskDoc.doc_updated_at);
549
+ this.appendCustomField(customFields, "doc_updated_by", taskDoc.doc_updated_by);
550
+ await this.requestJson("PUT", `issues/${issueIdText}.json`, {
551
+ issue: { custom_fields: customFields },
552
+ });
553
+ const task = this.issueToTask(issue, taskId);
554
+ if (task) {
555
+ task.doc = taskDoc.doc;
556
+ task.doc_version = taskDoc.doc_version;
557
+ task.doc_updated_at = taskDoc.doc_updated_at;
558
+ task.doc_updated_by = taskDoc.doc_updated_by;
559
+ await this.cacheTask(task, false);
560
+ }
561
+ }
562
+ catch (err) {
563
+ if (err instanceof RedmineUnavailable) {
564
+ if (!this.cache)
565
+ throw err;
566
+ const cached = await this.cache.getTask(taskId);
567
+ if (!cached)
568
+ throw new Error(`Unknown task id: ${taskId}`);
569
+ cached.doc = String(doc ?? "");
570
+ ensureDocMetadata(cached, updatedBy);
571
+ cached.dirty = true;
572
+ await this.cache.writeTask(cached);
573
+ return;
574
+ }
575
+ throw err;
576
+ }
577
+ }
578
+ async touchTaskDocMetadata(taskId, updatedBy) {
579
+ try {
580
+ const issue = await this.findIssueByTaskId(taskId);
581
+ if (!issue)
582
+ throw new Error(`Unknown task id: ${taskId}`);
583
+ const issueIdText = toStringSafe(issue.id);
584
+ if (!issueIdText)
585
+ throw new Error("Missing Redmine issue id for task");
586
+ const docValue = this.customFieldValue(issue, this.customFields.doc);
587
+ const taskDoc = { doc: docValue ?? "" };
588
+ ensureDocMetadata(taskDoc, updatedBy);
589
+ const customFields = [];
590
+ this.appendCustomField(customFields, "doc_version", taskDoc.doc_version);
591
+ this.appendCustomField(customFields, "doc_updated_at", taskDoc.doc_updated_at);
592
+ this.appendCustomField(customFields, "doc_updated_by", taskDoc.doc_updated_by);
593
+ if (customFields.length > 0) {
594
+ await this.requestJson("PUT", `issues/${issueIdText}.json`, {
595
+ issue: { custom_fields: customFields },
596
+ });
597
+ const task = this.issueToTask(issue, taskId);
598
+ if (task) {
599
+ task.doc_version = taskDoc.doc_version;
600
+ task.doc_updated_at = taskDoc.doc_updated_at;
601
+ task.doc_updated_by = taskDoc.doc_updated_by;
602
+ await this.cacheTask(task, false);
603
+ }
604
+ }
605
+ }
606
+ catch (err) {
607
+ if (err instanceof RedmineUnavailable) {
608
+ if (!this.cache)
609
+ throw err;
610
+ const cached = await this.cache.getTask(taskId);
611
+ if (!cached)
612
+ throw new Error(`Unknown task id: ${taskId}`);
613
+ ensureDocMetadata(cached, updatedBy);
614
+ cached.dirty = true;
615
+ await this.cache.writeTask(cached);
616
+ return;
617
+ }
618
+ throw err;
619
+ }
620
+ }
621
+ async writeTask(task) {
622
+ const taskId = toStringSafe(task.id).trim();
623
+ if (!taskId)
624
+ throw new Error("task.id is required");
625
+ validateTaskId(taskId);
626
+ try {
627
+ this.ensureDocMetadata(task);
628
+ let issue = await this.findIssueByTaskId(taskId);
629
+ let issueId = issue?.id;
630
+ let issueIdText = issueId ? toStringSafe(issueId) : "";
631
+ let existingIssue = issue ?? null;
632
+ if (issueIdText && !existingIssue) {
633
+ const payload = await this.requestJson("GET", `issues/${issueIdText}.json`);
634
+ existingIssue = this.issueFromPayload(payload);
635
+ }
636
+ const payload = this.taskToIssuePayload(task, existingIssue ?? undefined);
637
+ if (issueIdText) {
638
+ await this.requestJson("PUT", `issues/${issueIdText}.json`, { issue: payload });
639
+ }
640
+ else {
641
+ const createPayload = { ...payload, project_id: this.projectId };
642
+ const created = await this.requestJson("POST", "issues.json", { issue: createPayload });
643
+ const createdIssue = this.issueFromPayload(created);
644
+ issueId = createdIssue?.id;
645
+ issueIdText = issueId ? toStringSafe(issueId) : "";
646
+ if (issueIdText) {
647
+ const updatePayload = { ...payload };
648
+ delete updatePayload.project_id;
649
+ await this.requestJson("PUT", `issues/${issueIdText}.json`, { issue: updatePayload });
650
+ const refreshed = await this.requestJson("GET", `issues/${issueIdText}.json`);
651
+ existingIssue = this.issueFromPayload(refreshed);
652
+ }
653
+ }
654
+ if (issueIdText) {
655
+ const existingComments = existingIssue && this.customFields.comments
656
+ ? this.normalizeComments(this.maybeParseJson(this.customFieldValue(existingIssue, this.customFields.comments)))
657
+ : [];
658
+ const desiredComments = this.normalizeComments(task.comments);
659
+ await this.appendCommentNotes(issueIdText, existingComments, desiredComments);
660
+ }
661
+ task.dirty = false;
662
+ await this.cacheTask(task, false);
663
+ this.issueCache.clear();
664
+ }
665
+ catch (err) {
666
+ if (err instanceof RedmineUnavailable) {
667
+ if (!this.cache)
668
+ throw err;
669
+ task.dirty = true;
670
+ await this.cacheTask(task, true);
671
+ return;
672
+ }
673
+ throw err;
674
+ }
675
+ }
676
+ async writeTasks(tasks) {
677
+ for (const [index, task] of tasks.entries()) {
678
+ await this.writeTask(task);
679
+ if (this.batchPause && this.batchSize > 0 && (index + 1) % this.batchSize === 0) {
680
+ await sleep(this.batchPause * 1000);
681
+ }
682
+ }
683
+ }
684
+ async sync(opts) {
685
+ if (opts.direction === "push") {
686
+ await this.syncPush(opts.quiet, opts.confirm);
687
+ return;
688
+ }
689
+ if (opts.direction === "pull") {
690
+ await this.syncPull(opts.conflict, opts.quiet);
691
+ return;
692
+ }
693
+ throw new BackendError("Unsupported direction", "E_BACKEND");
694
+ }
695
+ ensureDocMetadata(task) {
696
+ if (task.doc === undefined)
697
+ return;
698
+ if (task.doc_version !== DOC_VERSION)
699
+ task.doc_version = DOC_VERSION;
700
+ task.doc_updated_at ??= nowIso();
701
+ task.doc_updated_by ??= DEFAULT_DOC_UPDATED_BY;
702
+ }
703
+ async syncPush(quiet, confirm) {
704
+ if (!this.cache) {
705
+ throw new BackendError("Redmine cache is disabled; sync push is unavailable", "E_BACKEND");
706
+ }
707
+ const tasks = await this.cache.listTasks();
708
+ const dirty = tasks.filter((task) => task.dirty);
709
+ if (dirty.length === 0) {
710
+ if (!quiet)
711
+ process.stdout.write("ℹ️ no dirty tasks to push\n");
712
+ return;
713
+ }
714
+ if (!confirm) {
715
+ for (const task of dirty) {
716
+ process.stdout.write(`- pending push: ${task.id}\n`);
717
+ }
718
+ throw new BackendError("Refusing to push without --yes (preview above)", "E_BACKEND");
719
+ }
720
+ await this.writeTasks(dirty);
721
+ if (!quiet)
722
+ process.stdout.write(`✅ pushed ${dirty.length} dirty task(s)\n`);
723
+ }
724
+ async syncPull(conflict, quiet) {
725
+ if (!this.cache) {
726
+ throw new BackendError("Redmine cache is disabled; sync pull is unavailable", "E_BACKEND");
727
+ }
728
+ const remoteTasks = await this.listTasksRemote();
729
+ const remoteById = new Map();
730
+ for (const task of remoteTasks) {
731
+ const taskId = toStringSafe(task.id);
732
+ if (taskId)
733
+ remoteById.set(taskId, task);
734
+ }
735
+ const localTasks = await this.cache.listTasks();
736
+ const localById = new Map();
737
+ for (const task of localTasks) {
738
+ const taskId = toStringSafe(task.id);
739
+ if (taskId)
740
+ localById.set(taskId, task);
741
+ }
742
+ for (const [taskId, remoteTask] of remoteById.entries()) {
743
+ const localTask = localById.get(taskId);
744
+ if (localTask?.dirty) {
745
+ if (this.tasksDiffer(localTask, remoteTask)) {
746
+ await this.handleConflict(taskId, localTask, remoteTask, conflict);
747
+ continue;
748
+ }
749
+ localTask.dirty = false;
750
+ await this.cacheTask(localTask, false);
751
+ continue;
752
+ }
753
+ await this.cacheTask(remoteTask, false);
754
+ }
755
+ if (!quiet)
756
+ process.stdout.write(`✅ pulled ${remoteById.size} task(s)\n`);
757
+ }
758
+ async handleConflict(taskId, localTask, remoteTask, conflict) {
759
+ if (conflict === "prefer-local") {
760
+ await this.writeTask(localTask);
761
+ return;
762
+ }
763
+ if (conflict === "prefer-remote") {
764
+ await this.cacheTask(remoteTask, false);
765
+ return;
766
+ }
767
+ if (conflict === "diff") {
768
+ const diff = this.diffTasks(localTask, remoteTask);
769
+ process.stdout.write(`${diff}\n`);
770
+ throw new BackendError(`Conflict detected for ${taskId}`, "E_BACKEND");
771
+ }
772
+ throw new BackendError(`Conflict detected for ${taskId}`, "E_BACKEND");
773
+ }
774
+ diffTasks(localTask, remoteTask) {
775
+ const localText = JSON.stringify(canonicalizeJson(localTask), null, 2).split("\n");
776
+ const remoteText = JSON.stringify(canonicalizeJson(remoteTask), null, 2).split("\n");
777
+ const diff = ["--- remote", "+++ local"];
778
+ const max = Math.max(localText.length, remoteText.length);
779
+ for (let i = 0; i < max; i++) {
780
+ const l = localText[i];
781
+ const r = remoteText[i];
782
+ if (l === r)
783
+ continue;
784
+ if (r !== undefined)
785
+ diff.push(`- ${r}`);
786
+ if (l !== undefined)
787
+ diff.push(`+ ${l}`);
788
+ }
789
+ return diff.join("\n");
790
+ }
791
+ tasksDiffer(localTask, remoteTask) {
792
+ const localText = JSON.stringify(canonicalizeJson(localTask));
793
+ const remoteText = JSON.stringify(canonicalizeJson(remoteTask));
794
+ return localText !== remoteText;
795
+ }
796
+ async cacheTask(task, dirty) {
797
+ if (!this.cache)
798
+ return;
799
+ const next = { ...task, dirty };
800
+ await this.cache.writeTask(next);
801
+ }
802
+ taskIdFieldId() {
803
+ const fieldId = this.customFields?.task_id;
804
+ if (fieldId)
805
+ return fieldId;
806
+ throw new BackendError("Redmine backend requires custom_fields.task_id", "E_BACKEND");
807
+ }
808
+ setIssueCustomFieldValue(issue, fieldId, value) {
809
+ const fields = Array.isArray(issue.custom_fields) ? issue.custom_fields : [];
810
+ let found = false;
811
+ const updated = fields.map((field) => {
812
+ if (isRecord(field) && field.id === fieldId) {
813
+ found = true;
814
+ return { ...field, value };
815
+ }
816
+ return field;
817
+ });
818
+ if (!found)
819
+ updated.push({ id: fieldId, value });
820
+ issue.custom_fields = updated;
821
+ }
822
+ async listTasksRemote() {
823
+ const tasks = [];
824
+ const allIssues = [];
825
+ let offset = 0;
826
+ const limit = 100;
827
+ const taskField = this.taskIdFieldId();
828
+ this.issueCache.clear();
829
+ while (true) {
830
+ const payload = await this.requestJson("GET", "issues.json", undefined, {
831
+ project_id: this.projectId,
832
+ limit,
833
+ offset,
834
+ status_id: "*",
835
+ });
836
+ const issues = Array.isArray(payload.issues) ? payload.issues : [];
837
+ const pageIssues = issues.filter((issue) => isRecord(issue));
838
+ allIssues.push(...pageIssues);
839
+ const total = Number(payload.total_count ?? 0);
840
+ if (total === 0 || offset + limit >= total)
841
+ break;
842
+ offset += limit;
843
+ }
844
+ const existingIds = new Set();
845
+ const duplicates = new Set();
846
+ for (const issue of allIssues) {
847
+ const taskId = this.customFieldValue(issue, taskField);
848
+ if (!taskId)
849
+ continue;
850
+ const taskIdStr = toStringSafe(taskId);
851
+ if (!TASK_ID_RE.test(taskIdStr))
852
+ continue;
853
+ if (existingIds.has(taskIdStr))
854
+ duplicates.add(taskIdStr);
855
+ existingIds.add(taskIdStr);
856
+ }
857
+ if (duplicates.size > 0) {
858
+ const sample = [...duplicates].toSorted().slice(0, 5).join(", ");
859
+ throw new BackendError(`Duplicate task_id values found in Redmine: ${sample}`, "E_BACKEND");
860
+ }
861
+ for (const issue of allIssues) {
862
+ const taskId = this.customFieldValue(issue, taskField);
863
+ const taskIdText = toStringSafe(taskId);
864
+ if (!taskIdText || !TASK_ID_RE.test(taskIdText))
865
+ continue;
866
+ const task = this.issueToTask(issue, taskIdText);
867
+ if (task) {
868
+ const idText = toStringSafe(task.id);
869
+ if (idText)
870
+ this.issueCache.set(idText, issue);
871
+ tasks.push(task);
872
+ }
873
+ }
874
+ return tasks;
875
+ }
876
+ issueFromPayload(payload) {
877
+ return isRecord(payload.issue) ? payload.issue : null;
878
+ }
879
+ async findIssueByTaskId(taskId) {
880
+ const id = toStringSafe(taskId).trim();
881
+ if (!id)
882
+ return null;
883
+ const cached = this.issueCache.get(id);
884
+ if (cached)
885
+ return cached;
886
+ const taskField = this.taskIdFieldId();
887
+ const payload = await this.requestJson("GET", "issues.json", undefined, {
888
+ project_id: this.projectId,
889
+ status_id: "*",
890
+ [`cf_${String(taskField)}`]: id,
891
+ limit: 100,
892
+ });
893
+ const candidates = Array.isArray(payload.issues) ? payload.issues : [];
894
+ for (const candidate of candidates) {
895
+ if (!isRecord(candidate))
896
+ continue;
897
+ const val = this.customFieldValue(candidate, taskField);
898
+ if (val && String(val) === id) {
899
+ this.issueCache.set(id, candidate);
900
+ return candidate;
901
+ }
902
+ }
903
+ await this.listTasksRemote();
904
+ const refreshed = this.issueCache.get(id);
905
+ return refreshed ?? null;
906
+ }
907
+ issueToTask(issue, taskIdOverride) {
908
+ const taskId = taskIdOverride ?? this.customFieldValue(issue, this.customFields.task_id);
909
+ if (!taskId)
910
+ return null;
911
+ const statusVal = isRecord(issue.status) ? issue.status : null;
912
+ const statusId = statusVal && typeof statusVal.id === "number" ? statusVal.id : null;
913
+ const status = statusId !== null && this.reverseStatus.has(statusId)
914
+ ? this.reverseStatus.get(statusId)
915
+ : "TODO";
916
+ const verifyVal = this.customFieldValue(issue, this.customFields.verify);
917
+ const commitVal = this.customFieldValue(issue, this.customFields.commit);
918
+ const docVal = this.customFieldValue(issue, this.customFields.doc);
919
+ const commentsVal = this.customFieldValue(issue, this.customFields.comments);
920
+ const docVersionVal = this.customFieldValue(issue, this.customFields.doc_version);
921
+ const docUpdatedAtVal = this.customFieldValue(issue, this.customFields.doc_updated_at);
922
+ const docUpdatedByVal = this.customFieldValue(issue, this.customFields.doc_updated_by);
923
+ const updatedOn = typeof issue.updated_on === "string"
924
+ ? issue.updated_on
925
+ : typeof issue.created_on === "string"
926
+ ? issue.created_on
927
+ : null;
928
+ const priorityVal = isRecord(issue.priority) ? issue.priority : null;
929
+ const priorityName = normalizePriority(priorityVal?.name);
930
+ const tags = [];
931
+ if (Array.isArray(issue.tags)) {
932
+ for (const tag of issue.tags) {
933
+ if (isRecord(tag) && tag.name)
934
+ tags.push(toStringSafe(tag.name));
935
+ }
936
+ }
937
+ const task = {
938
+ id: toStringSafe(taskId),
939
+ title: toStringSafe(issue.subject),
940
+ description: toStringSafe(issue.description),
941
+ status: status ?? "TODO",
942
+ priority: priorityName,
943
+ owner: this.ownerAgent,
944
+ tags,
945
+ depends_on: [],
946
+ verify: this.maybeParseJson(verifyVal),
947
+ commit: this.maybeParseJson(commitVal),
948
+ comments: this.normalizeComments(this.maybeParseJson(commentsVal)),
949
+ id_source: "custom",
950
+ };
951
+ if (docVal)
952
+ task.doc = toStringSafe(docVal);
953
+ const docVersion = this.coerceDocVersion(docVersionVal);
954
+ task.doc_version = docVersion ?? DOC_VERSION;
955
+ task.doc_updated_at = docUpdatedAtVal ? toStringSafe(docUpdatedAtVal) : (updatedOn ?? nowIso());
956
+ task.doc_updated_by = docUpdatedByVal ? toStringSafe(docUpdatedByVal) : this.ownerAgent;
957
+ return task;
958
+ }
959
+ taskToIssuePayload(task, existingIssue) {
960
+ const status = toStringSafe(task.status).trim().toUpperCase();
961
+ const payload = {
962
+ subject: toStringSafe(task.title),
963
+ description: toStringSafe(task.description),
964
+ };
965
+ if (status && this.statusMap && status in this.statusMap) {
966
+ payload.status_id = this.statusMap[status];
967
+ }
968
+ if (typeof task.priority === "number")
969
+ payload.priority_id = task.priority;
970
+ let existingAssignee = null;
971
+ if (existingIssue && isRecord(existingIssue.assigned_to)) {
972
+ existingAssignee = existingIssue.assigned_to.id;
973
+ }
974
+ if (this.assigneeId && !existingAssignee)
975
+ payload.assigned_to_id = this.assigneeId;
976
+ const startDate = this.startDateFromTaskId(toStringSafe(task.id));
977
+ if (startDate)
978
+ payload.start_date = startDate;
979
+ const doneRatio = this.doneRatioForStatus(status);
980
+ if (doneRatio !== null)
981
+ payload.done_ratio = doneRatio;
982
+ const customFields = [];
983
+ this.ensureDocMetadata(task);
984
+ this.appendCustomField(customFields, "task_id", task.id);
985
+ this.appendCustomField(customFields, "verify", task.verify);
986
+ this.appendCustomField(customFields, "commit", task.commit);
987
+ this.appendCustomField(customFields, "comments", task.comments);
988
+ this.appendCustomField(customFields, "doc", task.doc);
989
+ this.appendCustomField(customFields, "doc_version", task.doc_version);
990
+ this.appendCustomField(customFields, "doc_updated_at", task.doc_updated_at);
991
+ this.appendCustomField(customFields, "doc_updated_by", task.doc_updated_by);
992
+ if (customFields.length > 0)
993
+ payload.custom_fields = customFields;
994
+ return payload;
995
+ }
996
+ appendCustomField(fields, key, value) {
997
+ const fieldId = this.customFields?.[key];
998
+ if (!fieldId)
999
+ return;
1000
+ let payloadValue = value;
1001
+ if (Array.isArray(value) || isRecord(value)) {
1002
+ payloadValue = JSON.stringify(value);
1003
+ }
1004
+ fields.push({ id: fieldId, value: payloadValue });
1005
+ }
1006
+ normalizeComments(value) {
1007
+ if (Array.isArray(value)) {
1008
+ return value.filter((item) => isRecord(item) ? typeof item.author === "string" && typeof item.body === "string" : false);
1009
+ }
1010
+ if (isRecord(value))
1011
+ return [value];
1012
+ if (typeof value === "string" && value.trim()) {
1013
+ return [{ author: "redmine", body: value.trim() }];
1014
+ }
1015
+ return [];
1016
+ }
1017
+ commentsToPairs(comments) {
1018
+ const pairs = [];
1019
+ for (const comment of comments) {
1020
+ const author = toStringSafe(comment.author).trim();
1021
+ const body = toStringSafe(comment.body).trim();
1022
+ if (!author && !body)
1023
+ continue;
1024
+ pairs.push([author, body]);
1025
+ }
1026
+ return pairs;
1027
+ }
1028
+ formatCommentNote(author = "unknown", body = "") {
1029
+ const authorText = author;
1030
+ const bodyText = body;
1031
+ return `[comment] ${authorText}: ${bodyText}`.trim();
1032
+ }
1033
+ async appendCommentNotes(issueId, existingComments, desiredComments) {
1034
+ const issueIdText = toStringSafe(issueId);
1035
+ if (!issueIdText)
1036
+ return;
1037
+ const existingPairs = this.commentsToPairs(existingComments);
1038
+ const desiredPairs = this.commentsToPairs(desiredComments);
1039
+ if (desiredPairs.length === 0)
1040
+ return;
1041
+ if (desiredPairs.length < existingPairs.length)
1042
+ return;
1043
+ if (existingPairs.length > 0) {
1044
+ const prefix = desiredPairs.slice(0, existingPairs.length);
1045
+ const matches = prefix.length === existingPairs.length &&
1046
+ prefix.every((pair, idx) => pair[0] === existingPairs[idx]?.[0] && pair[1] === existingPairs[idx]?.[1]);
1047
+ if (!matches)
1048
+ return;
1049
+ }
1050
+ const newPairs = desiredPairs.slice(existingPairs.length);
1051
+ for (const [author, body] of newPairs) {
1052
+ const note = this.formatCommentNote(author, body);
1053
+ if (note) {
1054
+ await this.requestJson("PUT", `issues/${issueIdText}.json`, { issue: { notes: note } });
1055
+ }
1056
+ }
1057
+ }
1058
+ startDateFromTaskId(taskId) {
1059
+ if (!taskId.includes("-"))
1060
+ return null;
1061
+ const prefix = taskId.split("-", 1)[0] ?? "";
1062
+ if (prefix.length < 8)
1063
+ return null;
1064
+ const year = prefix.slice(0, 4);
1065
+ const month = prefix.slice(4, 6);
1066
+ const day = prefix.slice(6, 8);
1067
+ if (!/^\d{8}$/u.test(`${year}${month}${day}`))
1068
+ return null;
1069
+ return `${year}-${month}-${day}`;
1070
+ }
1071
+ doneRatioForStatus(status) {
1072
+ if (!status)
1073
+ return null;
1074
+ if (status === "DONE")
1075
+ return 100;
1076
+ return 0;
1077
+ }
1078
+ customFieldValue(issue, fieldId) {
1079
+ if (!fieldId)
1080
+ return null;
1081
+ const fields = Array.isArray(issue.custom_fields) ? issue.custom_fields : [];
1082
+ for (const field of fields) {
1083
+ if (isRecord(field) && field.id === fieldId) {
1084
+ const value = field.value;
1085
+ return value !== undefined && value !== null ? toStringSafe(value) : "";
1086
+ }
1087
+ }
1088
+ return null;
1089
+ }
1090
+ maybeParseJson(value) {
1091
+ if (value === null || value === undefined)
1092
+ return null;
1093
+ const raw = toStringSafe(value).trim();
1094
+ if (!raw)
1095
+ return null;
1096
+ if (raw.startsWith("{") || raw.startsWith("[")) {
1097
+ try {
1098
+ return JSON.parse(raw);
1099
+ }
1100
+ catch {
1101
+ return raw;
1102
+ }
1103
+ }
1104
+ return raw;
1105
+ }
1106
+ coerceDocVersion(value) {
1107
+ if (value === null || value === undefined)
1108
+ return null;
1109
+ if (typeof value === "number")
1110
+ return value;
1111
+ const raw = toStringSafe(value).trim();
1112
+ if (/^\d+$/u.test(raw))
1113
+ return Number(raw);
1114
+ return null;
1115
+ }
1116
+ async requestJson(method, reqPath, payload, params, opts) {
1117
+ let url = `${this.baseUrl}/${reqPath.replace(/^\//u, "")}`;
1118
+ if (params) {
1119
+ const search = new URLSearchParams();
1120
+ for (const [key, value] of Object.entries(params)) {
1121
+ if (value === undefined || value === null)
1122
+ continue;
1123
+ search.append(key, toStringSafe(value));
1124
+ }
1125
+ const qs = search.toString();
1126
+ if (qs)
1127
+ url += `?${qs}`;
1128
+ }
1129
+ const attempts = Math.max(1, opts?.attempts ?? 3);
1130
+ const backoff = opts?.backoff ?? 0.5;
1131
+ let lastError = null;
1132
+ for (let attempt = 1; attempt <= attempts; attempt++) {
1133
+ try {
1134
+ const resp = await fetch(url, {
1135
+ method,
1136
+ headers: {
1137
+ "Content-Type": "application/json",
1138
+ "X-Redmine-API-Key": this.apiKey,
1139
+ },
1140
+ body: payload ? JSON.stringify(payload) : undefined,
1141
+ });
1142
+ const text = await resp.text();
1143
+ if (!resp.ok) {
1144
+ if ((resp.status === 429 || resp.status >= 500) && attempt < attempts) {
1145
+ await sleep(backoff * attempt * 1000);
1146
+ continue;
1147
+ }
1148
+ throw new BackendError(`Redmine API error: ${resp.status} ${text}`, "E_BACKEND");
1149
+ }
1150
+ if (!text)
1151
+ return {};
1152
+ try {
1153
+ const parsed = JSON.parse(text);
1154
+ if (isRecord(parsed))
1155
+ return parsed;
1156
+ return {};
1157
+ }
1158
+ catch {
1159
+ return {};
1160
+ }
1161
+ }
1162
+ catch (err) {
1163
+ lastError = err;
1164
+ if (err instanceof BackendError)
1165
+ throw err;
1166
+ if (attempt >= attempts) {
1167
+ throw new RedmineUnavailable("Redmine unavailable");
1168
+ }
1169
+ await sleep(backoff * attempt * 1000);
1170
+ }
1171
+ }
1172
+ throw lastError instanceof Error ? lastError : new RedmineUnavailable("Redmine unavailable");
1173
+ }
1174
+ }
1175
+ function generateTaskId(existingIds, length, attempts) {
1176
+ if (length < 4)
1177
+ throw new Error("length must be >= 4");
1178
+ for (let i = 0; i < attempts; i++) {
1179
+ const now = new Date();
1180
+ const yyyy = String(now.getUTCFullYear()).padStart(4, "0");
1181
+ const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
1182
+ const dd = String(now.getUTCDate()).padStart(2, "0");
1183
+ const hh = String(now.getUTCHours()).padStart(2, "0");
1184
+ const min = String(now.getUTCMinutes()).padStart(2, "0");
1185
+ let suffix = "";
1186
+ for (let j = 0; j < length; j++) {
1187
+ suffix += ID_ALPHABET[randomInt(0, ID_ALPHABET.length)];
1188
+ }
1189
+ const candidate = `${yyyy}${mm}${dd}${hh}${min}-${suffix}`;
1190
+ if (!existingIds.has(candidate))
1191
+ return candidate;
1192
+ }
1193
+ throw new Error("Failed to generate a unique task id");
1194
+ }
1195
+ async function loadBackendConfig(configPath) {
1196
+ try {
1197
+ const raw = JSON.parse(await readFile(configPath, "utf8"));
1198
+ return isRecord(raw) ? raw : null;
1199
+ }
1200
+ catch (err) {
1201
+ const code = err?.code;
1202
+ if (code === "ENOENT")
1203
+ return null;
1204
+ throw err;
1205
+ }
1206
+ }
1207
+ function resolveMaybeRelative(root, input) {
1208
+ if (!input)
1209
+ return null;
1210
+ const raw = toStringSafe(input).trim();
1211
+ if (!raw)
1212
+ return null;
1213
+ return path.isAbsolute(raw) ? raw : path.join(root, raw);
1214
+ }
1215
+ export async function loadTaskBackend(opts) {
1216
+ const resolved = await resolveProject({ cwd: opts.cwd, rootOverride: opts.rootOverride ?? null });
1217
+ const loaded = await loadConfig(resolved.agentplaneDir);
1218
+ const backendConfigPath = path.join(resolved.gitRoot, loaded.config.tasks_backend.config_path);
1219
+ const backendConfig = await loadBackendConfig(backendConfigPath);
1220
+ const backendIdRaw = toStringSafe(backendConfig?.id).trim();
1221
+ const backendId = backendIdRaw.length > 0 ? backendIdRaw : "local";
1222
+ const settings = isRecord(backendConfig?.settings) ? backendConfig?.settings : {};
1223
+ if (backendId === "redmine") {
1224
+ await loadDotEnv(resolved.gitRoot);
1225
+ const cacheDirRaw = resolveMaybeRelative(resolved.gitRoot, settings.cache_dir);
1226
+ const cacheDir = cacheDirRaw ?? path.join(resolved.gitRoot, loaded.config.paths.workflow_dir);
1227
+ const cache = cacheDir ? new LocalBackend({ dir: cacheDir }) : null;
1228
+ const redmine = new RedmineBackend(settings, { cache });
1229
+ return { backend: redmine, backendId, resolved, config: loaded.config, backendConfigPath };
1230
+ }
1231
+ const localDir = resolveMaybeRelative(resolved.gitRoot, settings.dir) ??
1232
+ path.join(resolved.gitRoot, loaded.config.paths.workflow_dir);
1233
+ const local = new LocalBackend({ dir: localDir });
1234
+ return { backend: local, backendId: "local", resolved, config: loaded.config, backendConfigPath };
1235
+ }