trackerctl 0.3.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
@@ -89,6 +89,7 @@ Read commands serve from a local sqlite cache that auto-syncs when older than
89
89
  | `tracker release <id>` | Clear assignee + label, tombstone claim tokens |
90
90
  | `tracker close <id> [--reason <text>]` | Close (clears assignee + in-progress label) |
91
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 |
92
93
  | `tracker spend <id> <duration>` | Add time spent (`1h30m`, `2d`; `-30m` subtracts) |
93
94
  | `tracker estimate <id> <duration>` | Set the time estimate (`0` clears it) |
94
95
  | `tracker dep <id> --blocked-by <o> \| --blocks <o>` | Add a dependency edge |
@@ -96,6 +97,16 @@ Read commands serve from a local sqlite cache that auto-syncs when older than
96
97
  | `tracker remember <key> <text>` | Store a project memory |
97
98
  | `tracker forget <key>` | Hide a memory key |
98
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.
99
110
 
100
111
  Examples:
101
112
 
@@ -106,6 +117,7 @@ tracker search --state closed # state alone is a valid filter
106
117
  tracker search "payment timeout" --label backend --state open
107
118
  tracker comment 42 "blocked on design review"
108
119
  tracker comments 42 --json
120
+ tracker attach 42 before.png after.png -m "reference screenshots"
109
121
  tracker spend 42 1h30m # durations: w/d/h/m/s, 1d=8h, 1w=5d
110
122
  tracker estimate 42 2d
111
123
  tracker search checkout --remote --json # fresher, server-side
@@ -113,6 +125,9 @@ tracker create -t "Ship login" -d "OAuth" --parent 12 --blocked-by 7,9 -l auth,b
113
125
  tracker claim 42 && do-the-work || echo "someone else got it"
114
126
  tracker close 42 --reason "fixed in MR !17"
115
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
116
131
  ```
117
132
 
118
133
  Exit codes: **0** success · **2** domain failure (lost claim race, refused claim, not
@@ -151,6 +166,15 @@ domain refusal (e.g. lost a claim race) — pick different work, don't retry.
151
166
  - Inspect: `tracker show <id> --json`, `tracker children <id> --json`,
152
167
  `tracker epic-status <id> --json`, `tracker comments <id> --json`
153
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
+
154
178
  - Track time: `tracker spend <id> 1h30m` after finishing work (estimate vs spent
155
179
  feeds later evaluations; durations use 1d=8h, -30m subtracts)
156
180
  - Project memory: `tracker remember <key> "<fact>"`, `tracker memories --json`,
package/bin/tracker.mjs CHANGED
File without changes
package/dist/tracker.js CHANGED
@@ -2,8 +2,38 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli/index.ts
5
- import { existsSync as existsSync4 } from "fs";
6
- import { resolve as resolve4 } 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),
@@ -1201,6 +1354,8 @@ write commands:
1201
1354
  release <id> clear assignee/label, tombstone live claim tokens
1202
1355
  close <id> [--reason <text>]
1203
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
1204
1359
  spend <id> <duration> add time spent (1h30m, 45m, 2d; -30m subtracts)
1205
1360
  estimate <id> <duration> set the time estimate (0 clears it)
1206
1361
  dep <id> --blocked-by <other> | --blocks <other>
@@ -1208,6 +1363,15 @@ write commands:
1208
1363
  remember <key> <text> store a project memory (key has no whitespace)
1209
1364
  forget <key> hide a memory key
1210
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
+
1211
1375
  search filters:
1212
1376
  --assignee <user> --author <user> --label <l> --state open|closed|all
1213
1377
  --parent <id> --remote (text query optional when any filter is given)
@@ -1266,6 +1430,42 @@ Posts a comment on the item. Everything after the id is joined into one
1266
1430
  comment body, so quoting multi-word text is optional.
1267
1431
 
1268
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"`,
1269
1469
  comments: `usage: tracker comments <id> [--json]
1270
1470
 
1271
1471
  Lists the item's comments oldest-first (system notes are filtered out). Claim
@@ -1626,6 +1826,128 @@ async function cmdComment(ctx, args) {
1626
1826
  await ctx.adapter.comment(id, body);
1627
1827
  console.log(`commented on #${id}`);
1628
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
+ }
1629
1951
  async function cmdComments(ctx, args) {
1630
1952
  const id = normalizeId(args.positionals[0]);
1631
1953
  const comments = await ctx.adapter.listComments(id);
@@ -1841,6 +2163,18 @@ var VALUE_FLAGS = {
1841
2163
  memories: { "--json": "bool" },
1842
2164
  comment: {},
1843
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
+ },
1844
2178
  spend: {},
1845
2179
  estimate: {},
1846
2180
  sync: {},
@@ -1858,10 +2192,13 @@ var ALIASES = {
1858
2192
  "--labels": "--label",
1859
2193
  "-m": "--milestone"
1860
2194
  },
1861
- close: { "-r": "--reason" }
2195
+ close: { "-r": "--reason" },
2196
+ attach: { "-m": "--message" },
2197
+ pr: { "-t": "--title", "-d": "--description", "-m": "--message", "-i": "--issue" }
1862
2198
  };
1863
2199
  async function run(argv) {
1864
- const [cmd, ...rest] = argv;
2200
+ const [rawCmd, ...rest] = argv;
2201
+ const cmd = rawCmd === "mr" ? "pr" : rawCmd;
1865
2202
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
1866
2203
  console.log(HELP);
1867
2204
  return cmd ? 0 : 1;
@@ -1902,6 +2239,8 @@ ${HELP}`);
1902
2239
  memories: cmdMemories,
1903
2240
  comment: cmdComment,
1904
2241
  comments: cmdComments,
2242
+ attach: cmdAttach,
2243
+ pr: cmdPr,
1905
2244
  spend: cmdSpend,
1906
2245
  estimate: cmdEstimate,
1907
2246
  search: cmdSearch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackerctl",
3
- "version": "0.3.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",