trackerctl 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.
@@ -0,0 +1,1665 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/cli/index.ts
5
+ import { existsSync as existsSync3 } from "fs";
6
+ import { resolve as resolve3 } from "path";
7
+
8
+ // src/errors.ts
9
+ class DomainError extends Error {
10
+ }
11
+
12
+ class UsageError extends Error {
13
+ }
14
+ var secrets = [];
15
+ function registerSecret(secret) {
16
+ if (secret && !secrets.includes(secret))
17
+ secrets.push(secret);
18
+ }
19
+ function redact(text) {
20
+ let out = text;
21
+ for (const s of secrets) {
22
+ while (out.includes(s))
23
+ out = out.replace(s, "[REDACTED]");
24
+ }
25
+ out = out.replace(/glpat-[\w-]{10,}/g, "[REDACTED]");
26
+ return out;
27
+ }
28
+
29
+ // src/adapters/gitlab/client.ts
30
+ class GitLabHttpError extends Error {
31
+ status;
32
+ constructor(status, message) {
33
+ super(message);
34
+ this.status = status;
35
+ }
36
+ }
37
+
38
+ class GitLabClient {
39
+ baseUrl;
40
+ token;
41
+ fetchImpl;
42
+ constructor(opts) {
43
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
44
+ this.token = opts.token;
45
+ this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
46
+ registerSecret(this.token);
47
+ }
48
+ async request(url, init) {
49
+ let res;
50
+ try {
51
+ res = await this.fetchImpl(url, {
52
+ ...init,
53
+ headers: {
54
+ Authorization: `Bearer ${this.token}`,
55
+ "Content-Type": "application/json",
56
+ ...init.headers ?? {}
57
+ }
58
+ });
59
+ } catch (e) {
60
+ throw new UsageError(redact(`request to ${url} failed: ${e.message}`));
61
+ }
62
+ if (!res.ok) {
63
+ const body = await res.text().catch(() => "");
64
+ throw new GitLabHttpError(res.status, redact(`GitLab API ${init.method ?? "GET"} ${url} \u2192 ${res.status}: ${body.slice(0, 500)}`));
65
+ }
66
+ return res;
67
+ }
68
+ async rest(path, opts = {}) {
69
+ const makeUrl = (page2) => {
70
+ const url = new URL(`${this.baseUrl}/api/v4/${path.replace(/^\//, "")}`);
71
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
72
+ if (v !== undefined)
73
+ url.searchParams.set(k, v);
74
+ }
75
+ if (opts.paginate) {
76
+ url.searchParams.set("per_page", "100");
77
+ if (page2)
78
+ url.searchParams.set("page", String(page2));
79
+ }
80
+ return url.toString();
81
+ };
82
+ const init = {
83
+ method: opts.method ?? "GET",
84
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body)
85
+ };
86
+ if (!opts.paginate) {
87
+ const res = await this.request(makeUrl(), init);
88
+ const text = await res.text();
89
+ return text.trim() ? JSON.parse(text) : null;
90
+ }
91
+ const all = [];
92
+ let page;
93
+ for (let i = 0;i < 100; i++) {
94
+ const res = await this.request(makeUrl(page), init);
95
+ const chunk = await res.json();
96
+ all.push(...chunk);
97
+ const next = res.headers.get("x-next-page");
98
+ if (!next)
99
+ break;
100
+ page = Number(next);
101
+ }
102
+ return all;
103
+ }
104
+ async graphql(query, variables = {}) {
105
+ const res = await this.request(`${this.baseUrl}/api/graphql`, {
106
+ method: "POST",
107
+ body: JSON.stringify({ query, variables })
108
+ });
109
+ const json = await res.json();
110
+ if (json.errors?.length) {
111
+ throw new UsageError(redact(`GraphQL: ${json.errors.map((e) => e.message).join("; ")}`));
112
+ }
113
+ if (json.data === undefined)
114
+ throw new UsageError("GraphQL: empty response");
115
+ return json.data;
116
+ }
117
+ }
118
+
119
+ // src/adapters/gitlab/map.ts
120
+ var TRAILER_RE = /^Tracker-Blocked-By:\s*(.+)$/im;
121
+ function parseBlockedByTrailer(description) {
122
+ const match = description?.match(TRAILER_RE);
123
+ if (!match)
124
+ return [];
125
+ return match[1].split(",").map((s) => s.trim().replace(/^#/, "")).filter(Boolean);
126
+ }
127
+ function upsertBlockedByTrailer(description, blockerIds) {
128
+ const existing = parseBlockedByTrailer(description);
129
+ const merged = [...new Set([...existing, ...blockerIds])];
130
+ const line = `Tracker-Blocked-By: ${merged.map((id) => `#${id}`).join(", ")}`;
131
+ if (TRAILER_RE.test(description))
132
+ return description.replace(TRAILER_RE, line);
133
+ return description.trim() ? `${description.trimEnd()}
134
+
135
+ ${line}` : line;
136
+ }
137
+ function mapUser(u) {
138
+ return { id: String(u.id), username: u.username, ...u.name ? { name: u.name } : {} };
139
+ }
140
+ function mapIssue(issue, info) {
141
+ const description = issue.description ?? "";
142
+ const blockedBy = new Set([
143
+ ...info?.blockedBy ?? [],
144
+ ...parseBlockedByTrailer(description)
145
+ ]);
146
+ return {
147
+ id: String(issue.iid),
148
+ kind: info?.typeName === "Epic" ? "epic" : "task",
149
+ title: issue.title,
150
+ state: issue.state === "opened" ? "open" : "closed",
151
+ labels: issue.labels ?? [],
152
+ assignees: (issue.assignees ?? []).map(mapUser),
153
+ author: issue.author ? mapUser(issue.author) : null,
154
+ parent: info?.parent ?? null,
155
+ blockedBy: [...blockedBy].sort((a, b) => Number(a) - Number(b) || (a < b ? -1 : 1)),
156
+ url: issue.web_url,
157
+ description,
158
+ updatedAt: issue.updated_at,
159
+ raw: issue
160
+ };
161
+ }
162
+
163
+ // src/adapters/gitlab/adapter.ts
164
+ var WORK_ITEMS_QUERY = `
165
+ query trackerWorkItems($fullPath: ID!, $after: String) {
166
+ project(fullPath: $fullPath) {
167
+ workItems(first: 100, after: $after) {
168
+ nodes {
169
+ iid
170
+ workItemType { name }
171
+ widgets {
172
+ ... on WorkItemWidgetHierarchy { parent { iid } }
173
+ ... on WorkItemWidgetLinkedItems {
174
+ linkedItems(first: 100) { nodes { linkType workItem { iid } } }
175
+ }
176
+ }
177
+ }
178
+ pageInfo { endCursor hasNextPage }
179
+ }
180
+ }
181
+ }`;
182
+ var WORK_ITEM_GIDS_QUERY = `
183
+ query trackerWorkItemGids($fullPath: ID!, $iids: [String!]) {
184
+ project(fullPath: $fullPath) {
185
+ workItems(iids: $iids) { nodes { id iid } }
186
+ }
187
+ }`;
188
+ var WORK_ITEM_TYPES_QUERY = `
189
+ query trackerWorkItemTypes($fullPath: ID!) {
190
+ project(fullPath: $fullPath) {
191
+ workItemTypes { nodes { id name } }
192
+ }
193
+ }`;
194
+ var WORK_ITEM_CREATE_MUTATION = `
195
+ mutation trackerWorkItemCreate($input: WorkItemCreateInput!) {
196
+ workItemCreate(input: $input) {
197
+ errors
198
+ workItem { id iid }
199
+ }
200
+ }`;
201
+ var WORK_ITEM_UPDATE_MUTATION = `
202
+ mutation trackerWorkItemSetParent($input: WorkItemUpdateInput!) {
203
+ workItemUpdate(input: $input) {
204
+ errors
205
+ workItem { id }
206
+ }
207
+ }`;
208
+
209
+ class GitLabAdapter {
210
+ provider = "gitlab";
211
+ client;
212
+ projectRef;
213
+ fullPath;
214
+ nativeBlocking;
215
+ me = null;
216
+ taskTypeGid = null;
217
+ constructor(opts) {
218
+ this.client = new GitLabClient({
219
+ baseUrl: opts.baseUrl,
220
+ token: opts.token,
221
+ fetchImpl: opts.fetchImpl
222
+ });
223
+ this.projectRef = encodeURIComponent(opts.project);
224
+ this.fullPath = /^\d+$/.test(opts.project) ? null : opts.project;
225
+ this.nativeBlocking = opts.nativeBlocking;
226
+ }
227
+ capabilities() {
228
+ return { nativeBlocking: this.nativeBlocking, nativeHierarchy: true, serverSearch: true };
229
+ }
230
+ async getFullPath() {
231
+ if (this.fullPath)
232
+ return this.fullPath;
233
+ const project = await this.client.rest(`projects/${this.projectRef}`);
234
+ this.fullPath = project.path_with_namespace;
235
+ return this.fullPath;
236
+ }
237
+ async whoami() {
238
+ if (!this.me)
239
+ this.me = mapUser(await this.client.rest("user"));
240
+ return this.me;
241
+ }
242
+ async fetchHierarchy() {
243
+ const fullPath = await this.getFullPath();
244
+ const out = new Map;
245
+ let after = null;
246
+ for (let page = 0;page < 100; page++) {
247
+ const data = await this.client.graphql(WORK_ITEMS_QUERY, {
248
+ fullPath,
249
+ after
250
+ });
251
+ const conn = data.project?.workItems;
252
+ if (!conn)
253
+ break;
254
+ for (const node of conn.nodes) {
255
+ const parent = node.widgets.find((w) => w.parent !== undefined)?.parent;
256
+ const linked = node.widgets.find((w) => w.linkedItems !== undefined)?.linkedItems;
257
+ const blockedBy = [];
258
+ for (const link of linked?.nodes ?? []) {
259
+ const type = link.linkType.toLowerCase();
260
+ if (type === "is_blocked_by" || type === "blocked_by")
261
+ blockedBy.push(link.workItem.iid);
262
+ }
263
+ out.set(node.iid, {
264
+ parent: parent ? parent.iid : null,
265
+ blockedBy,
266
+ typeName: node.workItemType.name
267
+ });
268
+ }
269
+ if (!conn.pageInfo.hasNextPage)
270
+ break;
271
+ after = conn.pageInfo.endCursor;
272
+ }
273
+ return out;
274
+ }
275
+ async fetchAll() {
276
+ const [issues, hierarchy] = await Promise.all([
277
+ this.client.rest(`projects/${this.projectRef}/issues`, {
278
+ query: { state: "all" },
279
+ paginate: true
280
+ }),
281
+ this.fetchHierarchy()
282
+ ]);
283
+ return issues.map((issue) => mapIssue(issue, hierarchy.get(String(issue.iid))));
284
+ }
285
+ async get(id) {
286
+ let issue;
287
+ try {
288
+ issue = await this.client.rest(`projects/${this.projectRef}/issues/${id}`);
289
+ } catch (e) {
290
+ if (e instanceof GitLabHttpError && e.status === 404) {
291
+ throw new DomainError(`#${id} not found`);
292
+ }
293
+ throw e;
294
+ }
295
+ return mapIssue(issue);
296
+ }
297
+ async resolveGids(iids) {
298
+ const fullPath = await this.getFullPath();
299
+ const data = await this.client.graphql(WORK_ITEM_GIDS_QUERY, { fullPath, iids });
300
+ const out = new Map;
301
+ for (const node of data.project?.workItems?.nodes ?? [])
302
+ out.set(node.iid, node.id);
303
+ return out;
304
+ }
305
+ async getTaskTypeGid() {
306
+ if (this.taskTypeGid)
307
+ return this.taskTypeGid;
308
+ const fullPath = await this.getFullPath();
309
+ const data = await this.client.graphql(WORK_ITEM_TYPES_QUERY, { fullPath });
310
+ const task = data.project?.workItemTypes?.nodes.find((t) => t.name === "Task");
311
+ if (!task)
312
+ throw new UsageError("project has no Task work-item type");
313
+ this.taskTypeGid = task.id;
314
+ return task.id;
315
+ }
316
+ async create(draft) {
317
+ if (draft.parent) {
318
+ const gids = await this.resolveGids([draft.parent]);
319
+ const parentGid = gids.get(draft.parent);
320
+ if (!parentGid)
321
+ throw new DomainError(`parent #${draft.parent} not found`);
322
+ const fullPath = await this.getFullPath();
323
+ const data = await this.client.graphql(WORK_ITEM_CREATE_MUTATION, {
324
+ input: {
325
+ namespacePath: fullPath,
326
+ title: draft.title,
327
+ description: draft.description ?? "",
328
+ workItemTypeId: await this.getTaskTypeGid(),
329
+ hierarchyWidget: { parentId: parentGid }
330
+ }
331
+ });
332
+ if (data.workItemCreate.errors.length || !data.workItemCreate.workItem) {
333
+ throw new DomainError(`workItemCreate: ${data.workItemCreate.errors.join("; ")}`);
334
+ }
335
+ const iid = data.workItemCreate.workItem.iid;
336
+ if (draft.labels?.length || draft.milestone) {
337
+ await this.client.rest(`projects/${this.projectRef}/issues/${iid}`, {
338
+ method: "PUT",
339
+ body: {
340
+ ...draft.labels?.length ? { add_labels: draft.labels.join(",") } : {},
341
+ ...draft.milestone ? { milestone_id: draft.milestone } : {}
342
+ }
343
+ });
344
+ }
345
+ const created = await this.get(iid);
346
+ return { ...created, parent: draft.parent };
347
+ }
348
+ const issue = await this.client.rest(`projects/${this.projectRef}/issues`, {
349
+ method: "POST",
350
+ body: {
351
+ title: draft.title,
352
+ description: draft.description ?? "",
353
+ ...draft.labels?.length ? { labels: draft.labels.join(",") } : {},
354
+ ...draft.milestone ? { milestone_id: draft.milestone } : {},
355
+ ...draft.epicId ? { epic_id: Number(draft.epicId) } : {}
356
+ }
357
+ });
358
+ return mapIssue(issue);
359
+ }
360
+ async update(id, patch) {
361
+ const body = {};
362
+ if (patch.assigneeIds) {
363
+ body.assignee_ids = patch.assigneeIds.length ? patch.assigneeIds.map(Number) : [0];
364
+ }
365
+ if (patch.addLabels?.length)
366
+ body.add_labels = patch.addLabels.join(",");
367
+ if (patch.removeLabels?.length)
368
+ body.remove_labels = patch.removeLabels.join(",");
369
+ if (patch.title !== undefined)
370
+ body.title = patch.title;
371
+ if (patch.description !== undefined)
372
+ body.description = patch.description;
373
+ if (Object.keys(body).length === 0)
374
+ return;
375
+ await this.client.rest(`projects/${this.projectRef}/issues/${id}`, { method: "PUT", body });
376
+ }
377
+ async transition(id, to) {
378
+ await this.client.rest(`projects/${this.projectRef}/issues/${id}`, {
379
+ method: "PUT",
380
+ body: { state_event: to === "closed" ? "close" : "reopen" }
381
+ });
382
+ }
383
+ async link(blocker, blocked) {
384
+ if (this.nativeBlocking) {
385
+ await this.client.rest(`projects/${this.projectRef}/issues/${blocker}/links`, {
386
+ method: "POST",
387
+ body: {
388
+ target_project_id: decodeURIComponent(this.projectRef),
389
+ target_issue_iid: Number(blocked),
390
+ link_type: "blocks"
391
+ }
392
+ });
393
+ return;
394
+ }
395
+ const item = await this.get(blocked);
396
+ await this.update(blocked, {
397
+ description: upsertBlockedByTrailer(item.description, [blocker])
398
+ });
399
+ }
400
+ async setParent(child, parent) {
401
+ const wanted = parent === null ? [child] : [child, parent];
402
+ const gids = await this.resolveGids(wanted);
403
+ const childGid = gids.get(child);
404
+ if (!childGid)
405
+ throw new DomainError(`#${child} not found`);
406
+ let parentGid = null;
407
+ if (parent !== null) {
408
+ parentGid = gids.get(parent) ?? null;
409
+ if (!parentGid)
410
+ throw new DomainError(`parent #${parent} not found`);
411
+ }
412
+ const data = await this.client.graphql(WORK_ITEM_UPDATE_MUTATION, { input: { id: childGid, hierarchyWidget: { parentId: parentGid } } });
413
+ if (data.workItemUpdate.errors.length) {
414
+ throw new DomainError(`workItemUpdate: ${data.workItemUpdate.errors.join("; ")}`);
415
+ }
416
+ }
417
+ async comment(id, body) {
418
+ await this.client.rest(`projects/${this.projectRef}/issues/${id}/notes`, {
419
+ method: "POST",
420
+ body: { body }
421
+ });
422
+ }
423
+ async listComments(id) {
424
+ const notes = await this.client.rest(`projects/${this.projectRef}/issues/${id}/notes`, { query: { sort: "asc", order_by: "created_at" }, paginate: true });
425
+ return notes.filter((n) => !n.system).map((n) => ({
426
+ id: String(n.id),
427
+ body: n.body,
428
+ author: mapUser(n.author),
429
+ createdAt: n.created_at
430
+ }));
431
+ }
432
+ async searchRemote(q) {
433
+ const state = q.state ?? "all";
434
+ const issues = await this.client.rest(`projects/${this.projectRef}/issues`, {
435
+ query: {
436
+ ...q.text ? { search: q.text, in: "title,description" } : {},
437
+ ...q.assignee ? { assignee_username: q.assignee.replace(/^@/, "") } : {},
438
+ ...q.author ? { author_username: q.author.replace(/^@/, "") } : {},
439
+ ...q.label ? { labels: q.label } : {},
440
+ ...state !== "all" ? { state: state === "open" ? "opened" : "closed" } : {}
441
+ },
442
+ paginate: true
443
+ });
444
+ return issues.map((issue) => mapIssue(issue));
445
+ }
446
+ async resolveUsers(query) {
447
+ const users = await this.client.rest(`projects/${this.projectRef}/users`, {
448
+ query: { search: query },
449
+ paginate: true
450
+ });
451
+ return users.map(mapUser);
452
+ }
453
+ async probeProject() {
454
+ const project = await this.client.rest(`projects/${this.projectRef}`);
455
+ return { name: project.name_with_namespace, webUrl: project.web_url };
456
+ }
457
+ async probeGraphql() {
458
+ await this.getTaskTypeGid();
459
+ return true;
460
+ }
461
+ }
462
+
463
+ // src/cache/db.ts
464
+ import { Database } from "bun:sqlite";
465
+ import { mkdirSync } from "fs";
466
+ import { dirname } from "path";
467
+ var SCHEMA = `
468
+ CREATE TABLE IF NOT EXISTS items (
469
+ id TEXT PRIMARY KEY,
470
+ provider TEXT NOT NULL,
471
+ kind TEXT NOT NULL,
472
+ title TEXT NOT NULL,
473
+ state TEXT NOT NULL,
474
+ labels TEXT NOT NULL,
475
+ assignees TEXT NOT NULL,
476
+ author TEXT,
477
+ parent TEXT,
478
+ url TEXT NOT NULL,
479
+ description TEXT NOT NULL,
480
+ updated_at TEXT NOT NULL,
481
+ raw TEXT
482
+ );
483
+ CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent);
484
+ CREATE TABLE IF NOT EXISTS links (
485
+ blocker TEXT NOT NULL,
486
+ blocked TEXT NOT NULL,
487
+ PRIMARY KEY (blocker, blocked)
488
+ );
489
+ CREATE INDEX IF NOT EXISTS idx_links_blocked ON links(blocked);
490
+ CREATE TABLE IF NOT EXISTS meta (
491
+ key TEXT PRIMARY KEY,
492
+ value TEXT NOT NULL
493
+ );
494
+ CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(id UNINDEXED, title, description);
495
+ `;
496
+
497
+ class Cache {
498
+ db;
499
+ constructor(path) {
500
+ if (path !== ":memory:")
501
+ mkdirSync(dirname(path), { recursive: true });
502
+ this.db = new Database(path);
503
+ this.db.exec(SCHEMA);
504
+ }
505
+ replaceAll(items, provider) {
506
+ const insertItem = this.db.prepare(`INSERT INTO items (id, provider, kind, title, state, labels, assignees, author, parent, url, description, updated_at, raw)
507
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
508
+ const insertLink = this.db.prepare("INSERT OR IGNORE INTO links (blocker, blocked) VALUES (?, ?)");
509
+ const insertFts = this.db.prepare("INSERT INTO items_fts (id, title, description) VALUES (?, ?, ?)");
510
+ const tx = this.db.transaction(() => {
511
+ this.db.exec("DELETE FROM items; DELETE FROM links; DELETE FROM items_fts;");
512
+ for (const item of items) {
513
+ insertItem.run(item.id, provider, item.kind, item.title, item.state, JSON.stringify(item.labels), JSON.stringify(item.assignees), item.author ? JSON.stringify(item.author) : null, item.parent, item.url, item.description, item.updatedAt, item.raw === undefined ? null : JSON.stringify(item.raw));
514
+ for (const blocker of item.blockedBy)
515
+ insertLink.run(blocker, item.id);
516
+ insertFts.run(item.id, item.title, item.description);
517
+ }
518
+ });
519
+ tx();
520
+ }
521
+ rowToItem(row) {
522
+ return {
523
+ id: row.id,
524
+ kind: row.kind,
525
+ title: row.title,
526
+ state: row.state,
527
+ labels: JSON.parse(row.labels),
528
+ assignees: JSON.parse(row.assignees),
529
+ author: row.author ? JSON.parse(row.author) : null,
530
+ parent: row.parent,
531
+ blockedBy: this.blockersOf(row.id),
532
+ url: row.url,
533
+ description: row.description,
534
+ updatedAt: row.updated_at
535
+ };
536
+ }
537
+ blockersOf(id) {
538
+ return this.db.query("SELECT blocker FROM links WHERE blocked = ? ORDER BY blocker").all(id).map((r) => r.blocker);
539
+ }
540
+ getItem(id) {
541
+ const row = this.db.query("SELECT * FROM items WHERE id = ?").get(id);
542
+ return row ? this.rowToItem(row) : null;
543
+ }
544
+ getRaw(id) {
545
+ const row = this.db.query("SELECT raw FROM items WHERE id = ?").get(id);
546
+ return row?.raw ? JSON.parse(row.raw) : null;
547
+ }
548
+ allItems() {
549
+ return this.db.query("SELECT * FROM items ORDER BY CAST(id AS INTEGER), id").all().map((row) => this.rowToItem(row));
550
+ }
551
+ childrenOf(parent) {
552
+ return this.db.query("SELECT * FROM items WHERE parent = ? ORDER BY CAST(id AS INTEGER), id").all(parent).map((row) => this.rowToItem(row));
553
+ }
554
+ count() {
555
+ const row = this.db.query("SELECT COUNT(*) AS n FROM items").get();
556
+ return row?.n ?? 0;
557
+ }
558
+ searchText(text) {
559
+ const query = text.split(/\s+/).filter(Boolean).map((t) => `"${t.replaceAll('"', '""')}"`).join(" ");
560
+ if (!query)
561
+ return new Set;
562
+ const rows = this.db.query("SELECT id FROM items_fts WHERE items_fts MATCH ?").all(query);
563
+ return new Set(rows.map((r) => r.id));
564
+ }
565
+ metaGet(key) {
566
+ const row = this.db.query("SELECT value FROM meta WHERE key = ?").get(key);
567
+ return row?.value ?? null;
568
+ }
569
+ metaSet(key, value) {
570
+ this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(key, value);
571
+ }
572
+ markSynced(nowMs) {
573
+ this.metaSet("last_sync_at", String(nowMs));
574
+ }
575
+ lastSyncAt() {
576
+ const v = this.metaGet("last_sync_at");
577
+ return v === null ? null : Number(v);
578
+ }
579
+ isStale(nowMs, staleMs) {
580
+ const last = this.lastSyncAt();
581
+ return last === null || nowMs - last > staleMs;
582
+ }
583
+ close() {
584
+ this.db.close();
585
+ }
586
+ }
587
+
588
+ // src/cache/ignore-guard.ts
589
+ import { appendFileSync, existsSync, readFileSync } from "fs";
590
+ import { dirname as dirname2, join, relative, resolve } from "path";
591
+ function findGitRoot(startDir) {
592
+ let dir = resolve(startDir);
593
+ for (;; ) {
594
+ if (existsSync(join(dir, ".git")))
595
+ return dir;
596
+ const parent = dirname2(dir);
597
+ if (parent === dir)
598
+ return null;
599
+ dir = parent;
600
+ }
601
+ }
602
+ function isGitIgnored(gitRoot, path) {
603
+ try {
604
+ const proc = Bun.spawnSync(["git", "-C", gitRoot, "check-ignore", "-q", "--", path], {
605
+ stdout: "ignore",
606
+ stderr: "ignore"
607
+ });
608
+ if (proc.exitCode === 0)
609
+ return true;
610
+ if (proc.exitCode === 1)
611
+ return false;
612
+ return null;
613
+ } catch {
614
+ return null;
615
+ }
616
+ }
617
+ function guardCacheIgnored(rootDir, absCachePath) {
618
+ const cacheDir = dirname2(absCachePath);
619
+ const gitRoot = findGitRoot(rootDir);
620
+ if (!gitRoot)
621
+ return { action: "skipped", detail: "not inside a git repository" };
622
+ const ignored = isGitIgnored(gitRoot, absCachePath);
623
+ if (ignored === null)
624
+ return { action: "skipped", detail: "git not available" };
625
+ if (ignored)
626
+ return { action: "ok", detail: "cache path is git-ignored" };
627
+ const rel = relative(rootDir, cacheDir);
628
+ if (rel.startsWith("..")) {
629
+ return {
630
+ action: "warn",
631
+ detail: `cache dir ${cacheDir} is outside the config root and NOT git-ignored \u2014 add it to a .gitignore manually`
632
+ };
633
+ }
634
+ const pattern = `${rel.replaceAll("\\", "/")}/`;
635
+ const gitignorePath = join(rootDir, ".gitignore");
636
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
637
+ const prefix = existing && !existing.endsWith(`
638
+ `) ? `
639
+ ` : "";
640
+ appendFileSync(gitignorePath, `${prefix}${pattern}
641
+ `);
642
+ return { action: "added", detail: `added "${pattern}" to ${gitignorePath}` };
643
+ }
644
+
645
+ // src/config.ts
646
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
647
+ import { dirname as dirname3, join as join2, resolve as resolve2 } from "path";
648
+ var CONFIG_FILENAME = "tracker.config.json";
649
+ function findConfigFile(startDir) {
650
+ let dir = resolve2(startDir);
651
+ for (;; ) {
652
+ const candidate = join2(dir, CONFIG_FILENAME);
653
+ if (existsSync2(candidate))
654
+ return candidate;
655
+ const parent = dirname3(dir);
656
+ if (parent === dir)
657
+ return null;
658
+ dir = parent;
659
+ }
660
+ }
661
+ function parseConfig(json, rootDir) {
662
+ let raw;
663
+ try {
664
+ raw = JSON.parse(json);
665
+ } catch (e) {
666
+ throw new UsageError(`invalid ${CONFIG_FILENAME}: ${e.message}`);
667
+ }
668
+ if (raw.provider !== "gitlab") {
669
+ throw new UsageError(`unsupported provider "${raw.provider}" (only "gitlab" for now)`);
670
+ }
671
+ const g = raw.gitlab ?? {};
672
+ if (!g.base_url)
673
+ throw new UsageError("config: gitlab.base_url is required");
674
+ if (!g.project)
675
+ throw new UsageError("config: gitlab.project is required");
676
+ const tokenEnv = g.token_env === undefined ? ["TRACKER_GITLAB_TOKEN"] : Array.isArray(g.token_env) ? g.token_env : [g.token_env];
677
+ return {
678
+ provider: "gitlab",
679
+ gitlab: {
680
+ base_url: g.base_url.replace(/\/+$/, ""),
681
+ project: String(g.project),
682
+ token_env: tokenEnv,
683
+ native_blocking: g.native_blocking ?? true
684
+ },
685
+ labels: { in_progress: raw.labels?.in_progress ?? "status::in-progress" },
686
+ memory: {
687
+ enabled: raw.memory?.enabled ?? true,
688
+ title: raw.memory?.title ?? "\uD83D\uDCCC Project Memory",
689
+ label: raw.memory?.label ?? "meta::memory"
690
+ },
691
+ cache: {
692
+ path: raw.cache?.path ?? ".tracker/cache.sqlite",
693
+ stale_minutes: raw.cache?.stale_minutes ?? 15
694
+ },
695
+ rootDir
696
+ };
697
+ }
698
+ function loadConfig(startDir = process.cwd()) {
699
+ const file = findConfigFile(startDir);
700
+ if (!file) {
701
+ throw new UsageError(`no ${CONFIG_FILENAME} found in ${startDir} or any parent directory \u2014 create one (see README) or cd into the project.`);
702
+ }
703
+ return parseConfig(readFileSync2(file, "utf8"), dirname3(file));
704
+ }
705
+ function parseDotEnv(text) {
706
+ const out = {};
707
+ for (const line of text.split(`
708
+ `)) {
709
+ const trimmed = line.trim();
710
+ if (!trimmed || trimmed.startsWith("#"))
711
+ continue;
712
+ const eq = trimmed.indexOf("=");
713
+ if (eq <= 0)
714
+ continue;
715
+ const key = trimmed.slice(0, eq).trim();
716
+ let value = trimmed.slice(eq + 1).trim();
717
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
718
+ value = value.slice(1, -1);
719
+ }
720
+ out[key] = value;
721
+ }
722
+ return out;
723
+ }
724
+ function resolveToken(config, env = process.env) {
725
+ const dotEnvPath = join2(config.rootDir, ".env");
726
+ const dotEnv = existsSync2(dotEnvPath) ? parseDotEnv(readFileSync2(dotEnvPath, "utf8")) : {};
727
+ for (const name of config.gitlab.token_env) {
728
+ const fromEnv = env[name];
729
+ if (fromEnv) {
730
+ registerSecret(fromEnv);
731
+ return { token: fromEnv, source: `${name} (environment)` };
732
+ }
733
+ const fromFile = dotEnv[name];
734
+ if (fromFile) {
735
+ registerSecret(fromFile);
736
+ return { token: fromFile, source: `${name} (.env)` };
737
+ }
738
+ }
739
+ throw new UsageError(`no GitLab token found. Set one of [${config.gitlab.token_env.join(", ")}] ` + `in the environment or in ${dotEnvPath} (gitignored).`);
740
+ }
741
+
742
+ // src/core/claim.ts
743
+ var CLAIM_MARK = "\uD83D\uDD12 tracker-claim";
744
+ var RELEASE_MARK = "\uD83D\uDD13 tracker-release";
745
+ var CLAIM_TTL_MS = 5 * 60000;
746
+ var SETTLE_MS = 2000;
747
+ function parseClaim(body) {
748
+ if (!body.startsWith(CLAIM_MARK))
749
+ return null;
750
+ const token = body.match(/token=(\S+)/)?.[1];
751
+ const agent = body.match(/agent=(\S+)/)?.[1];
752
+ const at = body.match(/at=(\S+)/)?.[1];
753
+ if (!token || !agent || !at)
754
+ return null;
755
+ const ts = Date.parse(at);
756
+ if (Number.isNaN(ts))
757
+ return null;
758
+ return { token, agent, ts };
759
+ }
760
+ function parseRelease(body) {
761
+ if (!body.startsWith(RELEASE_MARK))
762
+ return null;
763
+ return body.match(/token=(\S+)/)?.[1] ?? null;
764
+ }
765
+ function compareCommentIds(a, b) {
766
+ const na = Number(a);
767
+ const nb = Number(b);
768
+ if (Number.isFinite(na) && Number.isFinite(nb))
769
+ return na - nb;
770
+ return a < b ? -1 : a > b ? 1 : 0;
771
+ }
772
+ function electWinner(comments, nowMs, ttlMs) {
773
+ const released = new Set;
774
+ for (const c of comments) {
775
+ const token = parseRelease(c.body);
776
+ if (token)
777
+ released.add(token);
778
+ }
779
+ const live = [];
780
+ for (const c of comments) {
781
+ const claim = parseClaim(c.body);
782
+ if (!claim)
783
+ continue;
784
+ if (released.has(claim.token))
785
+ continue;
786
+ if (nowMs - claim.ts > ttlMs)
787
+ continue;
788
+ live.push({ ...claim, commentId: c.id });
789
+ }
790
+ live.sort((a, b) => a.ts - b.ts || compareCommentIds(a.commentId, b.commentId));
791
+ return live[0] ?? null;
792
+ }
793
+ var realClaimDeps = {
794
+ now: () => Date.now(),
795
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
796
+ randomSuffix: () => Math.random().toString(36).slice(2, 10)
797
+ };
798
+ async function claimItem(adapter, id, policy, deps = realClaimDeps) {
799
+ const ttlMs = policy.ttlMs ?? CLAIM_TTL_MS;
800
+ const settleMs = policy.settleMs ?? SETTLE_MS;
801
+ const me = await adapter.whoami();
802
+ const item = await adapter.get(id);
803
+ if (item.state !== "open")
804
+ return { ok: false, id, reason: `#${id} is ${item.state}` };
805
+ if (item.labels.includes(policy.memoryLabel)) {
806
+ return { ok: false, id, reason: `#${id} is the memory issue and cannot be claimed` };
807
+ }
808
+ if (item.labels.includes(policy.inProgressLabel)) {
809
+ return {
810
+ ok: false,
811
+ id,
812
+ reason: `#${id} already claimed (${policy.inProgressLabel} label set)`
813
+ };
814
+ }
815
+ if (item.assignees.length > 0) {
816
+ const who = item.assignees.map((a) => `@${a.username}`).join(", ");
817
+ return { ok: false, id, reason: `#${id} already assigned to ${who}` };
818
+ }
819
+ const token = `${deps.now()}-${deps.randomSuffix()}`;
820
+ const at = new Date(deps.now()).toISOString();
821
+ await adapter.comment(id, `${CLAIM_MARK} agent=${me.username} token=${token} at=${at}`);
822
+ await deps.sleep(settleMs);
823
+ const comments = await adapter.listComments(id);
824
+ const winner = electWinner(comments, deps.now(), ttlMs);
825
+ if (!winner || winner.token !== token) {
826
+ await adapter.comment(id, `${RELEASE_MARK} token=${token} reason=lost-race`);
827
+ return {
828
+ ok: false,
829
+ id,
830
+ reason: `#${id} lost race to @${winner?.agent ?? "?"} (token=${winner?.token ?? "?"})`
831
+ };
832
+ }
833
+ await adapter.update(id, { assigneeIds: [me.id], addLabels: [policy.inProgressLabel] });
834
+ return { ok: true, id, agent: me.username, token };
835
+ }
836
+ async function releaseItem(adapter, id, policy) {
837
+ const me = await adapter.whoami();
838
+ await adapter.update(id, { assigneeIds: [], removeLabels: [policy.inProgressLabel] });
839
+ const comments = await adapter.listComments(id);
840
+ const released = new Set;
841
+ for (const c of comments) {
842
+ const token = parseRelease(c.body);
843
+ if (token)
844
+ released.add(token);
845
+ }
846
+ let cleared = 0;
847
+ for (const c of comments) {
848
+ const claim = parseClaim(c.body);
849
+ if (!claim || released.has(claim.token))
850
+ continue;
851
+ await adapter.comment(id, `${RELEASE_MARK} token=${claim.token} agent=${me.username} reason=manual-release`);
852
+ cleared++;
853
+ }
854
+ return { id, cleared };
855
+ }
856
+
857
+ // src/core/ids.ts
858
+ function normalizeId(input) {
859
+ const id = (input ?? "").trim().replace(/^#/, "");
860
+ if (!id)
861
+ throw new UsageError("an item id is required (e.g. 42 or #42)");
862
+ return id;
863
+ }
864
+
865
+ // src/core/memory.ts
866
+ var MEM_MARK = "\uD83D\uDCCC MEMORY";
867
+ var FORGET_MARK = "\uD83D\uDDD1 FORGET";
868
+ function parseMemory(body) {
869
+ if (!body.startsWith(MEM_MARK))
870
+ return null;
871
+ const m = body.match(/^\S+\s+\S+\s+key=(\S+)\s+([\s\S]*)$/);
872
+ if (!m)
873
+ return null;
874
+ return { key: m[1], text: m[2].trim() };
875
+ }
876
+ function parseForget(body) {
877
+ if (!body.startsWith(FORGET_MARK))
878
+ return null;
879
+ return body.match(/key=(\S+)/)?.[1] ?? null;
880
+ }
881
+ function resolveMemories(comments, filter) {
882
+ const forgotten = new Set;
883
+ for (const c of comments) {
884
+ const key = parseForget(c.body);
885
+ if (key)
886
+ forgotten.add(key);
887
+ }
888
+ const latest = new Map;
889
+ for (const c of comments) {
890
+ const m = parseMemory(c.body);
891
+ if (!m || forgotten.has(m.key))
892
+ continue;
893
+ latest.set(m.key, { key: m.key, text: m.text, ts: c.createdAt });
894
+ }
895
+ const all = [...latest.values()];
896
+ if (!filter)
897
+ return all;
898
+ const f = filter.toLowerCase();
899
+ return all.filter((m) => m.key.toLowerCase().includes(f) || m.text.toLowerCase().includes(f));
900
+ }
901
+ async function ensureMemoryItem(adapter, cache, policy) {
902
+ const cached = cache.metaGet("memory_id");
903
+ if (cached)
904
+ return cached;
905
+ let found = cache.allItems().find((i) => i.title === policy.title && i.state === "open");
906
+ if (!found && adapter.capabilities().serverSearch) {
907
+ const hits = await adapter.searchRemote({ text: policy.title, state: "open" });
908
+ found = hits.find((i) => i.title === policy.title);
909
+ }
910
+ const item = found ?? await adapter.create({
911
+ title: policy.title,
912
+ description: "Persistent project memories. Each note is a memory keyed by `key=<name>`. Do not close.",
913
+ labels: [policy.label]
914
+ });
915
+ cache.metaSet("memory_id", item.id);
916
+ return item.id;
917
+ }
918
+ async function remember(adapter, cache, policy, key, text) {
919
+ if (/\s/.test(key))
920
+ throw new DomainError("memory key cannot contain whitespace");
921
+ const id = await ensureMemoryItem(adapter, cache, policy);
922
+ await adapter.comment(id, `${MEM_MARK} key=${key} ${text}`);
923
+ return id;
924
+ }
925
+ async function forget(adapter, cache, policy, key) {
926
+ const id = await ensureMemoryItem(adapter, cache, policy);
927
+ await adapter.comment(id, `${FORGET_MARK} key=${key}`);
928
+ return id;
929
+ }
930
+ async function listMemories(adapter, cache, policy, filter) {
931
+ const id = await ensureMemoryItem(adapter, cache, policy);
932
+ const comments = await adapter.listComments(id);
933
+ return resolveMemories(comments, filter);
934
+ }
935
+
936
+ // src/core/ready.ts
937
+ function computeReady(items, policy) {
938
+ const stateById = new Map(items.map((i) => [i.id, i.state]));
939
+ return items.filter((item) => {
940
+ if (item.state !== "open")
941
+ return false;
942
+ if (policy.parent && item.parent !== policy.parent)
943
+ return false;
944
+ if (item.assignees.length > 0)
945
+ return false;
946
+ if (item.labels.includes(policy.inProgressLabel))
947
+ return false;
948
+ if (item.labels.includes(policy.memoryLabel))
949
+ return false;
950
+ return item.blockedBy.every((blocker) => stateById.get(blocker) !== "open");
951
+ });
952
+ }
953
+ function computeEpicStatus(parent, children) {
954
+ if (children.length === 0)
955
+ return null;
956
+ const closed = children.filter((c) => c.state === "closed").length;
957
+ return {
958
+ parent,
959
+ total: children.length,
960
+ open: children.length - closed,
961
+ closed,
962
+ pctClosed: Math.round(closed / children.length * 100)
963
+ };
964
+ }
965
+
966
+ // src/core/search.ts
967
+ var normalizeUser = (u) => u.replace(/^@/, "").toLowerCase();
968
+ function searchLocal(cache, q) {
969
+ let items = cache.allItems();
970
+ if (q.text?.trim()) {
971
+ const ids = cache.searchText(q.text);
972
+ items = items.filter((i) => ids.has(i.id));
973
+ }
974
+ const state = q.state ?? "all";
975
+ if (state !== "all")
976
+ items = items.filter((i) => i.state === state);
977
+ if (q.parent)
978
+ items = items.filter((i) => i.parent === q.parent);
979
+ if (q.label)
980
+ items = items.filter((i) => i.labels.includes(q.label));
981
+ if (q.assignee) {
982
+ const want = normalizeUser(q.assignee);
983
+ items = items.filter((i) => i.assignees.some((a) => a.username.toLowerCase() === want));
984
+ }
985
+ if (q.author) {
986
+ const want = normalizeUser(q.author);
987
+ items = items.filter((i) => i.author !== null && i.author.username.toLowerCase() === want);
988
+ }
989
+ return items;
990
+ }
991
+
992
+ // src/core/sync.ts
993
+ async function syncCache(adapter, cache, nowMs = Date.now()) {
994
+ const items = await adapter.fetchAll();
995
+ cache.replaceAll(items, adapter.provider);
996
+ cache.markSynced(nowMs);
997
+ return { count: items.length };
998
+ }
999
+ async function ensureFresh(adapter, cache, staleMs, nowMs = Date.now(), onSync) {
1000
+ if (!cache.isStale(nowMs, staleMs))
1001
+ return false;
1002
+ onSync?.();
1003
+ await syncCache(adapter, cache, nowMs);
1004
+ return true;
1005
+ }
1006
+
1007
+ // src/cli/help.ts
1008
+ var HELP = `tracker \u2014 multi-backend issue tracking for humans and AI agents
1009
+
1010
+ usage: tracker <command> [args]
1011
+
1012
+ read commands (local cache, auto-sync when stale; all accept --json):
1013
+ sync refresh the local cache from the provider
1014
+ ready [--parent <id>] open + unblocked + unassigned + not in-progress
1015
+ show <id> full detail for one item
1016
+ children <id> direct children of an item
1017
+ epic-status <id> closed/total progress over an item's children
1018
+ search [text] [filters] full-text + filters; --remote for server-side search
1019
+ comments <id> list an item's comments (oldest first)
1020
+ users <query> resolve usernames/names to user ids
1021
+ whoami the authenticated user
1022
+ memories [filter] list project memories
1023
+ doctor verify config, token, connectivity, capabilities
1024
+
1025
+ write commands:
1026
+ create -t <title> [-d <desc>] [--parent <id>] [--epic <id>] [-l a,b]
1027
+ [--blocked-by 1,2] [-m <milestone>] [--json]
1028
+ claim <id> race-safe claim (assigns you + in-progress label)
1029
+ release <id> clear assignee/label, tombstone live claim tokens
1030
+ close <id> [--reason <text>]
1031
+ comment <id> <text> post a comment on an item
1032
+ dep <id> --blocked-by <other> | --blocks <other>
1033
+ parent <child-id> <parent-id>
1034
+ remember <key> <text> store a project memory (key has no whitespace)
1035
+ forget <key> hide a memory key
1036
+
1037
+ search filters:
1038
+ --assignee <user> --author <user> --label <l> --state open|closed|all
1039
+ --parent <id> --remote (text query optional when any filter is given)
1040
+
1041
+ examples:
1042
+ tracker ready --parent 12 --json
1043
+ tracker search --assignee mehmet # works with no text query
1044
+ tracker search "login bug" --state open
1045
+ tracker create -t "Fix login" --parent 12 --blocked-by 7
1046
+ tracker claim 42 && echo mine || echo taken
1047
+ tracker close 42 --reason "fixed in MR !17"
1048
+
1049
+ exit codes: 0 ok \xB7 2 domain failure (lost race, refused claim, not found) \xB7 1 usage/config error`;
1050
+ var PER_COMMAND = {
1051
+ sync: `usage: tracker sync
1052
+
1053
+ Full refresh of the local cache (issues + hierarchy + dependency links).`,
1054
+ ready: `usage: tracker ready [--parent <id>] [--json]
1055
+
1056
+ Items that are open, not blocked by any open item, unassigned, not in-progress,
1057
+ and not the memory issue.
1058
+
1059
+ examples:
1060
+ tracker ready
1061
+ tracker ready --parent 12 --json`,
1062
+ show: `usage: tracker show <id> [--json]
1063
+
1064
+ example: tracker show 42`,
1065
+ children: `usage: tracker children <id> [--json]
1066
+
1067
+ example: tracker children 12`,
1068
+ "epic-status": `usage: tracker epic-status <id> [--json]
1069
+
1070
+ example: tracker epic-status 12`,
1071
+ claim: `usage: tracker claim <id>
1072
+
1073
+ Race-safe claim: posts a claim note, waits a settle window, re-reads all notes,
1074
+ oldest live claim wins. Loser exits 2. Winner gets assigned + in-progress label.
1075
+
1076
+ example: tracker claim 42 && start-work || pick-another`,
1077
+ release: `usage: tracker release <id>
1078
+
1079
+ Clears assignee + in-progress label and tombstones all live claim tokens.`,
1080
+ create: `usage: tracker create -t <title> [-d <desc>] [--parent <id>] [--epic <id>]
1081
+ [-l label1,label2] [--blocked-by <id1,id2>] [-m <milestone>] [--json]
1082
+
1083
+ examples:
1084
+ tracker create -t "Ship login" -d "OAuth via Keycloak" -l backend,auth
1085
+ tracker create -t "Subtask" --parent 12 --blocked-by 7,9`,
1086
+ close: `usage: tracker close <id> [--reason <text>]
1087
+
1088
+ example: tracker close 42 --reason "fixed in MR !17"`,
1089
+ comment: `usage: tracker comment <id> <text>
1090
+
1091
+ Posts a comment on the item. Everything after the id is joined into one
1092
+ comment body, so quoting multi-word text is optional.
1093
+
1094
+ example: tracker comment 42 "blocked on the design review, see thread"`,
1095
+ comments: `usage: tracker comments <id> [--json]
1096
+
1097
+ Lists the item's comments oldest-first (system notes are filtered out). Claim
1098
+ notes (\uD83D\uDD12/\uD83D\uDD13) and memory entries (\uD83D\uDCCC) appear here too \u2014 useful for debugging.
1099
+
1100
+ example: tracker comments 42 --json`,
1101
+ dep: `usage: tracker dep <id> --blocked-by <other> | --blocks <other>
1102
+
1103
+ examples:
1104
+ tracker dep 42 --blocked-by 7 # 7 blocks 42
1105
+ tracker dep 42 --blocks 50 # 42 blocks 50`,
1106
+ parent: `usage: tracker parent <child-id> <parent-id>
1107
+
1108
+ example: tracker parent 42 12`,
1109
+ remember: `usage: tracker remember <key> <text>
1110
+
1111
+ example: tracker remember deploy-cmd "bun run deploy:prod"`,
1112
+ forget: `usage: tracker forget <key>
1113
+
1114
+ example: tracker forget deploy-cmd`,
1115
+ memories: `usage: tracker memories [filter] [--json]
1116
+
1117
+ example: tracker memories deploy`,
1118
+ search: `usage: tracker search [text] [--assignee <user>] [--author <user>] [--label <l>]
1119
+ [--state open|closed|all] [--parent <id>] [--remote] [--json]
1120
+
1121
+ Local-first: full-text (FTS5) over cached title+description plus structured
1122
+ filters. Text is optional when at least one filter is given. --remote runs the
1123
+ provider's server-side search instead (fresher, slower, no --parent).
1124
+
1125
+ examples:
1126
+ tracker search --assignee mehmet
1127
+ tracker search --state closed # state alone is a valid filter
1128
+ tracker search "payment timeout" --label backend --state open
1129
+ tracker search checkout --remote --json`,
1130
+ users: `usage: tracker users <query> [--json]
1131
+
1132
+ example: tracker users mehmet`,
1133
+ whoami: "usage: tracker whoami [--json]",
1134
+ doctor: `usage: tracker doctor [--json]
1135
+
1136
+ Verifies config, token, REST/GraphQL connectivity, capabilities, cache.`
1137
+ };
1138
+ function commandHelp(cmd) {
1139
+ return PER_COMMAND[cmd] ?? HELP;
1140
+ }
1141
+
1142
+ // src/cli/output.ts
1143
+ function itemToJson(item) {
1144
+ const { raw: _raw, ...publicItem } = item;
1145
+ return publicItem;
1146
+ }
1147
+ function printJson(value) {
1148
+ console.log(JSON.stringify(value, null, 2));
1149
+ }
1150
+ var formatUsers = (users) => users.map((u) => `@${u.username}`).join(",");
1151
+ function printItemLines(items) {
1152
+ for (const item of items) {
1153
+ const parts = [`#${item.id}`, `[${item.state}]`, item.title];
1154
+ if (item.assignees.length)
1155
+ parts.push(`(${formatUsers(item.assignees)})`);
1156
+ if (item.labels.length)
1157
+ parts.push(`{${item.labels.join(", ")}}`);
1158
+ console.log(parts.join("\t"));
1159
+ }
1160
+ }
1161
+ function printItemDetail(item, blocks) {
1162
+ console.log(`#${item.id} ${item.title} [${item.state}]`);
1163
+ console.log(`url: ${item.url}`);
1164
+ console.log(`kind: ${item.kind}`);
1165
+ console.log(`labels: ${item.labels.join(", ") || "-"}`);
1166
+ console.log(`assignees: ${formatUsers(item.assignees) || "-"}`);
1167
+ console.log(`author: ${item.author ? `@${item.author.username}` : "-"}`);
1168
+ console.log(`parent: ${item.parent ? `#${item.parent}` : "-"}`);
1169
+ console.log(`blocked by: ${item.blockedBy.map((b) => `#${b}`).join(", ") || "-"}`);
1170
+ console.log(`blocks: ${blocks.map((b) => `#${b}`).join(", ") || "-"}`);
1171
+ console.log(`updated: ${item.updatedAt}`);
1172
+ if (item.description) {
1173
+ console.log("---");
1174
+ console.log(item.description);
1175
+ }
1176
+ }
1177
+ function printUsers(users) {
1178
+ for (const u of users)
1179
+ console.log(`${u.id} @${u.username} ${u.name ?? ""}`);
1180
+ }
1181
+
1182
+ // src/cli/index.ts
1183
+ function parseArgs(args, spec, aliases = {}) {
1184
+ const flags = new Map;
1185
+ const positionals = [];
1186
+ for (let i = 0;i < args.length; i++) {
1187
+ const arg = args[i];
1188
+ if (!arg.startsWith("-") || arg === "-") {
1189
+ positionals.push(arg);
1190
+ continue;
1191
+ }
1192
+ const name = aliases[arg] ?? arg;
1193
+ const kind = spec[name];
1194
+ if (!kind)
1195
+ throw new UsageError(`unknown flag ${arg} (see tracker help)`);
1196
+ if (kind === "bool") {
1197
+ flags.set(name, true);
1198
+ } else {
1199
+ const value = args[++i];
1200
+ if (value === undefined)
1201
+ throw new UsageError(`flag ${arg} needs a value`);
1202
+ flags.set(name, value);
1203
+ }
1204
+ }
1205
+ return { flags, positionals };
1206
+ }
1207
+ var str = (p, name) => {
1208
+ const v = p.flags.get(name);
1209
+ return typeof v === "string" ? v : undefined;
1210
+ };
1211
+ function buildCtx() {
1212
+ const config = loadConfig();
1213
+ const { token } = resolveToken(config);
1214
+ const adapter = new GitLabAdapter({
1215
+ baseUrl: config.gitlab.base_url,
1216
+ project: config.gitlab.project,
1217
+ token,
1218
+ nativeBlocking: config.gitlab.native_blocking
1219
+ });
1220
+ const cachePath = resolve3(config.rootDir, config.cache.path);
1221
+ if (!existsSync3(cachePath)) {
1222
+ const guard = guardCacheIgnored(config.rootDir, cachePath);
1223
+ if (guard.action === "added" || guard.action === "warn")
1224
+ console.error(`(${guard.detail})`);
1225
+ }
1226
+ const cache = new Cache(cachePath);
1227
+ return { config, cache, adapter };
1228
+ }
1229
+ var claimPolicy = (config) => ({
1230
+ inProgressLabel: config.labels.in_progress,
1231
+ memoryLabel: config.memory.label
1232
+ });
1233
+ async function freshen(ctx) {
1234
+ await ensureFresh(ctx.adapter, ctx.cache, ctx.config.cache.stale_minutes * 60000, Date.now(), () => console.error("(cache stale, syncing...)"));
1235
+ }
1236
+ var invalidate = (ctx) => ctx.cache.metaSet("last_sync_at", "0");
1237
+ var memoryPolicy = (ctx) => {
1238
+ if (!ctx.config.memory.enabled) {
1239
+ throw new UsageError("the memory feature is disabled in tracker.config.json");
1240
+ }
1241
+ return { title: ctx.config.memory.title, label: ctx.config.memory.label };
1242
+ };
1243
+ async function cmdSync(ctx) {
1244
+ const t0 = performance.now();
1245
+ const { count } = await syncCache(ctx.adapter, ctx.cache);
1246
+ const ms = Math.round(performance.now() - t0);
1247
+ console.log(`synced ${count} items in ${ms}ms \u2192 ${resolve3(ctx.config.rootDir, ctx.config.cache.path)}`);
1248
+ }
1249
+ async function cmdReady(ctx, args) {
1250
+ await freshen(ctx);
1251
+ const items = computeReady(ctx.cache.allItems(), {
1252
+ ...claimPolicy(ctx.config),
1253
+ parent: str(args, "--parent") ? normalizeId(str(args, "--parent")) : null
1254
+ });
1255
+ if (args.flags.get("--json"))
1256
+ return printJson(items.map(itemToJson));
1257
+ if (items.length === 0)
1258
+ return console.error("(no ready items)");
1259
+ printItemLines(items);
1260
+ }
1261
+ async function cmdShow(ctx, args) {
1262
+ await freshen(ctx);
1263
+ const id = normalizeId(args.positionals[0]);
1264
+ const item = ctx.cache.getItem(id);
1265
+ if (!item)
1266
+ throw new DomainError(`#${id} not found in cache (try: tracker sync)`);
1267
+ const blocks = ctx.cache.allItems().filter((i) => i.blockedBy.includes(id)).map((i) => i.id);
1268
+ if (args.flags.get("--json"))
1269
+ return printJson({ ...itemToJson(item), blocks });
1270
+ printItemDetail(item, blocks);
1271
+ }
1272
+ async function cmdChildren(ctx, args) {
1273
+ await freshen(ctx);
1274
+ const id = normalizeId(args.positionals[0]);
1275
+ const children = ctx.cache.childrenOf(id);
1276
+ if (args.flags.get("--json"))
1277
+ return printJson(children.map(itemToJson));
1278
+ if (children.length === 0)
1279
+ return console.error(`(no children of #${id})`);
1280
+ printItemLines(children);
1281
+ }
1282
+ async function cmdEpicStatus(ctx, args) {
1283
+ await freshen(ctx);
1284
+ const id = normalizeId(args.positionals[0]);
1285
+ const status = computeEpicStatus(id, ctx.cache.childrenOf(id));
1286
+ if (args.flags.get("--json")) {
1287
+ return printJson(status ?? { parent: id, total: 0, open: 0, closed: 0, pctClosed: 0 });
1288
+ }
1289
+ if (!status)
1290
+ return console.error(`(no children of #${id})`);
1291
+ console.log(`#${status.parent} ${status.closed}/${status.total} closed (${status.pctClosed}%) open=${status.open}`);
1292
+ }
1293
+ async function cmdClaim(ctx, args) {
1294
+ const id = normalizeId(args.positionals[0]);
1295
+ const result = await claimItem(ctx.adapter, id, claimPolicy(ctx.config));
1296
+ invalidate(ctx);
1297
+ if (!result.ok)
1298
+ throw new DomainError(result.reason);
1299
+ console.log(`#${id} claimed by @${result.agent} (token=${result.token})`);
1300
+ }
1301
+ async function cmdRelease(ctx, args) {
1302
+ const id = normalizeId(args.positionals[0]);
1303
+ const { cleared } = await releaseItem(ctx.adapter, id, claimPolicy(ctx.config));
1304
+ invalidate(ctx);
1305
+ console.log(`#${id} released (${cleared} live claim${cleared === 1 ? "" : "s"} cleared)`);
1306
+ }
1307
+ async function cmdCreate(ctx, args) {
1308
+ const title = str(args, "--title");
1309
+ if (!title)
1310
+ throw new UsageError(commandHelp("create"));
1311
+ const parent = str(args, "--parent");
1312
+ const item = await ctx.adapter.create({
1313
+ title,
1314
+ description: str(args, "--description") ?? "",
1315
+ parent: parent ? normalizeId(parent) : null,
1316
+ labels: str(args, "--label")?.split(",").map((s) => s.trim()).filter(Boolean),
1317
+ milestone: str(args, "--milestone"),
1318
+ epicId: str(args, "--epic")
1319
+ });
1320
+ const blockers = (str(args, "--blocked-by") ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(normalizeId);
1321
+ for (const blocker of blockers)
1322
+ await ctx.adapter.link(blocker, item.id);
1323
+ invalidate(ctx);
1324
+ const withDeps = { ...item, blockedBy: [...new Set([...item.blockedBy, ...blockers])] };
1325
+ if (args.flags.get("--json"))
1326
+ return printJson(itemToJson(withDeps));
1327
+ console.log(`#${item.id} ${item.title} ${item.url}`);
1328
+ }
1329
+ async function cmdClose(ctx, args) {
1330
+ const id = normalizeId(args.positionals[0]);
1331
+ const reason = str(args, "--reason");
1332
+ if (reason)
1333
+ await ctx.adapter.comment(id, `closed: ${reason}`);
1334
+ await ctx.adapter.update(id, {
1335
+ assigneeIds: [],
1336
+ removeLabels: [ctx.config.labels.in_progress]
1337
+ });
1338
+ await ctx.adapter.transition(id, "closed");
1339
+ invalidate(ctx);
1340
+ console.log(`#${id} closed`);
1341
+ }
1342
+ async function cmdDep(ctx, args) {
1343
+ const id = normalizeId(args.positionals[0]);
1344
+ const blockedBy = str(args, "--blocked-by");
1345
+ const blocks = str(args, "--blocks");
1346
+ if (blockedBy === undefined === (blocks === undefined)) {
1347
+ throw new UsageError(commandHelp("dep"));
1348
+ }
1349
+ const [blocker, blocked] = blockedBy ? [normalizeId(blockedBy), id] : [id, normalizeId(blocks)];
1350
+ await ctx.adapter.link(blocker, blocked);
1351
+ invalidate(ctx);
1352
+ console.log(`#${blocker} blocks #${blocked}`);
1353
+ }
1354
+ async function cmdParent(ctx, args) {
1355
+ const child = normalizeId(args.positionals[0]);
1356
+ const parent = normalizeId(args.positionals[1]);
1357
+ await ctx.adapter.setParent(child, parent);
1358
+ invalidate(ctx);
1359
+ console.log(`#${child} parent=#${parent}`);
1360
+ }
1361
+ async function cmdRemember(ctx, args) {
1362
+ const [key, ...text] = args.positionals;
1363
+ if (!key || text.length === 0)
1364
+ throw new UsageError(commandHelp("remember"));
1365
+ const id = await remember(ctx.adapter, ctx.cache, memoryPolicy(ctx), key, text.join(" "));
1366
+ console.log(`remembered ${key} on #${id}`);
1367
+ }
1368
+ async function cmdForget(ctx, args) {
1369
+ const key = args.positionals[0];
1370
+ if (!key)
1371
+ throw new UsageError(commandHelp("forget"));
1372
+ await forget(ctx.adapter, ctx.cache, memoryPolicy(ctx), key);
1373
+ console.log(`forgot ${key}`);
1374
+ }
1375
+ async function cmdMemories(ctx, args) {
1376
+ const memories = await listMemories(ctx.adapter, ctx.cache, memoryPolicy(ctx), args.positionals[0]);
1377
+ if (args.flags.get("--json"))
1378
+ return printJson(memories);
1379
+ if (memories.length === 0)
1380
+ return console.error("(no memories)");
1381
+ for (const m of memories)
1382
+ console.log(`${m.key} ${m.ts} ${m.text}`);
1383
+ }
1384
+ async function cmdComment(ctx, args) {
1385
+ const [idArg, ...textParts] = args.positionals;
1386
+ const id = normalizeId(idArg);
1387
+ const body = textParts.join(" ").trim();
1388
+ if (!body)
1389
+ throw new UsageError(commandHelp("comment"));
1390
+ await ctx.adapter.comment(id, body);
1391
+ console.log(`commented on #${id}`);
1392
+ }
1393
+ async function cmdComments(ctx, args) {
1394
+ const id = normalizeId(args.positionals[0]);
1395
+ const comments = await ctx.adapter.listComments(id);
1396
+ if (args.flags.get("--json"))
1397
+ return printJson(comments);
1398
+ if (comments.length === 0)
1399
+ return console.error(`(no comments on #${id})`);
1400
+ for (const c of comments) {
1401
+ console.log(`@${c.author.username} ${c.createdAt}`);
1402
+ console.log(c.body);
1403
+ console.log("");
1404
+ }
1405
+ }
1406
+ async function cmdSearch(ctx, args) {
1407
+ const query = {
1408
+ text: args.positionals.join(" ") || undefined,
1409
+ assignee: str(args, "--assignee"),
1410
+ author: str(args, "--author"),
1411
+ label: str(args, "--label"),
1412
+ state: str(args, "--state") ?? "all",
1413
+ parent: str(args, "--parent") ? normalizeId(str(args, "--parent")) : undefined
1414
+ };
1415
+ if (!["open", "closed", "all"].includes(query.state)) {
1416
+ throw new UsageError("--state must be open, closed or all");
1417
+ }
1418
+ const hasFilter = query.assignee || query.author || query.label || query.parent || args.flags.has("--state");
1419
+ if (!query.text && !hasFilter) {
1420
+ throw new UsageError(commandHelp("search"));
1421
+ }
1422
+ let items;
1423
+ if (args.flags.get("--remote")) {
1424
+ if (query.parent)
1425
+ throw new UsageError("--parent is local-only (remote search cannot filter by parent)");
1426
+ items = await ctx.adapter.searchRemote(query);
1427
+ } else {
1428
+ await freshen(ctx);
1429
+ items = searchLocal(ctx.cache, query);
1430
+ }
1431
+ if (args.flags.get("--json"))
1432
+ return printJson(items.map(itemToJson));
1433
+ if (items.length === 0)
1434
+ return console.error("(no matches)");
1435
+ printItemLines(items);
1436
+ }
1437
+ async function cmdUsers(ctx, args) {
1438
+ const query = args.positionals.join(" ");
1439
+ if (!query)
1440
+ throw new UsageError(commandHelp("users"));
1441
+ const users = await ctx.adapter.resolveUsers(query);
1442
+ if (args.flags.get("--json"))
1443
+ return printJson(users);
1444
+ if (users.length === 0)
1445
+ return console.error("(no users matched)");
1446
+ printUsers(users);
1447
+ }
1448
+ async function cmdWhoami(ctx, args) {
1449
+ const me = await ctx.adapter.whoami();
1450
+ if (args.flags.get("--json"))
1451
+ return printJson(me);
1452
+ console.log(`@${me.username} (id=${me.id})${me.name ? ` \u2014 ${me.name}` : ""}`);
1453
+ }
1454
+ async function cmdDoctor(args) {
1455
+ const checks = [];
1456
+ let config = null;
1457
+ try {
1458
+ config = loadConfig();
1459
+ checks.push({ name: "config", status: "ok", detail: `${config.rootDir}/tracker.config.json` });
1460
+ } catch (e) {
1461
+ checks.push({
1462
+ name: "config",
1463
+ status: "fail",
1464
+ detail: e.message,
1465
+ fix: "create tracker.config.json (see README) in the project root"
1466
+ });
1467
+ }
1468
+ let ctx = null;
1469
+ if (config) {
1470
+ try {
1471
+ const { source } = resolveToken(config);
1472
+ checks.push({ name: "token", status: "ok", detail: `found via ${source}` });
1473
+ ctx = buildCtx();
1474
+ } catch (e) {
1475
+ checks.push({
1476
+ name: "token",
1477
+ status: "fail",
1478
+ detail: e.message,
1479
+ fix: `export ${config.gitlab.token_env[0]} or add it to ${config.rootDir}/.env`
1480
+ });
1481
+ }
1482
+ }
1483
+ if (ctx) {
1484
+ try {
1485
+ const me = await ctx.adapter.whoami();
1486
+ checks.push({ name: "auth", status: "ok", detail: `authenticated as @${me.username}` });
1487
+ } catch (e) {
1488
+ checks.push({
1489
+ name: "auth",
1490
+ status: "fail",
1491
+ detail: e.message,
1492
+ fix: "check the token's validity and `api` scope, and the base_url"
1493
+ });
1494
+ }
1495
+ try {
1496
+ const project = await ctx.adapter.probeProject();
1497
+ checks.push({ name: "project", status: "ok", detail: project.name });
1498
+ } catch (e) {
1499
+ checks.push({
1500
+ name: "project",
1501
+ status: "fail",
1502
+ detail: e.message,
1503
+ fix: `check gitlab.project ("${config?.gitlab.project}") and that the token can read it`
1504
+ });
1505
+ }
1506
+ try {
1507
+ await ctx.adapter.probeGraphql();
1508
+ checks.push({
1509
+ name: "graphql",
1510
+ status: "ok",
1511
+ detail: "work-item API reachable (Task type found)"
1512
+ });
1513
+ } catch (e) {
1514
+ checks.push({
1515
+ name: "graphql",
1516
+ status: "fail",
1517
+ detail: e.message,
1518
+ fix: "hierarchy commands (create --parent, parent) need the GraphQL work-item API"
1519
+ });
1520
+ }
1521
+ checks.push({
1522
+ name: "blocking-links",
1523
+ status: "ok",
1524
+ detail: `native_blocking=${config?.gitlab.native_blocking} (configured; tier is not verifiable without mutating). Fallback: Tracker-Blocked-By description trailers.`
1525
+ });
1526
+ const last = ctx.cache.lastSyncAt();
1527
+ checks.push({
1528
+ name: "cache",
1529
+ status: "ok",
1530
+ detail: last ? `${ctx.cache.count()} items, synced ${Math.round((Date.now() - last) / 60000)}m ago` : "empty (run: tracker sync)"
1531
+ });
1532
+ const cachePath = resolve3(config.rootDir, config.cache.path);
1533
+ const gitRoot = findGitRoot(config.rootDir);
1534
+ if (gitRoot) {
1535
+ const ignored = isGitIgnored(gitRoot, cachePath);
1536
+ checks.push({
1537
+ name: "cache-ignored",
1538
+ status: ignored === false ? "warn" : "ok",
1539
+ detail: ignored === null ? "git not available, cannot verify" : ignored ? "cache path is git-ignored" : "cache path is NOT git-ignored",
1540
+ ...ignored === false ? { fix: `add "${config.cache.path.replace(/\/[^/]*$/, "")}/" to ${gitRoot}/.gitignore` } : {}
1541
+ });
1542
+ }
1543
+ }
1544
+ const failed = checks.some((c) => c.status === "fail");
1545
+ if (args.flags.get("--json")) {
1546
+ printJson({ ok: !failed, checks });
1547
+ } else {
1548
+ for (const c of checks) {
1549
+ const icon = c.status === "ok" ? "\u2713" : c.status === "warn" ? "!" : "\u2717";
1550
+ console.log(`${icon} ${c.name.padEnd(15)} ${c.detail}`);
1551
+ if (c.fix && c.status !== "ok")
1552
+ console.log(` fix: ${c.fix}`);
1553
+ }
1554
+ console.log(failed ? `
1555
+ problems found.` : `
1556
+ all good.`);
1557
+ }
1558
+ return failed ? 1 : 0;
1559
+ }
1560
+ var VALUE_FLAGS = {
1561
+ ready: { "--parent": "value", "--json": "bool" },
1562
+ show: { "--json": "bool" },
1563
+ children: { "--json": "bool" },
1564
+ "epic-status": { "--json": "bool" },
1565
+ create: {
1566
+ "--title": "value",
1567
+ "--description": "value",
1568
+ "--parent": "value",
1569
+ "--epic": "value",
1570
+ "--label": "value",
1571
+ "--blocked-by": "value",
1572
+ "--milestone": "value",
1573
+ "--json": "bool"
1574
+ },
1575
+ close: { "--reason": "value" },
1576
+ dep: { "--blocked-by": "value", "--blocks": "value" },
1577
+ search: {
1578
+ "--assignee": "value",
1579
+ "--author": "value",
1580
+ "--label": "value",
1581
+ "--state": "value",
1582
+ "--parent": "value",
1583
+ "--remote": "bool",
1584
+ "--json": "bool"
1585
+ },
1586
+ users: { "--json": "bool" },
1587
+ whoami: { "--json": "bool" },
1588
+ doctor: { "--json": "bool" },
1589
+ memories: { "--json": "bool" },
1590
+ comment: {},
1591
+ comments: { "--json": "bool" },
1592
+ sync: {},
1593
+ claim: {},
1594
+ release: {},
1595
+ parent: {},
1596
+ remember: {},
1597
+ forget: {}
1598
+ };
1599
+ var ALIASES = {
1600
+ create: {
1601
+ "-t": "--title",
1602
+ "-d": "--description",
1603
+ "-l": "--label",
1604
+ "--labels": "--label",
1605
+ "-m": "--milestone"
1606
+ },
1607
+ close: { "-r": "--reason" }
1608
+ };
1609
+ async function run(argv) {
1610
+ const [cmd, ...rest] = argv;
1611
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
1612
+ console.log(HELP);
1613
+ return cmd ? 0 : 1;
1614
+ }
1615
+ const spec = VALUE_FLAGS[cmd];
1616
+ if (!spec) {
1617
+ console.error(`unknown command: ${cmd}
1618
+
1619
+ ${HELP}`);
1620
+ return 1;
1621
+ }
1622
+ if (rest.includes("--help") || rest.includes("-h")) {
1623
+ console.log(commandHelp(cmd));
1624
+ return 0;
1625
+ }
1626
+ const args = parseArgs(rest, spec, ALIASES[cmd] ?? {});
1627
+ if (cmd === "doctor")
1628
+ return cmdDoctor(args);
1629
+ const ctx = buildCtx();
1630
+ const handlers = {
1631
+ sync: cmdSync,
1632
+ ready: cmdReady,
1633
+ show: cmdShow,
1634
+ children: cmdChildren,
1635
+ "epic-status": cmdEpicStatus,
1636
+ claim: cmdClaim,
1637
+ release: cmdRelease,
1638
+ create: cmdCreate,
1639
+ close: cmdClose,
1640
+ dep: cmdDep,
1641
+ parent: cmdParent,
1642
+ remember: cmdRemember,
1643
+ forget: cmdForget,
1644
+ memories: cmdMemories,
1645
+ comment: cmdComment,
1646
+ comments: cmdComments,
1647
+ search: cmdSearch,
1648
+ users: cmdUsers,
1649
+ whoami: cmdWhoami
1650
+ };
1651
+ await handlers[cmd](ctx, args);
1652
+ return 0;
1653
+ }
1654
+ if (import.meta.main) {
1655
+ try {
1656
+ process.exit(await run(process.argv.slice(2)));
1657
+ } catch (e) {
1658
+ const message = redact(e instanceof Error ? e.message : String(e));
1659
+ console.error(message);
1660
+ process.exit(e instanceof DomainError ? 2 : 1);
1661
+ }
1662
+ }
1663
+ export {
1664
+ run
1665
+ };