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 +33 -6
- package/dist/tracker.js +453 -13
- package/package.json +1 -1
- package/tracker.config.example.json +1 -0
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
|
-
|
|
31
|
+
In the project root, scaffold a config:
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
|
|
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`
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
6
|
-
import { resolve as
|
|
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
|
|
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 =
|
|
1344
|
-
if (!
|
|
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 ${
|
|
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: "
|
|
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 =
|
|
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 [
|
|
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.
|
|
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",
|