trackerctl 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,16 +28,19 @@ bun run build # bundle to dist/tracker.js (what the npm package ships)
28
28
 
29
29
  ## Configuration
30
30
 
31
- Copy `tracker.config.example.json` to `tracker.config.json` and fill in your instance:
31
+ In the project root, scaffold a config:
32
32
 
33
33
  ```sh
34
- cp tracker.config.example.json tracker.config.json
34
+ tracker init --base-url https://gitlab.example.com --project group/project
35
+ # or `tracker init` alone, then edit the placeholders
35
36
  ```
36
37
 
37
- `tracker.config.json` is **gitignored** (it carries instance/project identifiers); the
38
- committed example holds the shape. The token is also never committed (env var or gitignored
39
- `.env`). Config discovery walks up from the current directory, so any subdirectory of the
40
- project works.
38
+ `init` writes `tracker.config.json` and inside a git repository — git-ignores the
39
+ local-only files (`tracker.config.json`, `.tracker/`, `.env`) so instance and project
40
+ identifiers can never be committed. The committed `tracker.config.example.json` holds the
41
+ same shape if you prefer copying it by hand. The token is also never committed (env var or
42
+ gitignored `.env`). Config discovery walks up from the current directory, so any
43
+ subdirectory of the project works.
41
44
 
42
45
  ```json
43
46
  {
@@ -86,6 +89,7 @@ Read commands serve from a local sqlite cache that auto-syncs when older than
86
89
  | `tracker release <id>` | Clear assignee + label, tombstone claim tokens |
87
90
  | `tracker close <id> [--reason <text>]` | Close (clears assignee + in-progress label) |
88
91
  | `tracker comment <id> <text>` | Post a comment on an item |
92
+ | `tracker attach <id> <file...> [-m <msg>]` | Upload files, reference them from one comment; prints markdown |
89
93
  | `tracker spend <id> <duration>` | Add time spent (`1h30m`, `2d`; `-30m` subtracts) |
90
94
  | `tracker estimate <id> <duration>` | Set the time estimate (`0` clears it) |
91
95
  | `tracker dep <id> --blocked-by <o> \| --blocks <o>` | Add a dependency edge |
@@ -93,6 +97,16 @@ Read commands serve from a local sqlite cache that auto-syncs when older than
93
97
  | `tracker remember <key> <text>` | Store a project memory |
94
98
  | `tracker forget <key>` | Hide a memory key |
95
99
  | `tracker memories [filter]` | List memories (latest per key wins) |
100
+ | `tracker pr create -t <title> --target <b> …` | Open a PR/MR (`mr` is an alias); `-i 42,43` records Closes trailers |
101
+ | `tracker pr status <id>` | State + provider-neutral CI signal (`none\|pending\|green\|red`) |
102
+ | `tracker pr merge <id> [--close-issues]` | Merge; `--close-issues` closes trailer-referenced issues explicitly |
103
+ | `tracker pr comment/comments/close/reopen` | Discuss, reject (`close -m <reason>`), reopen |
104
+
105
+ Issues and PRs are **separate capability ports**: `provider` selects where issues live,
106
+ `merge_provider` (defaults to `provider`) selects where PRs live — so a Jira + GitHub
107
+ mix needs no core changes, only adapters. Issue closing on merge is always explicit via
108
+ the issues port: GitLab's `Closes #N` magic only fires on default-branch targets, and a
109
+ GitHub PR can never auto-close a Jira issue, so tracker never relies on provider magic.
96
110
 
97
111
  Examples:
98
112
 
@@ -103,6 +117,7 @@ tracker search --state closed # state alone is a valid filter
103
117
  tracker search "payment timeout" --label backend --state open
104
118
  tracker comment 42 "blocked on design review"
105
119
  tracker comments 42 --json
120
+ tracker attach 42 before.png after.png -m "reference screenshots"
106
121
  tracker spend 42 1h30m # durations: w/d/h/m/s, 1d=8h, 1w=5d
107
122
  tracker estimate 42 2d
108
123
  tracker search checkout --remote --json # fresher, server-side
@@ -110,6 +125,9 @@ tracker create -t "Ship login" -d "OAuth" --parent 12 --blocked-by 7,9 -l auth,b
110
125
  tracker claim 42 && do-the-work || echo "someone else got it"
111
126
  tracker close 42 --reason "fixed in MR !17"
112
127
  tracker remember deploy-cmd "bun run deploy:prod"
128
+ tracker pr create -t "Fix login" --target dev -i 42 --json
129
+ tracker pr status 5 --json # poll for ci green/red
130
+ tracker pr merge 5 --close-issues
113
131
  ```
114
132
 
115
133
  Exit codes: **0** success · **2** domain failure (lost claim race, refused claim, not
@@ -148,6 +166,15 @@ domain refusal (e.g. lost a claim race) — pick different work, don't retry.
148
166
  - Inspect: `tracker show <id> --json`, `tracker children <id> --json`,
149
167
  `tracker epic-status <id> --json`, `tracker comments <id> --json`
150
168
  - Discuss: `tracker comment <id> "<note for humans or other agents>"`
169
+ - Attach evidence: `tracker attach <id> <files...> -m "<context>" --json` — uploads
170
+ screenshots/files and references them from a comment; reuse the
171
+ returned markdown in descriptions
172
+ - Open a PR/MR: `tracker pr create -t "<title>" --target <branch> -i <issue-ids> --json`
173
+ (source defaults to the current branch)
174
+ - Watch the CI: `tracker pr status <id> --json` — poll until `.ci` is "green" or "red"
175
+ - Land it: `tracker pr merge <id> --close-issues` — also closes the `-i` issues
176
+ with a comment explaining why; never rely on provider auto-close
177
+
151
178
  - Track time: `tracker spend <id> 1h30m` after finishing work (estimate vs spent
152
179
  feeds later evaluations; durations use 1d=8h, -30m subtracts)
153
180
  - Project memory: `tracker remember <key> "<fact>"`, `tracker memories --json`,
package/dist/tracker.js CHANGED
@@ -2,8 +2,38 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli/index.ts
5
- import { existsSync as existsSync3 } from "fs";
6
- import { resolve as resolve3 } from "path";
5
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
6
+ import { basename, resolve as resolve4 } from "path";
7
+
8
+ // src/core/merge.ts
9
+ function closesTrailers(issues) {
10
+ return issues.map((id) => `Closes #${id}`).join(`
11
+ `);
12
+ }
13
+ function parseClosesIssues(description) {
14
+ const ids = [];
15
+ for (const m of description.matchAll(/^Closes #(\S+)\s*$/gim)) {
16
+ if (!ids.includes(m[1]))
17
+ ids.push(m[1]);
18
+ }
19
+ return ids;
20
+ }
21
+ function withClosesTrailers(description, issues) {
22
+ if (issues.length === 0)
23
+ return description;
24
+ return [description, closesTrailers(issues)].filter(Boolean).join(`
25
+
26
+ `);
27
+ }
28
+ async function mergeAndCloseIssues(merge, issues, prId) {
29
+ const pr = await merge.prGet(prId);
30
+ await merge.prMerge(prId);
31
+ for (const id of pr.closesIssues) {
32
+ await issues.transition(id, "closed");
33
+ await issues.comment(id, `closed by merged PR: ${pr.url}`);
34
+ }
35
+ return { closed: pr.closesIssues };
36
+ }
7
37
 
8
38
  // src/errors.ts
9
39
  class DomainError extends Error {
@@ -52,7 +82,7 @@ class GitLabClient {
52
82
  ...init,
53
83
  headers: {
54
84
  Authorization: `Bearer ${this.token}`,
55
- "Content-Type": "application/json",
85
+ ...init.body instanceof FormData ? {} : { "Content-Type": "application/json" },
56
86
  ...init.headers ?? {}
57
87
  }
58
88
  });
@@ -101,6 +131,11 @@ class GitLabClient {
101
131
  }
102
132
  return all;
103
133
  }
134
+ async upload(path, form) {
135
+ const url = `${this.baseUrl}/api/v4/${path.replace(/^\//, "")}`;
136
+ const res = await this.request(url, { method: "POST", body: form });
137
+ return await res.json();
138
+ }
104
139
  async graphql(query, variables = {}) {
105
140
  const res = await this.request(`${this.baseUrl}/api/graphql`, {
106
141
  method: "POST",
@@ -207,10 +242,35 @@ mutation trackerWorkItemSetParent($input: WorkItemUpdateInput!) {
207
242
  workItem { id }
208
243
  }
209
244
  }`;
245
+ function mapCiStatus(status) {
246
+ if (!status)
247
+ return "none";
248
+ if (status === "success")
249
+ return "green";
250
+ if (status === "failed" || status === "canceled")
251
+ return "red";
252
+ return "pending";
253
+ }
254
+ function mapMr(mr) {
255
+ return {
256
+ id: String(mr.iid),
257
+ title: mr.title,
258
+ state: mr.state === "merged" ? "merged" : mr.state === "opened" ? "open" : "closed",
259
+ source: mr.source_branch,
260
+ target: mr.target_branch,
261
+ draft: mr.draft ?? /^draft:/i.test(mr.title),
262
+ ci: mapCiStatus(mr.head_pipeline?.status),
263
+ closesIssues: parseClosesIssues(mr.description ?? ""),
264
+ url: mr.web_url,
265
+ description: mr.description ?? "",
266
+ updatedAt: mr.updated_at
267
+ };
268
+ }
210
269
 
211
270
  class GitLabAdapter {
212
271
  provider = "gitlab";
213
272
  client;
273
+ baseUrl;
214
274
  projectRef;
215
275
  fullPath;
216
276
  nativeBlocking;
@@ -222,6 +282,7 @@ class GitLabAdapter {
222
282
  token: opts.token,
223
283
  fetchImpl: opts.fetchImpl
224
284
  });
285
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
225
286
  this.projectRef = encodeURIComponent(opts.project);
226
287
  this.fullPath = /^\d+$/.test(opts.project) ? null : opts.project;
227
288
  this.nativeBlocking = opts.nativeBlocking;
@@ -477,6 +538,94 @@ class GitLabAdapter {
477
538
  createdAt: n.created_at
478
539
  }));
479
540
  }
541
+ async prCreate(draft) {
542
+ const title = draft.draft && !/^draft:/i.test(draft.title) ? `Draft: ${draft.title}` : draft.title;
543
+ const mr = await this.client.rest(`projects/${this.projectRef}/merge_requests`, {
544
+ method: "POST",
545
+ body: {
546
+ title,
547
+ source_branch: draft.source,
548
+ target_branch: draft.target,
549
+ description: withClosesTrailers(draft.description ?? "", draft.issues ?? [])
550
+ }
551
+ });
552
+ return mapMr(mr);
553
+ }
554
+ async prGet(id) {
555
+ try {
556
+ const mr = await this.client.rest(`projects/${this.projectRef}/merge_requests/${id}`);
557
+ return mapMr(mr);
558
+ } catch (e) {
559
+ if (e instanceof GitLabHttpError && e.status === 404) {
560
+ throw new DomainError(`PR !${id} not found`);
561
+ }
562
+ throw e;
563
+ }
564
+ }
565
+ async prMerge(id) {
566
+ try {
567
+ await this.client.rest(`projects/${this.projectRef}/merge_requests/${id}/merge`, {
568
+ method: "PUT"
569
+ });
570
+ } catch (e) {
571
+ if (e instanceof GitLabHttpError && [405, 406, 409, 422].includes(e.status)) {
572
+ throw new DomainError(`GitLab refuses to merge !${id}: ${e.message}`);
573
+ }
574
+ throw e;
575
+ }
576
+ }
577
+ async prComment(id, body) {
578
+ await this.client.rest(`projects/${this.projectRef}/merge_requests/${id}/notes`, {
579
+ method: "POST",
580
+ body: { body }
581
+ });
582
+ }
583
+ async prListComments(id) {
584
+ const notes = await this.client.rest(`projects/${this.projectRef}/merge_requests/${id}/notes`, { query: { sort: "asc", order_by: "created_at" }, paginate: true });
585
+ return notes.filter((n) => !n.system).map((n) => ({
586
+ id: String(n.id),
587
+ body: n.body,
588
+ author: mapUser(n.author),
589
+ createdAt: n.created_at
590
+ }));
591
+ }
592
+ async prStateEvent(id, event) {
593
+ try {
594
+ await this.client.rest(`projects/${this.projectRef}/merge_requests/${id}`, {
595
+ method: "PUT",
596
+ body: { state_event: event }
597
+ });
598
+ } catch (e) {
599
+ if (e instanceof GitLabHttpError && e.status === 422) {
600
+ throw new DomainError(`cannot ${event} PR !${id}: ${e.message}`);
601
+ }
602
+ throw e;
603
+ }
604
+ }
605
+ async prClose(id) {
606
+ return this.prStateEvent(id, "close");
607
+ }
608
+ async prReopen(id) {
609
+ return this.prStateEvent(id, "reopen");
610
+ }
611
+ async attach(id, files, message) {
612
+ const attachments = [];
613
+ for (const file of files) {
614
+ const form = new FormData;
615
+ form.set("file", new Blob([file.content]), file.filename);
616
+ const up = await this.client.upload(`projects/${this.projectRef}/uploads`, form);
617
+ attachments.push({
618
+ filename: file.filename,
619
+ url: `${this.baseUrl}${up.full_path ?? up.url}`,
620
+ markdown: up.markdown
621
+ });
622
+ }
623
+ const body = [message, ...attachments.map((a) => a.markdown)].filter(Boolean).join(`
624
+
625
+ `);
626
+ await this.comment(id, body);
627
+ return attachments;
628
+ }
480
629
  async searchRemote(q) {
481
630
  const state = q.state ?? "all";
482
631
  const issues = await this.client.rest(`projects/${this.projectRef}/issues`, {
@@ -728,6 +877,9 @@ function parseConfig(json, rootDir) {
728
877
  if (raw.provider !== "gitlab") {
729
878
  throw new UsageError(`unsupported provider "${raw.provider}" (only "gitlab" for now)`);
730
879
  }
880
+ if ((raw.merge_provider ?? raw.provider) !== "gitlab") {
881
+ throw new UsageError(`unsupported merge_provider "${raw.merge_provider}" (only "gitlab" for now)`);
882
+ }
731
883
  const g = raw.gitlab ?? {};
732
884
  if (!g.base_url)
733
885
  throw new UsageError("config: gitlab.base_url is required");
@@ -736,6 +888,7 @@ function parseConfig(json, rootDir) {
736
888
  const tokenEnv = g.token_env === undefined ? ["TRACKER_GITLAB_TOKEN"] : Array.isArray(g.token_env) ? g.token_env : [g.token_env];
737
889
  return {
738
890
  provider: "gitlab",
891
+ merge_provider: "gitlab",
739
892
  gitlab: {
740
893
  base_url: g.base_url.replace(/\/+$/, ""),
741
894
  project: String(g.project),
@@ -758,7 +911,7 @@ function parseConfig(json, rootDir) {
758
911
  function loadConfig(startDir = process.cwd()) {
759
912
  const file = findConfigFile(startDir);
760
913
  if (!file) {
761
- throw new UsageError(`no ${CONFIG_FILENAME} found in ${startDir} or any parent directory \u2014 create one (see README) or cd into the project.`);
914
+ throw new UsageError(`no ${CONFIG_FILENAME} found in ${startDir} or any parent directory \u2014 run \`tracker init\` to create one, or cd into the project.`);
762
915
  }
763
916
  return parseConfig(readFileSync2(file, "utf8"), dirname3(file));
764
917
  }
@@ -1105,11 +1258,83 @@ async function ensureFresh(adapter, cache, staleMs, nowMs = Date.now(), onSync)
1105
1258
  return true;
1106
1259
  }
1107
1260
 
1261
+ // src/init.ts
1262
+ import { appendFileSync as appendFileSync2, existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync } from "fs";
1263
+ import { dirname as dirname4, join as join3, resolve as resolve3 } from "path";
1264
+ var PLACEHOLDER_URL = "https://gitlab.example.com";
1265
+ var PLACEHOLDER_PROJECT = "group/project";
1266
+ function configTemplate(baseUrl, project) {
1267
+ const config = {
1268
+ provider: "gitlab",
1269
+ gitlab: {
1270
+ base_url: baseUrl.replace(/\/+$/, ""),
1271
+ project,
1272
+ token_env: ["TRACKER_GITLAB_TOKEN", "GITLAB_PERSONAL_ACCESS_TOKEN"],
1273
+ native_blocking: true
1274
+ },
1275
+ labels: { in_progress: "status::in-progress" },
1276
+ memory: { enabled: true, title: "\uD83D\uDCCC Project Memory", label: "meta::memory" },
1277
+ cache: { path: ".tracker/cache.sqlite", stale_minutes: 15 }
1278
+ };
1279
+ return `${JSON.stringify(config, null, 2)}
1280
+ `;
1281
+ }
1282
+ function ensureIgnored(dir, warnings) {
1283
+ const gitRoot = findGitRoot(dir);
1284
+ if (!gitRoot)
1285
+ return [];
1286
+ const entries = [
1287
+ [CONFIG_FILENAME, join3(dir, CONFIG_FILENAME)],
1288
+ [".tracker/", join3(dir, ".tracker", "cache.sqlite")],
1289
+ [".env", join3(dir, ".env")]
1290
+ ];
1291
+ const added = [];
1292
+ for (const [pattern, path] of entries) {
1293
+ const ignored = isGitIgnored(gitRoot, path);
1294
+ if (ignored === null) {
1295
+ warnings.push(`git not available \u2014 verify ${pattern} is git-ignored yourself`);
1296
+ return added;
1297
+ }
1298
+ if (ignored)
1299
+ continue;
1300
+ const gitignorePath = join3(dir, ".gitignore");
1301
+ const existing = existsSync3(gitignorePath) ? readFileSync3(gitignorePath, "utf8") : "";
1302
+ const prefix = existing && !existing.endsWith(`
1303
+ `) ? `
1304
+ ` : "";
1305
+ appendFileSync2(gitignorePath, `${prefix}${pattern}
1306
+ `);
1307
+ added.push(pattern);
1308
+ }
1309
+ return added;
1310
+ }
1311
+ function initProject(targetDir, opts) {
1312
+ const dir = resolve3(targetDir);
1313
+ const configPath = join3(dir, CONFIG_FILENAME);
1314
+ if (existsSync3(configPath)) {
1315
+ throw new UsageError(`${configPath} already exists \u2014 edit it instead of re-running init`);
1316
+ }
1317
+ const warnings = [];
1318
+ const parentConfig = findConfigFile(dirname4(dir));
1319
+ if (parentConfig) {
1320
+ warnings.push(`a ${CONFIG_FILENAME} already exists in ${dirname4(parentConfig)} \u2014 the one created here will shadow it for everything under ${dir}`);
1321
+ }
1322
+ const placeholders = !opts.baseUrl || !opts.project;
1323
+ writeFileSync(configPath, configTemplate(opts.baseUrl ?? PLACEHOLDER_URL, opts.project ?? PLACEHOLDER_PROJECT));
1324
+ const ignoreAdded = ensureIgnored(dir, warnings);
1325
+ return { configPath, placeholders, ignoreAdded, warnings };
1326
+ }
1327
+
1108
1328
  // src/cli/help.ts
1109
1329
  var HELP = `tracker \u2014 multi-backend issue tracking for humans and AI agents
1110
1330
 
1111
1331
  usage: tracker <command> [args]
1112
1332
 
1333
+ setup:
1334
+ init [--base-url <url>] [--project <group/project>]
1335
+ scaffold tracker.config.json + .gitignore entries
1336
+ doctor verify config, token, connectivity, capabilities
1337
+
1113
1338
  read commands (local cache, auto-sync when stale; all accept --json):
1114
1339
  sync refresh the local cache from the provider
1115
1340
  ready [--parent <id>] open + unblocked + unassigned + not in-progress
@@ -1121,7 +1346,6 @@ read commands (local cache, auto-sync when stale; all accept --json):
1121
1346
  users <query> resolve usernames/names to user ids
1122
1347
  whoami the authenticated user
1123
1348
  memories [filter] list project memories
1124
- doctor verify config, token, connectivity, capabilities
1125
1349
 
1126
1350
  write commands:
1127
1351
  create -t <title> [-d <desc>] [--parent <id>] [--epic <id>] [-l a,b]
@@ -1130,6 +1354,8 @@ write commands:
1130
1354
  release <id> clear assignee/label, tombstone live claim tokens
1131
1355
  close <id> [--reason <text>]
1132
1356
  comment <id> <text> post a comment on an item
1357
+ attach <id> <file...> [-m <message>]
1358
+ upload files and attach them via a comment
1133
1359
  spend <id> <duration> add time spent (1h30m, 45m, 2d; -30m subtracts)
1134
1360
  estimate <id> <duration> set the time estimate (0 clears it)
1135
1361
  dep <id> --blocked-by <other> | --blocks <other>
@@ -1137,6 +1363,15 @@ write commands:
1137
1363
  remember <key> <text> store a project memory (key has no whitespace)
1138
1364
  forget <key> hide a memory key
1139
1365
 
1366
+ pull/merge requests ("pr" and "mr" are the same command):
1367
+ pr create -t <title> --target <branch> [--source <branch>] [-d <desc>]
1368
+ [-i <issue-ids>] [--draft] [--json]
1369
+ pr status <id> [--json] state + provider-neutral ci signal (none|pending|green|red)
1370
+ pr merge <id> [--close-issues]
1371
+ pr comment <id> <text>
1372
+ pr comments <id> [--json]
1373
+ pr close <id> [-m <reason>] | pr reopen <id>
1374
+
1140
1375
  search filters:
1141
1376
  --assignee <user> --author <user> --label <l> --state open|closed|all
1142
1377
  --parent <id> --remote (text query optional when any filter is given)
@@ -1195,6 +1430,42 @@ Posts a comment on the item. Everything after the id is joined into one
1195
1430
  comment body, so quoting multi-word text is optional.
1196
1431
 
1197
1432
  example: tracker comment 42 "blocked on the design review, see thread"`,
1433
+ attach: `usage: tracker attach <id> <file...> [-m <message>] [--json]
1434
+
1435
+ Uploads each file to the provider and posts ONE comment on the item containing
1436
+ the optional message plus a markdown reference per file, so the attachments are
1437
+ discoverable from the item itself. Prints each file's markdown snippet (reusable
1438
+ in descriptions); --json emits [{filename, url, markdown}].
1439
+
1440
+ examples:
1441
+ tracker attach 42 before.png after.png -m "reference screenshots"
1442
+ tracker attach 42 design.png --json`,
1443
+ pr: `usage: tracker pr <action> \u2026 (alias: tracker mr)
1444
+
1445
+ actions:
1446
+ create -t <title> --target <branch> [--source <branch>] [-d <desc>]
1447
+ [-i <id1,id2>] [--draft] [--json]
1448
+ open a PR/MR; --source defaults to the current git branch.
1449
+ -i records "Closes #N" trailers so merge --close-issues
1450
+ can close those issues explicitly (no provider magic).
1451
+ status <id> [--json]
1452
+ state (open|merged|closed) + ci signal (none|pending|green|red);
1453
+ poll this to watch a pipeline.
1454
+ merge <id> [--close-issues]
1455
+ merge; --close-issues then closes every trailer-referenced
1456
+ issue via the issue tracker and comments why.
1457
+ comment <id> <text> post a comment
1458
+ comments <id> [--json]
1459
+ list comments oldest-first
1460
+ close <id> [-m <reason>]
1461
+ close without merging ("reject"); reason posts as a comment
1462
+ reopen <id>
1463
+
1464
+ examples:
1465
+ tracker pr create -t "Fix login" --target dev -i 42 --json
1466
+ tracker pr status 5 --json
1467
+ tracker pr merge 5 --close-issues
1468
+ tracker pr close 5 -m "superseded by !6"`,
1198
1469
  comments: `usage: tracker comments <id> [--json]
1199
1470
 
1200
1471
  Lists the item's comments oldest-first (system notes are filtered out). Claim
@@ -1251,7 +1522,17 @@ example: tracker users mehmet`,
1251
1522
  whoami: "usage: tracker whoami [--json]",
1252
1523
  doctor: `usage: tracker doctor [--json]
1253
1524
 
1254
- Verifies config, token, REST/GraphQL connectivity, capabilities, cache.`
1525
+ Verifies config, token, REST/GraphQL connectivity, capabilities, cache.`,
1526
+ init: `usage: tracker init [--base-url <url>] [--project <group/project>]
1527
+
1528
+ Scaffolds tracker.config.json in the current directory (placeholders when the
1529
+ flags are omitted) and, inside a git repository, git-ignores the local-only
1530
+ files (tracker.config.json, .tracker/, .env) so instance and project
1531
+ identifiers can never be committed. Refuses to overwrite an existing config.
1532
+
1533
+ examples:
1534
+ tracker init --base-url https://gitlab.example.com --project group/project
1535
+ tracker init # then edit the placeholders in tracker.config.json`
1255
1536
  };
1256
1537
  function commandHelp(cmd) {
1257
1538
  return PER_COMMAND[cmd] ?? HELP;
@@ -1340,8 +1621,8 @@ function buildCtx() {
1340
1621
  token,
1341
1622
  nativeBlocking: config.gitlab.native_blocking
1342
1623
  });
1343
- const cachePath = resolve3(config.rootDir, config.cache.path);
1344
- if (!existsSync3(cachePath)) {
1624
+ const cachePath = resolve4(config.rootDir, config.cache.path);
1625
+ if (!existsSync4(cachePath)) {
1345
1626
  const guard = guardCacheIgnored(config.rootDir, cachePath);
1346
1627
  if (guard.action === "added" || guard.action === "warn")
1347
1628
  console.error(`(${guard.detail})`);
@@ -1367,7 +1648,7 @@ async function cmdSync(ctx) {
1367
1648
  const t0 = performance.now();
1368
1649
  const { count } = await syncCache(ctx.adapter, ctx.cache);
1369
1650
  const ms = Math.round(performance.now() - t0);
1370
- console.log(`synced ${count} items in ${ms}ms \u2192 ${resolve3(ctx.config.rootDir, ctx.config.cache.path)}`);
1651
+ console.log(`synced ${count} items in ${ms}ms \u2192 ${resolve4(ctx.config.rootDir, ctx.config.cache.path)}`);
1371
1652
  }
1372
1653
  async function cmdReady(ctx, args) {
1373
1654
  await freshen(ctx);
@@ -1545,6 +1826,128 @@ async function cmdComment(ctx, args) {
1545
1826
  await ctx.adapter.comment(id, body);
1546
1827
  console.log(`commented on #${id}`);
1547
1828
  }
1829
+ async function cmdAttach(ctx, args) {
1830
+ const [idArg, ...paths] = args.positionals;
1831
+ const id = normalizeId(idArg);
1832
+ if (paths.length === 0)
1833
+ throw new UsageError(commandHelp("attach"));
1834
+ const files = paths.map((p) => {
1835
+ if (!existsSync4(p))
1836
+ throw new UsageError(`${p}: no such file`);
1837
+ return { filename: basename(p), content: new Uint8Array(readFileSync4(p)) };
1838
+ });
1839
+ const attachments = await ctx.adapter.attach(id, files, str(args, "--message"));
1840
+ if (args.flags.get("--json"))
1841
+ return printJson(attachments);
1842
+ for (const a of attachments)
1843
+ console.log(a.markdown);
1844
+ console.error(`attached ${attachments.length} file(s) to #${id}`);
1845
+ }
1846
+ function normalizePrId(raw) {
1847
+ if (!raw)
1848
+ throw new UsageError("PR id is required");
1849
+ return raw.replace(/^[!#]/, "");
1850
+ }
1851
+ function currentGitBranch() {
1852
+ const proc = Bun.spawnSync(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
1853
+ const branch = proc.success ? proc.stdout.toString().trim() : "";
1854
+ if (!branch || branch === "HEAD") {
1855
+ throw new UsageError("could not determine the current git branch \u2014 pass --source");
1856
+ }
1857
+ return branch;
1858
+ }
1859
+ async function cmdPr(ctx, args) {
1860
+ const [action, ...positionals] = args.positionals;
1861
+ const json = args.flags.get("--json");
1862
+ switch (action) {
1863
+ case "create": {
1864
+ const title = str(args, "--title");
1865
+ const target = str(args, "--target");
1866
+ if (!title)
1867
+ throw new UsageError(`--title is required
1868
+
1869
+ ${commandHelp("pr")}`);
1870
+ if (!target)
1871
+ throw new UsageError(`--target is required
1872
+
1873
+ ${commandHelp("pr")}`);
1874
+ const pr = await ctx.adapter.prCreate({
1875
+ title,
1876
+ target,
1877
+ source: str(args, "--source") ?? currentGitBranch(),
1878
+ description: str(args, "--description"),
1879
+ draft: args.flags.get("--draft") === true,
1880
+ issues: (str(args, "--issue") ?? "").split(",").filter(Boolean).map((s) => s.replace(/^#/, ""))
1881
+ });
1882
+ if (json)
1883
+ return printJson(pr);
1884
+ console.log(pr.url);
1885
+ console.error(`created PR !${pr.id}: ${pr.source} \u2192 ${pr.target}`);
1886
+ return;
1887
+ }
1888
+ case "status": {
1889
+ const pr = await ctx.adapter.prGet(normalizePrId(positionals[0]));
1890
+ if (json)
1891
+ return printJson(pr);
1892
+ const closes = pr.closesIssues.length ? ` \xB7 closes ${pr.closesIssues.map((i) => `#${i}`).join(",")}` : "";
1893
+ console.log(`!${pr.id} ${pr.state} \xB7 ci ${pr.ci}${pr.draft ? " \xB7 draft" : ""}${closes}`);
1894
+ console.log(pr.url);
1895
+ return;
1896
+ }
1897
+ case "merge": {
1898
+ const id = normalizePrId(positionals[0]);
1899
+ if (args.flags.get("--close-issues")) {
1900
+ const { closed } = await mergeAndCloseIssues(ctx.adapter, ctx.adapter, id);
1901
+ invalidate(ctx);
1902
+ console.log(closed.length ? `merged !${id}, closed ${closed.map((i) => `#${i}`).join(", ")}` : `merged !${id} (no Closes trailers found)`);
1903
+ } else {
1904
+ await ctx.adapter.prMerge(id);
1905
+ console.log(`merged !${id}`);
1906
+ }
1907
+ return;
1908
+ }
1909
+ case "comment": {
1910
+ const id = normalizePrId(positionals[0]);
1911
+ const body = positionals.slice(1).join(" ").trim();
1912
+ if (!body)
1913
+ throw new UsageError(commandHelp("pr"));
1914
+ await ctx.adapter.prComment(id, body);
1915
+ console.log(`commented on !${id}`);
1916
+ return;
1917
+ }
1918
+ case "comments": {
1919
+ const id = normalizePrId(positionals[0]);
1920
+ const comments = await ctx.adapter.prListComments(id);
1921
+ if (json)
1922
+ return printJson(comments);
1923
+ if (comments.length === 0)
1924
+ return console.error(`(no comments on !${id})`);
1925
+ for (const c of comments) {
1926
+ console.log(`@${c.author.username} ${c.createdAt}`);
1927
+ console.log(c.body);
1928
+ console.log("");
1929
+ }
1930
+ return;
1931
+ }
1932
+ case "close": {
1933
+ const id = normalizePrId(positionals[0]);
1934
+ const reason = str(args, "--message");
1935
+ if (reason)
1936
+ await ctx.adapter.prComment(id, reason);
1937
+ await ctx.adapter.prClose(id);
1938
+ console.log(`closed !${id}`);
1939
+ return;
1940
+ }
1941
+ case "reopen": {
1942
+ const id = normalizePrId(positionals[0]);
1943
+ await ctx.adapter.prReopen(id);
1944
+ console.log(`reopened !${id}`);
1945
+ return;
1946
+ }
1947
+ default:
1948
+ throw new UsageError(commandHelp("pr"));
1949
+ }
1950
+ }
1548
1951
  async function cmdComments(ctx, args) {
1549
1952
  const id = normalizeId(args.positionals[0]);
1550
1953
  const comments = await ctx.adapter.listComments(id);
@@ -1617,7 +2020,7 @@ async function cmdDoctor(args) {
1617
2020
  name: "config",
1618
2021
  status: "fail",
1619
2022
  detail: e.message,
1620
- fix: "create tracker.config.json (see README) in the project root"
2023
+ fix: "run `tracker init` in the project root to create tracker.config.json"
1621
2024
  });
1622
2025
  }
1623
2026
  let ctx = null;
@@ -1684,7 +2087,7 @@ async function cmdDoctor(args) {
1684
2087
  status: "ok",
1685
2088
  detail: last ? `${ctx.cache.count()} items, synced ${Math.round((Date.now() - last) / 60000)}m ago` : "empty (run: tracker sync)"
1686
2089
  });
1687
- const cachePath = resolve3(config.rootDir, config.cache.path);
2090
+ const cachePath = resolve4(config.rootDir, config.cache.path);
1688
2091
  const gitRoot = findGitRoot(config.rootDir);
1689
2092
  if (gitRoot) {
1690
2093
  const ignored = isGitIgnored(gitRoot, cachePath);
@@ -1712,6 +2115,21 @@ all good.`);
1712
2115
  }
1713
2116
  return failed ? 1 : 0;
1714
2117
  }
2118
+ function cmdInit(args) {
2119
+ const result = initProject(process.cwd(), {
2120
+ baseUrl: str(args, "--base-url"),
2121
+ project: str(args, "--project")
2122
+ });
2123
+ console.log(`created ${result.configPath}`);
2124
+ for (const pattern of result.ignoreAdded)
2125
+ console.log(`added "${pattern}" to .gitignore`);
2126
+ for (const warning of result.warnings)
2127
+ console.error(`warning: ${warning}`);
2128
+ if (result.placeholders) {
2129
+ console.log("next: edit gitlab.base_url and gitlab.project in the config");
2130
+ }
2131
+ console.log("then: export TRACKER_GITLAB_TOKEN (or add it to .env) and run: tracker doctor");
2132
+ }
1715
2133
  var VALUE_FLAGS = {
1716
2134
  ready: { "--parent": "value", "--json": "bool" },
1717
2135
  show: { "--json": "bool" },
@@ -1741,9 +2159,22 @@ var VALUE_FLAGS = {
1741
2159
  users: { "--json": "bool" },
1742
2160
  whoami: { "--json": "bool" },
1743
2161
  doctor: { "--json": "bool" },
2162
+ init: { "--base-url": "value", "--project": "value" },
1744
2163
  memories: { "--json": "bool" },
1745
2164
  comment: {},
1746
2165
  comments: { "--json": "bool" },
2166
+ attach: { "--message": "value", "--json": "bool" },
2167
+ pr: {
2168
+ "--title": "value",
2169
+ "--description": "value",
2170
+ "--source": "value",
2171
+ "--target": "value",
2172
+ "--issue": "value",
2173
+ "--draft": "bool",
2174
+ "--close-issues": "bool",
2175
+ "--message": "value",
2176
+ "--json": "bool"
2177
+ },
1747
2178
  spend: {},
1748
2179
  estimate: {},
1749
2180
  sync: {},
@@ -1761,10 +2192,13 @@ var ALIASES = {
1761
2192
  "--labels": "--label",
1762
2193
  "-m": "--milestone"
1763
2194
  },
1764
- close: { "-r": "--reason" }
2195
+ close: { "-r": "--reason" },
2196
+ attach: { "-m": "--message" },
2197
+ pr: { "-t": "--title", "-d": "--description", "-m": "--message", "-i": "--issue" }
1765
2198
  };
1766
2199
  async function run(argv) {
1767
- const [cmd, ...rest] = argv;
2200
+ const [rawCmd, ...rest] = argv;
2201
+ const cmd = rawCmd === "mr" ? "pr" : rawCmd;
1768
2202
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
1769
2203
  console.log(HELP);
1770
2204
  return cmd ? 0 : 1;
@@ -1781,6 +2215,10 @@ ${HELP}`);
1781
2215
  return 0;
1782
2216
  }
1783
2217
  const args = parseArgs(rest, spec, ALIASES[cmd] ?? {});
2218
+ if (cmd === "init") {
2219
+ cmdInit(args);
2220
+ return 0;
2221
+ }
1784
2222
  if (cmd === "doctor")
1785
2223
  return cmdDoctor(args);
1786
2224
  const ctx = buildCtx();
@@ -1801,6 +2239,8 @@ ${HELP}`);
1801
2239
  memories: cmdMemories,
1802
2240
  comment: cmdComment,
1803
2241
  comments: cmdComments,
2242
+ attach: cmdAttach,
2243
+ pr: cmdPr,
1804
2244
  spend: cmdSpend,
1805
2245
  estimate: cmdEstimate,
1806
2246
  search: cmdSearch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackerctl",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Multi-backend issue-tracking CLI for humans and AI agents — claim races, dependencies, hierarchy, local FTS search, project memory. GitLab adapter first.",
5
5
  "license": "MIT",
6
6
  "author": "refo",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "provider": "gitlab",
3
+ "merge_provider": "gitlab",
3
4
  "gitlab": {
4
5
  "base_url": "https://gitlab.example.com",
5
6
  "project": "group/project",