vibeops-tracker 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.
package/lib/store.mjs ADDED
@@ -0,0 +1,569 @@
1
+ // Markdown-file issue store. One file per issue under <dataDir>/<project>/issues/.
2
+ // All functions take dataDir explicitly so tests can use temp dirs.
3
+ //
4
+ // Body sections are delimited by HTML-comment sentinels (Backlog.md-style) so user
5
+ // content containing markdown headings round-trips intact. Files without sentinels
6
+ // (hand-written) fall back to heading-based parsing.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import matter from 'gray-matter';
10
+
11
+ export const STATUSES = ['backlog', 'in-progress', 'in-review', 'done'];
12
+ export const TYPES = ['bug', 'improvement', 'feature', 'other'];
13
+
14
+ const SECTIONS = [
15
+ { name: 'SEEING', heading: 'Seeing' },
16
+ { name: 'EXPECTING', heading: 'Expecting' },
17
+ { name: 'CONTEXT', heading: 'Context' },
18
+ { name: 'RESOLUTION', heading: 'Resolution' },
19
+ { name: 'COMMENTS', heading: 'Comments' },
20
+ ];
21
+ const PATCHABLE = ['status', 'ordinal', 'title', 'tags', 'severity', 'type', 'relatedTo'];
22
+ const MIN_ORDINAL_GAP = 1e-6;
23
+ const ORDINAL_STEP = 1000;
24
+ // Comment headers must carry an ISO timestamp, so heading-like lines inside
25
+ // comment text don't get mistaken for comment boundaries.
26
+ const COMMENT_HEADER_RE = /^### (.+) — (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)[ \t]*$/;
27
+
28
+ // ---- locking ---------------------------------------------------------------
29
+ // mkdir is atomic across processes; the web server and the MCP server may both
30
+ // mutate the store. Sync spin keeps the store API synchronous.
31
+
32
+ function sleepSync(ms) {
33
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
34
+ }
35
+
36
+ function withLock(lockBase, fn) {
37
+ const dir = `${lockBase}.lock`;
38
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
39
+ const deadline = Date.now() + 2000;
40
+ for (;;) {
41
+ try {
42
+ fs.mkdirSync(dir);
43
+ break;
44
+ } catch (err) {
45
+ if (err.code !== 'EEXIST') throw err;
46
+ try {
47
+ if (Date.now() - fs.statSync(dir).mtimeMs > 5000) {
48
+ fs.rmdirSync(dir); // stale lock from a crashed process
49
+ continue;
50
+ }
51
+ } catch {}
52
+ if (Date.now() > deadline) {
53
+ const e = new Error(`store lock timeout: ${dir}`);
54
+ e.statusCode = 503; // contention, not a client error
55
+ throw e;
56
+ }
57
+ sleepSync(5);
58
+ }
59
+ }
60
+ try {
61
+ return fn();
62
+ } finally {
63
+ try {
64
+ fs.rmdirSync(dir);
65
+ } catch {}
66
+ }
67
+ }
68
+
69
+ function writeFileAtomic(file, content) {
70
+ fs.mkdirSync(path.dirname(file), { recursive: true });
71
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
72
+ fs.writeFileSync(tmp, content);
73
+ fs.renameSync(tmp, file);
74
+ }
75
+
76
+ function notFound(id) {
77
+ const err = new Error(`Issue not found: ${id}`);
78
+ err.code = 'NOT_FOUND';
79
+ return err;
80
+ }
81
+
82
+ function sanitizeProjectKey(key) {
83
+ if (typeof key !== 'string' || !key.trim()) throw new Error('project key is required');
84
+ const clean = key.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-');
85
+ if (!clean) throw new Error(`invalid project key: ${key}`);
86
+ return clean;
87
+ }
88
+
89
+ function projectsFile(dataDir) {
90
+ return path.join(dataDir, 'projects.json');
91
+ }
92
+
93
+ export function listProjects(dataDir) {
94
+ const file = projectsFile(dataDir);
95
+ if (!fs.existsSync(file)) return [];
96
+ try {
97
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
98
+ return Array.isArray(parsed) ? parsed : [];
99
+ } catch {
100
+ return [];
101
+ }
102
+ }
103
+
104
+ export function ensureProject(dataDir, key, name, repoPath) {
105
+ const clean = sanitizeProjectKey(key);
106
+ return withLock(path.join(dataDir, '.registry'), () => {
107
+ const projects = listProjects(dataDir);
108
+ let project = projects.find((p) => p.key === clean);
109
+ let changed = false;
110
+ if (!project) {
111
+ project = { key: clean, name: name || clean };
112
+ projects.push(project);
113
+ changed = true;
114
+ }
115
+ if (name && project.name !== name) {
116
+ project.name = name;
117
+ changed = true;
118
+ }
119
+ if (repoPath && project.repo_path !== repoPath) {
120
+ project.repo_path = repoPath;
121
+ changed = true;
122
+ }
123
+ if (changed) writeFileAtomic(projectsFile(dataDir), JSON.stringify(projects, null, 2) + '\n');
124
+ return project;
125
+ });
126
+ }
127
+
128
+ export function getProject(dataDir, key) {
129
+ return listProjects(dataDir).find((p) => p.key === key) || null;
130
+ }
131
+
132
+ function issuesDir(dataDir, projectKey) {
133
+ return path.join(dataDir, projectKey, 'issues');
134
+ }
135
+
136
+ function closedDir(dataDir, projectKey) {
137
+ return path.join(dataDir, projectKey, 'closed');
138
+ }
139
+
140
+ function projectLock(dataDir, projectKey) {
141
+ return path.join(dataDir, projectKey, '.project');
142
+ }
143
+
144
+ function slugify(text) {
145
+ return (
146
+ String(text || '')
147
+ .toLowerCase()
148
+ .replace(/[^a-z0-9]+/g, '-')
149
+ .replace(/^-+|-+$/g, '')
150
+ .slice(0, 40)
151
+ .replace(/-+$/g, '') || 'untitled'
152
+ );
153
+ }
154
+
155
+ function toIso(value) {
156
+ if (value instanceof Date) return value.toISOString();
157
+ return value;
158
+ }
159
+
160
+ // ---- serialization ----------------------------------------------------------
161
+
162
+ function contextSummaryLines(context) {
163
+ const lines = [];
164
+ if (context?.url) lines.push(`- **URL:** ${context.url}`);
165
+ if (context?.viewport?.w) lines.push(`- **Viewport:** ${context.viewport.w}x${context.viewport.h}`);
166
+ if (context?.capturedAt) lines.push(`- **Captured:** ${context.capturedAt}`);
167
+ return lines;
168
+ }
169
+
170
+ function sectionBlock(name, heading, content) {
171
+ return `<!-- SECTION:${name}:BEGIN -->\n## ${heading}\n\n${content}\n<!-- SECTION:${name}:END -->`;
172
+ }
173
+
174
+ function serializeIssue(issue) {
175
+ const fm = {
176
+ id: issue.id,
177
+ project: issue.project,
178
+ title: issue.title,
179
+ status: issue.status,
180
+ type: issue.type,
181
+ tags: issue.tags,
182
+ severity: issue.severity,
183
+ ordinal: issue.ordinal,
184
+ created: issue.created,
185
+ updated: issue.updated,
186
+ };
187
+ if (issue.relatedTo) fm.related_to = issue.relatedTo;
188
+ if (issue.modifiedFiles?.length) fm.modified_files = issue.modifiedFiles;
189
+ if (issue.closed) fm.closed = issue.closed;
190
+
191
+ const blocks = [];
192
+ blocks.push(sectionBlock('SEEING', 'Seeing', `${issue.seeing || ''}\n`));
193
+ blocks.push(sectionBlock('EXPECTING', 'Expecting', `${issue.expecting || ''}\n`));
194
+ if (issue.context) {
195
+ const summary = contextSummaryLines(issue.context);
196
+ const body =
197
+ (summary.length ? summary.join('\n') + '\n\n' : '') +
198
+ '```json\n' +
199
+ JSON.stringify(issue.context, null, 2) +
200
+ '\n```\n';
201
+ blocks.push(sectionBlock('CONTEXT', 'Context', body));
202
+ }
203
+ if (issue.resolution) blocks.push(sectionBlock('RESOLUTION', 'Resolution', `${issue.resolution}\n`));
204
+ const comments = (issue.comments || [])
205
+ .map((c) => `### ${c.author} — ${c.at || new Date().toISOString()}\n\n${c.text}\n`)
206
+ .join('\n');
207
+ blocks.push(sectionBlock('COMMENTS', 'Comments', comments));
208
+
209
+ return matter.stringify('\n' + blocks.join('\n\n') + '\n', fm);
210
+ }
211
+
212
+ // ---- parsing ----------------------------------------------------------------
213
+
214
+ function extractSentinelSection(body, name) {
215
+ const re = new RegExp(`<!-- SECTION:${name}:BEGIN -->\\n([\\s\\S]*?)<!-- SECTION:${name}:END -->`);
216
+ const m = re.exec(body);
217
+ if (!m) return null;
218
+ let content = m[1];
219
+ content = content.replace(/^## [^\n]*\n/, ''); // drop the human-readable heading line
220
+ return content.replace(/^\n+/, '').replace(/\s+$/, '');
221
+ }
222
+
223
+ // Heading-based fallback for hand-written files without sentinels.
224
+ function splitSectionsByHeadings(body) {
225
+ const names = SECTIONS.map((s) => s.heading);
226
+ const re = new RegExp(`^## (${names.join('|')})[ \\t]*$`, 'gm');
227
+ const hits = [];
228
+ let m;
229
+ while ((m = re.exec(body)) !== null) hits.push({ name: m[1], start: m.index, contentStart: m.index + m[0].length });
230
+ const sections = {};
231
+ for (let i = 0; i < hits.length; i++) {
232
+ const end = i + 1 < hits.length ? hits[i + 1].start : body.length;
233
+ sections[hits[i].name] = body.slice(hits[i].contentStart, end).replace(/^\n+/, '').replace(/\s+$/, '');
234
+ }
235
+ return sections;
236
+ }
237
+
238
+ function extractSections(body) {
239
+ if (body.includes('<!-- SECTION:')) {
240
+ const out = {};
241
+ for (const s of SECTIONS) out[s.heading] = extractSentinelSection(body, s.name);
242
+ return out;
243
+ }
244
+ return splitSectionsByHeadings(body);
245
+ }
246
+
247
+ function parseContextSection(text) {
248
+ if (!text) return null;
249
+ const open = text.indexOf('```json\n');
250
+ if (open === -1) return null;
251
+ const close = text.indexOf('\n```', open + 8);
252
+ if (close === -1) return null;
253
+ try {
254
+ return JSON.parse(text.slice(open + 8, close));
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ function parseCommentsSection(text) {
261
+ if (!text) return [];
262
+ const lines = text.split('\n');
263
+ const comments = [];
264
+ let current = null;
265
+ for (const line of lines) {
266
+ const m = COMMENT_HEADER_RE.exec(line);
267
+ if (m) {
268
+ if (current) comments.push(finishComment(current));
269
+ current = { author: m[1], at: m[2], body: [] };
270
+ } else if (current) {
271
+ current.body.push(line);
272
+ }
273
+ }
274
+ if (current) comments.push(finishComment(current));
275
+ return comments;
276
+ }
277
+
278
+ function finishComment(c) {
279
+ const text = c.body.join('\n').replace(/^\n+/, '').replace(/\s+$/, '');
280
+ return { author: c.author, at: c.at, text };
281
+ }
282
+
283
+ function parseIssueFile(file) {
284
+ const raw = fs.readFileSync(file, 'utf8');
285
+ const fm = matter(raw);
286
+ const data = { ...fm.data };
287
+ if (!data.id || !data.project) return null;
288
+ const sections = extractSections(fm.content);
289
+ return {
290
+ id: String(data.id),
291
+ project: String(data.project),
292
+ title: data.title == null ? '' : String(data.title),
293
+ status: data.status || 'backlog',
294
+ type: data.type || 'other',
295
+ tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
296
+ severity: Number(data.severity) || 3,
297
+ ordinal: Number(data.ordinal) || 0,
298
+ created: toIso(data.created) || null,
299
+ updated: toIso(data.updated) || null,
300
+ relatedTo: data.related_to ? String(data.related_to) : null,
301
+ modifiedFiles: Array.isArray(data.modified_files) ? data.modified_files.map(String) : [],
302
+ closed: toIso(data.closed) || null,
303
+ seeing: sections.Seeing || '',
304
+ expecting: sections.Expecting || '',
305
+ context: parseContextSection(sections.Context),
306
+ resolution: sections.Resolution || null,
307
+ comments: parseCommentsSection(sections.Comments),
308
+ file,
309
+ };
310
+ }
311
+
312
+ function readProjectIssues(dataDir, projectKey, dir = issuesDir(dataDir, projectKey)) {
313
+ if (!fs.existsSync(dir)) return [];
314
+ const issues = [];
315
+ for (const name of fs.readdirSync(dir)) {
316
+ if (!name.endsWith('.md')) continue;
317
+ const file = path.join(dir, name);
318
+ try {
319
+ const issue = parseIssueFile(file);
320
+ if (issue) issues.push(issue);
321
+ else console.warn(`[issue-tracker] skipping malformed issue file: ${file}`);
322
+ } catch (err) {
323
+ console.warn(`[issue-tracker] skipping unreadable issue file: ${file} (${err.message})`);
324
+ }
325
+ }
326
+ return issues;
327
+ }
328
+
329
+ function parseId(id) {
330
+ const m = /^(.+)-(\d+)$/.exec(String(id || ''));
331
+ if (!m) return null;
332
+ // Sanitize the project segment the same way createIssue does, so a crafted id
333
+ // (e.g. "../../etc-1") can never steer the derived issues/closed path outside
334
+ // the data dir. Stored ids are already clean, so legit lookups are unaffected.
335
+ const project = m[1].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
336
+ return { project, seq: Number(m[2]) };
337
+ }
338
+
339
+ function findIssue(dataDir, id) {
340
+ const parsed = parseId(id);
341
+ if (!parsed) return null;
342
+ return (
343
+ readProjectIssues(dataDir, parsed.project).find((i) => i.id === id) ||
344
+ readProjectIssues(dataDir, parsed.project, closedDir(dataDir, parsed.project)).find((i) => i.id === id) ||
345
+ null
346
+ );
347
+ }
348
+
349
+ function assertOpen(issue) {
350
+ if (issue.closed) {
351
+ const err = new Error(`Issue is closed (swept to archive): ${issue.id}`);
352
+ err.code = 'CLOSED';
353
+ throw err;
354
+ }
355
+ }
356
+
357
+ function validatePatchValue(key, value) {
358
+ if (key === 'status' && !STATUSES.includes(value)) throw new Error(`Invalid status: ${value}`);
359
+ if (key === 'type' && !TYPES.includes(value)) throw new Error(`Invalid type: ${value}`);
360
+ if (key === 'severity') {
361
+ const n = Number(value);
362
+ if (!Number.isInteger(n) || n < 1 || n > 5) throw new Error(`Invalid severity: ${value}`);
363
+ return n;
364
+ }
365
+ if (key === 'ordinal') {
366
+ const n = Number(value);
367
+ if (!Number.isFinite(n)) throw new Error(`Invalid ordinal: ${value}`);
368
+ return n;
369
+ }
370
+ if (key === 'tags') {
371
+ if (!Array.isArray(value)) throw new Error('Invalid tags: expected array');
372
+ return value.map(String);
373
+ }
374
+ if (key === 'title') return String(value);
375
+ if (key === 'relatedTo') return value == null ? null : String(value);
376
+ return value;
377
+ }
378
+
379
+ // When midpoint inserts exhaust the gap between neighbors, rewrite the whole
380
+ // column on a fresh 1000-step grid (Backlog.md's approach).
381
+ function resequenceIfCramped(dataDir, projectKey, status) {
382
+ const column = readProjectIssues(dataDir, projectKey)
383
+ .filter((i) => i.status === status)
384
+ .sort((a, b) => a.ordinal - b.ordinal || a.id.localeCompare(b.id));
385
+ let cramped = false;
386
+ for (let i = 1; i < column.length; i++) {
387
+ if (Math.abs(column[i].ordinal - column[i - 1].ordinal) < MIN_ORDINAL_GAP) {
388
+ cramped = true;
389
+ break;
390
+ }
391
+ }
392
+ if (!cramped) return;
393
+ column.forEach((issue, idx) => {
394
+ issue.ordinal = (idx + 1) * ORDINAL_STEP;
395
+ writeFileAtomic(issue.file, serializeIssue(issue));
396
+ });
397
+ }
398
+
399
+ export function createIssue(dataDir, input) {
400
+ const project = sanitizeProjectKey(input.project);
401
+ if (!input.seeing || !String(input.seeing).trim()) throw new Error('seeing is required');
402
+ if (!input.expecting || !String(input.expecting).trim()) throw new Error('expecting is required');
403
+ ensureProject(dataDir, project);
404
+
405
+ return withLock(projectLock(dataDir, project), () => {
406
+ const existing = readProjectIssues(dataDir, project);
407
+ const closed = readProjectIssues(dataDir, project, closedDir(dataDir, project));
408
+ const maxSeq = [...existing, ...closed].reduce((max, i) => Math.max(max, parseId(i.id)?.seq || 0), 0);
409
+ const maxOrdinal = existing.reduce((max, i) => Math.max(max, i.ordinal || 0), 0);
410
+ const now = new Date().toISOString();
411
+
412
+ const issue = {
413
+ id: `${project}-${maxSeq + 1}`,
414
+ project,
415
+ title: input.title ? String(input.title) : '',
416
+ status: 'backlog',
417
+ type: TYPES.includes(input.type) ? input.type : 'other',
418
+ tags: validatePatchValue('tags', Array.isArray(input.tags) ? input.tags : []),
419
+ severity: input.severity == null ? 3 : validatePatchValue('severity', input.severity),
420
+ ordinal: maxOrdinal + ORDINAL_STEP,
421
+ created: now,
422
+ updated: now,
423
+ relatedTo: input.relatedTo ? String(input.relatedTo) : null,
424
+ seeing: String(input.seeing),
425
+ expecting: String(input.expecting),
426
+ context: input.context ?? null,
427
+ comments: [],
428
+ };
429
+ const file = path.join(issuesDir(dataDir, project), `${issue.id}-${slugify(issue.title)}.md`);
430
+ writeFileAtomic(file, serializeIssue(issue));
431
+ return { ...issue, file };
432
+ });
433
+ }
434
+
435
+ export function getIssue(dataDir, id) {
436
+ return findIssue(dataDir, id);
437
+ }
438
+
439
+ export function listIssues(dataDir, projectKey, { status } = {}) {
440
+ let issues = readProjectIssues(dataDir, projectKey);
441
+ if (status) issues = issues.filter((i) => i.status === status);
442
+ return issues.sort((a, b) => {
443
+ const s = STATUSES.indexOf(a.status) - STATUSES.indexOf(b.status);
444
+ if (s !== 0) return s;
445
+ return a.ordinal - b.ordinal || a.id.localeCompare(b.id);
446
+ });
447
+ }
448
+
449
+ export function updateIssue(dataDir, id, patch) {
450
+ const parsed = parseId(id);
451
+ if (!parsed) throw notFound(id);
452
+ return withLock(projectLock(dataDir, parsed.project), () => {
453
+ const issue = findIssue(dataDir, id);
454
+ if (!issue) throw notFound(id);
455
+ assertOpen(issue);
456
+ for (const [key, value] of Object.entries(patch)) {
457
+ if (!PATCHABLE.includes(key)) throw new Error(`Cannot update field: ${key}`);
458
+ issue[key] = validatePatchValue(key, value);
459
+ }
460
+ issue.updated = new Date().toISOString();
461
+ writeFileAtomic(issue.file, serializeIssue(issue));
462
+ if ('ordinal' in patch) resequenceIfCramped(dataDir, issue.project, issue.status);
463
+ return 'ordinal' in patch ? findIssue(dataDir, id) : issue;
464
+ });
465
+ }
466
+
467
+ export function resolveIssue(dataDir, id, { resolution, modifiedFiles } = {}) {
468
+ const parsed = parseId(id);
469
+ if (!parsed) throw notFound(id);
470
+ if (!resolution || !String(resolution).trim()) throw new Error('resolution is required');
471
+ return withLock(projectLock(dataDir, parsed.project), () => {
472
+ const issue = findIssue(dataDir, id);
473
+ if (!issue) throw notFound(id);
474
+ assertOpen(issue);
475
+ issue.resolution = String(resolution);
476
+ issue.modifiedFiles = Array.isArray(modifiedFiles) ? modifiedFiles.map(String) : issue.modifiedFiles;
477
+ issue.status = 'in-review';
478
+ issue.updated = new Date().toISOString();
479
+ writeFileAtomic(issue.file, serializeIssue(issue));
480
+ return issue;
481
+ });
482
+ }
483
+
484
+ export function sweepDone(dataDir, projectKey) {
485
+ const project = sanitizeProjectKey(projectKey);
486
+ return withLock(projectLock(dataDir, project), () => {
487
+ const done = readProjectIssues(dataDir, project).filter((i) => i.status === 'done');
488
+ const now = new Date().toISOString();
489
+ const swept = [];
490
+ for (const issue of done) {
491
+ issue.closed = now;
492
+ const dest = path.join(closedDir(dataDir, project), path.basename(issue.file));
493
+ writeFileAtomic(dest, serializeIssue(issue));
494
+ fs.rmSync(issue.file);
495
+ swept.push({ ...issue, file: dest });
496
+ }
497
+ return swept;
498
+ });
499
+ }
500
+
501
+ // Permanently remove an issue file (open OR archived). Unlike the other
502
+ // mutations this intentionally does NOT assertOpen — deleting stale / test /
503
+ // no-longer-useful issues, including swept ones, is the whole point. Hard
504
+ // delete is irreversible and has no MCP equivalent, so only a human at the
505
+ // board (or a deliberate REST call) can do it. Note on ids: deleting the
506
+ // highest-numbered issue frees that id for reuse by the next createIssue;
507
+ // deleting any other issue just leaves a gap in the sequence.
508
+ export function deleteIssue(dataDir, id) {
509
+ const parsed = parseId(id);
510
+ if (!parsed) throw notFound(id);
511
+ return withLock(projectLock(dataDir, parsed.project), () => {
512
+ const issue = findIssue(dataDir, id);
513
+ if (!issue) throw notFound(id);
514
+ // force: if the file vanished after findIssue (an out-of-lock `rm`, a git
515
+ // checkout in data/), treat it as already-deleted rather than throwing a
516
+ // path-leaking ENOENT — the intent (make it gone) is satisfied either way.
517
+ fs.rmSync(issue.file, { force: true });
518
+ return { deleted: issue.id, project: issue.project, title: issue.title, wasClosed: !!issue.closed };
519
+ });
520
+ }
521
+
522
+ export function listClosed(dataDir, projectKey) {
523
+ return readProjectIssues(dataDir, projectKey, closedDir(dataDir, projectKey)).sort((a, b) =>
524
+ String(b.closed).localeCompare(String(a.closed))
525
+ );
526
+ }
527
+
528
+ export function searchIssues(dataDir, { query, project, status, includeClosed = false, limit = 50 } = {}) {
529
+ const q = String(query || '').trim().toLowerCase();
530
+ if (!q) return [];
531
+ const keys = project ? [sanitizeProjectKey(project)] : listProjects(dataDir).map((p) => p.key);
532
+ const results = [];
533
+ for (const key of keys) {
534
+ let issues = listIssues(dataDir, key, { status });
535
+ if (includeClosed) issues = issues.concat(listClosed(dataDir, key));
536
+ for (const issue of issues) {
537
+ const haystack = [
538
+ issue.id,
539
+ issue.title,
540
+ issue.tags.join(' '),
541
+ issue.seeing,
542
+ issue.expecting,
543
+ issue.resolution || '',
544
+ issue.comments.map((c) => `${c.author} ${c.text}`).join(' '),
545
+ ]
546
+ .join('\n')
547
+ .toLowerCase();
548
+ if (haystack.includes(q)) results.push(issue);
549
+ if (results.length >= limit) return results;
550
+ }
551
+ }
552
+ return results;
553
+ }
554
+
555
+ export function addComment(dataDir, id, { author, text }) {
556
+ const parsed = parseId(id);
557
+ if (!parsed) throw notFound(id);
558
+ if (!author || !String(author).trim()) throw new Error('author is required');
559
+ if (!text || !String(text).trim()) throw new Error('text is required');
560
+ return withLock(projectLock(dataDir, parsed.project), () => {
561
+ const issue = findIssue(dataDir, id);
562
+ if (!issue) throw notFound(id);
563
+ assertOpen(issue);
564
+ issue.comments.push({ author: String(author), at: new Date().toISOString(), text: String(text) });
565
+ issue.updated = new Date().toISOString();
566
+ writeFileAtomic(issue.file, serializeIssue(issue));
567
+ return issue;
568
+ });
569
+ }