trackerctl 0.1.0 → 0.3.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 +18 -7
- package/bin/tracker.mjs +0 -0
- package/dist/tracker.js +276 -16
- package/package.json +1 -1
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,8 @@ 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 spend <id> <duration>` | Add time spent (`1h30m`, `2d`; `-30m` subtracts) |
|
|
93
|
+
| `tracker estimate <id> <duration>` | Set the time estimate (`0` clears it) |
|
|
89
94
|
| `tracker dep <id> --blocked-by <o> \| --blocks <o>` | Add a dependency edge |
|
|
90
95
|
| `tracker parent <child> <parent>` | Re-parent an item |
|
|
91
96
|
| `tracker remember <key> <text>` | Store a project memory |
|
|
@@ -101,6 +106,8 @@ tracker search --state closed # state alone is a valid filter
|
|
|
101
106
|
tracker search "payment timeout" --label backend --state open
|
|
102
107
|
tracker comment 42 "blocked on design review"
|
|
103
108
|
tracker comments 42 --json
|
|
109
|
+
tracker spend 42 1h30m # durations: w/d/h/m/s, 1d=8h, 1w=5d
|
|
110
|
+
tracker estimate 42 2d
|
|
104
111
|
tracker search checkout --remote --json # fresher, server-side
|
|
105
112
|
tracker create -t "Ship login" -d "OAuth" --parent 12 --blocked-by 7,9 -l auth,backend
|
|
106
113
|
tracker claim 42 && do-the-work || echo "someone else got it"
|
|
@@ -144,6 +151,8 @@ domain refusal (e.g. lost a claim race) — pick different work, don't retry.
|
|
|
144
151
|
- Inspect: `tracker show <id> --json`, `tracker children <id> --json`,
|
|
145
152
|
`tracker epic-status <id> --json`, `tracker comments <id> --json`
|
|
146
153
|
- Discuss: `tracker comment <id> "<note for humans or other agents>"`
|
|
154
|
+
- Track time: `tracker spend <id> 1h30m` after finishing work (estimate vs spent
|
|
155
|
+
feeds later evaluations; durations use 1d=8h, -30m subtracts)
|
|
147
156
|
- Project memory: `tracker remember <key> "<fact>"`, `tracker memories --json`,
|
|
148
157
|
`tracker forget <key>`
|
|
149
158
|
|
|
@@ -168,7 +177,9 @@ Never edit the `📌 Project Memory` issue or `🔒/🔓` notes by hand.
|
|
|
168
177
|
"blockedBy": ["7", "9"], // ids of open OR closed blockers; `ready` checks openness
|
|
169
178
|
"url": "https://gitlab…/issues/42",
|
|
170
179
|
"description": "…",
|
|
171
|
-
"updatedAt": "2026-06-10T17:21:33.000Z"
|
|
180
|
+
"updatedAt": "2026-06-10T17:21:33.000Z",
|
|
181
|
+
"timeSpentSeconds": 3600, // 0 = none recorded
|
|
182
|
+
"timeEstimateSeconds": 57600 // 0 = no estimate
|
|
172
183
|
}
|
|
173
184
|
```
|
|
174
185
|
|
package/bin/tracker.mjs
CHANGED
|
File without changes
|
package/dist/tracker.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/cli/index.ts
|
|
5
|
-
import { existsSync as
|
|
6
|
-
import { resolve as
|
|
5
|
+
import { existsSync as existsSync4 } from "fs";
|
|
6
|
+
import { resolve as resolve4 } from "path";
|
|
7
7
|
|
|
8
8
|
// src/errors.ts
|
|
9
9
|
class DomainError extends Error {
|
|
@@ -156,6 +156,8 @@ function mapIssue(issue, info) {
|
|
|
156
156
|
url: issue.web_url,
|
|
157
157
|
description,
|
|
158
158
|
updatedAt: issue.updated_at,
|
|
159
|
+
timeSpentSeconds: issue.time_stats?.total_time_spent ?? 0,
|
|
160
|
+
timeEstimateSeconds: issue.time_stats?.time_estimate ?? 0,
|
|
159
161
|
raw: issue
|
|
160
162
|
};
|
|
161
163
|
}
|
|
@@ -225,7 +227,12 @@ class GitLabAdapter {
|
|
|
225
227
|
this.nativeBlocking = opts.nativeBlocking;
|
|
226
228
|
}
|
|
227
229
|
capabilities() {
|
|
228
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
nativeBlocking: this.nativeBlocking,
|
|
232
|
+
nativeHierarchy: true,
|
|
233
|
+
serverSearch: true,
|
|
234
|
+
timeTracking: true
|
|
235
|
+
};
|
|
229
236
|
}
|
|
230
237
|
async getFullPath() {
|
|
231
238
|
if (this.fullPath)
|
|
@@ -414,6 +421,47 @@ class GitLabAdapter {
|
|
|
414
421
|
throw new DomainError(`workItemUpdate: ${data.workItemUpdate.errors.join("; ")}`);
|
|
415
422
|
}
|
|
416
423
|
}
|
|
424
|
+
static glDuration(seconds) {
|
|
425
|
+
const sign = seconds < 0 ? "-" : "";
|
|
426
|
+
let rest = Math.abs(seconds);
|
|
427
|
+
const parts = [];
|
|
428
|
+
const h = Math.floor(rest / 3600);
|
|
429
|
+
rest %= 3600;
|
|
430
|
+
const m = Math.floor(rest / 60);
|
|
431
|
+
const s = rest % 60;
|
|
432
|
+
if (h)
|
|
433
|
+
parts.push(`${h}h`);
|
|
434
|
+
if (m)
|
|
435
|
+
parts.push(`${m}m`);
|
|
436
|
+
if (s)
|
|
437
|
+
parts.push(`${s}s`);
|
|
438
|
+
return sign + (parts.join("") || "0m");
|
|
439
|
+
}
|
|
440
|
+
async addTimeSpent(id, seconds) {
|
|
441
|
+
try {
|
|
442
|
+
await this.client.rest(`projects/${this.projectRef}/issues/${id}/add_spent_time`, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
body: { duration: GitLabAdapter.glDuration(seconds) }
|
|
445
|
+
});
|
|
446
|
+
} catch (e) {
|
|
447
|
+
if (e instanceof GitLabHttpError && e.status === 400) {
|
|
448
|
+
throw new DomainError(`cannot record ${GitLabAdapter.glDuration(seconds)} on #${id}: ${e.message}`);
|
|
449
|
+
}
|
|
450
|
+
throw e;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async setTimeEstimate(id, seconds) {
|
|
454
|
+
if (seconds === 0) {
|
|
455
|
+
await this.client.rest(`projects/${this.projectRef}/issues/${id}/reset_time_estimate`, {
|
|
456
|
+
method: "POST"
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
await this.client.rest(`projects/${this.projectRef}/issues/${id}/time_estimate`, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
body: { duration: GitLabAdapter.glDuration(seconds) }
|
|
463
|
+
});
|
|
464
|
+
}
|
|
417
465
|
async comment(id, body) {
|
|
418
466
|
await this.client.rest(`projects/${this.projectRef}/issues/${id}/notes`, {
|
|
419
467
|
method: "POST",
|
|
@@ -478,6 +526,8 @@ CREATE TABLE IF NOT EXISTS items (
|
|
|
478
526
|
url TEXT NOT NULL,
|
|
479
527
|
description TEXT NOT NULL,
|
|
480
528
|
updated_at TEXT NOT NULL,
|
|
529
|
+
time_spent INTEGER NOT NULL DEFAULT 0,
|
|
530
|
+
time_estimate INTEGER NOT NULL DEFAULT 0,
|
|
481
531
|
raw TEXT
|
|
482
532
|
);
|
|
483
533
|
CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent);
|
|
@@ -501,16 +551,24 @@ class Cache {
|
|
|
501
551
|
mkdirSync(dirname(path), { recursive: true });
|
|
502
552
|
this.db = new Database(path);
|
|
503
553
|
this.db.exec(SCHEMA);
|
|
554
|
+
for (const col of [
|
|
555
|
+
"time_spent INTEGER NOT NULL DEFAULT 0",
|
|
556
|
+
"time_estimate INTEGER NOT NULL DEFAULT 0"
|
|
557
|
+
]) {
|
|
558
|
+
try {
|
|
559
|
+
this.db.exec(`ALTER TABLE items ADD COLUMN ${col}`);
|
|
560
|
+
} catch {}
|
|
561
|
+
}
|
|
504
562
|
}
|
|
505
563
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
564
|
+
const insertItem = this.db.prepare(`INSERT INTO items (id, provider, kind, title, state, labels, assignees, author, parent, url, description, updated_at, time_spent, time_estimate, raw)
|
|
565
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
508
566
|
const insertLink = this.db.prepare("INSERT OR IGNORE INTO links (blocker, blocked) VALUES (?, ?)");
|
|
509
567
|
const insertFts = this.db.prepare("INSERT INTO items_fts (id, title, description) VALUES (?, ?, ?)");
|
|
510
568
|
const tx = this.db.transaction(() => {
|
|
511
569
|
this.db.exec("DELETE FROM items; DELETE FROM links; DELETE FROM items_fts;");
|
|
512
570
|
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));
|
|
571
|
+
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.timeSpentSeconds, item.timeEstimateSeconds, item.raw === undefined ? null : JSON.stringify(item.raw));
|
|
514
572
|
for (const blocker of item.blockedBy)
|
|
515
573
|
insertLink.run(blocker, item.id);
|
|
516
574
|
insertFts.run(item.id, item.title, item.description);
|
|
@@ -531,7 +589,9 @@ class Cache {
|
|
|
531
589
|
blockedBy: this.blockersOf(row.id),
|
|
532
590
|
url: row.url,
|
|
533
591
|
description: row.description,
|
|
534
|
-
updatedAt: row.updated_at
|
|
592
|
+
updatedAt: row.updated_at,
|
|
593
|
+
timeSpentSeconds: row.time_spent,
|
|
594
|
+
timeEstimateSeconds: row.time_estimate
|
|
535
595
|
};
|
|
536
596
|
}
|
|
537
597
|
blockersOf(id) {
|
|
@@ -698,7 +758,7 @@ function parseConfig(json, rootDir) {
|
|
|
698
758
|
function loadConfig(startDir = process.cwd()) {
|
|
699
759
|
const file = findConfigFile(startDir);
|
|
700
760
|
if (!file) {
|
|
701
|
-
throw new UsageError(`no ${CONFIG_FILENAME} found in ${startDir} or any parent directory \u2014 create one
|
|
761
|
+
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.`);
|
|
702
762
|
}
|
|
703
763
|
return parseConfig(readFileSync2(file, "utf8"), dirname3(file));
|
|
704
764
|
}
|
|
@@ -854,6 +914,47 @@ async function releaseItem(adapter, id, policy) {
|
|
|
854
914
|
return { id, cleared };
|
|
855
915
|
}
|
|
856
916
|
|
|
917
|
+
// src/core/duration.ts
|
|
918
|
+
var UNIT_SECONDS = {
|
|
919
|
+
w: 5 * 8 * 3600,
|
|
920
|
+
d: 8 * 3600,
|
|
921
|
+
h: 3600,
|
|
922
|
+
m: 60,
|
|
923
|
+
s: 1
|
|
924
|
+
};
|
|
925
|
+
function parseDuration(input) {
|
|
926
|
+
const trimmed = input.trim();
|
|
927
|
+
const negative = trimmed.startsWith("-");
|
|
928
|
+
const body = negative ? trimmed.slice(1) : trimmed;
|
|
929
|
+
if (body === "0")
|
|
930
|
+
return 0;
|
|
931
|
+
const parts = [...body.matchAll(/(\d+)([wdhms])/g)];
|
|
932
|
+
const matched = parts.map((p) => p[0]).join("");
|
|
933
|
+
if (parts.length === 0 || matched !== body) {
|
|
934
|
+
throw new UsageError(`invalid duration "${input}" \u2014 use units w/d/h/m/s, e.g. 1h30m, 45m, 2d (1d=8h, 1w=5d), -30m to subtract`);
|
|
935
|
+
}
|
|
936
|
+
const seconds = parts.reduce((sum, p) => sum + Number(p[1]) * UNIT_SECONDS[p[2]], 0);
|
|
937
|
+
return negative ? -seconds : seconds;
|
|
938
|
+
}
|
|
939
|
+
function formatDuration(totalSeconds) {
|
|
940
|
+
const sign = totalSeconds < 0 ? "-" : "";
|
|
941
|
+
let rest = Math.abs(Math.round(totalSeconds));
|
|
942
|
+
const parts = [];
|
|
943
|
+
const h = Math.floor(rest / 3600);
|
|
944
|
+
rest %= 3600;
|
|
945
|
+
const m = Math.floor(rest / 60);
|
|
946
|
+
const s = rest % 60;
|
|
947
|
+
if (h)
|
|
948
|
+
parts.push(`${h}h`);
|
|
949
|
+
if (m)
|
|
950
|
+
parts.push(`${m}m`);
|
|
951
|
+
if (s)
|
|
952
|
+
parts.push(`${s}s`);
|
|
953
|
+
if (parts.length === 0)
|
|
954
|
+
return "0m";
|
|
955
|
+
return sign + parts.join(" ");
|
|
956
|
+
}
|
|
957
|
+
|
|
857
958
|
// src/core/ids.ts
|
|
858
959
|
function normalizeId(input) {
|
|
859
960
|
const id = (input ?? "").trim().replace(/^#/, "");
|
|
@@ -1004,11 +1105,83 @@ async function ensureFresh(adapter, cache, staleMs, nowMs = Date.now(), onSync)
|
|
|
1004
1105
|
return true;
|
|
1005
1106
|
}
|
|
1006
1107
|
|
|
1108
|
+
// src/init.ts
|
|
1109
|
+
import { appendFileSync as appendFileSync2, existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
1110
|
+
import { dirname as dirname4, join as join3, resolve as resolve3 } from "path";
|
|
1111
|
+
var PLACEHOLDER_URL = "https://gitlab.example.com";
|
|
1112
|
+
var PLACEHOLDER_PROJECT = "group/project";
|
|
1113
|
+
function configTemplate(baseUrl, project) {
|
|
1114
|
+
const config = {
|
|
1115
|
+
provider: "gitlab",
|
|
1116
|
+
gitlab: {
|
|
1117
|
+
base_url: baseUrl.replace(/\/+$/, ""),
|
|
1118
|
+
project,
|
|
1119
|
+
token_env: ["TRACKER_GITLAB_TOKEN", "GITLAB_PERSONAL_ACCESS_TOKEN"],
|
|
1120
|
+
native_blocking: true
|
|
1121
|
+
},
|
|
1122
|
+
labels: { in_progress: "status::in-progress" },
|
|
1123
|
+
memory: { enabled: true, title: "\uD83D\uDCCC Project Memory", label: "meta::memory" },
|
|
1124
|
+
cache: { path: ".tracker/cache.sqlite", stale_minutes: 15 }
|
|
1125
|
+
};
|
|
1126
|
+
return `${JSON.stringify(config, null, 2)}
|
|
1127
|
+
`;
|
|
1128
|
+
}
|
|
1129
|
+
function ensureIgnored(dir, warnings) {
|
|
1130
|
+
const gitRoot = findGitRoot(dir);
|
|
1131
|
+
if (!gitRoot)
|
|
1132
|
+
return [];
|
|
1133
|
+
const entries = [
|
|
1134
|
+
[CONFIG_FILENAME, join3(dir, CONFIG_FILENAME)],
|
|
1135
|
+
[".tracker/", join3(dir, ".tracker", "cache.sqlite")],
|
|
1136
|
+
[".env", join3(dir, ".env")]
|
|
1137
|
+
];
|
|
1138
|
+
const added = [];
|
|
1139
|
+
for (const [pattern, path] of entries) {
|
|
1140
|
+
const ignored = isGitIgnored(gitRoot, path);
|
|
1141
|
+
if (ignored === null) {
|
|
1142
|
+
warnings.push(`git not available \u2014 verify ${pattern} is git-ignored yourself`);
|
|
1143
|
+
return added;
|
|
1144
|
+
}
|
|
1145
|
+
if (ignored)
|
|
1146
|
+
continue;
|
|
1147
|
+
const gitignorePath = join3(dir, ".gitignore");
|
|
1148
|
+
const existing = existsSync3(gitignorePath) ? readFileSync3(gitignorePath, "utf8") : "";
|
|
1149
|
+
const prefix = existing && !existing.endsWith(`
|
|
1150
|
+
`) ? `
|
|
1151
|
+
` : "";
|
|
1152
|
+
appendFileSync2(gitignorePath, `${prefix}${pattern}
|
|
1153
|
+
`);
|
|
1154
|
+
added.push(pattern);
|
|
1155
|
+
}
|
|
1156
|
+
return added;
|
|
1157
|
+
}
|
|
1158
|
+
function initProject(targetDir, opts) {
|
|
1159
|
+
const dir = resolve3(targetDir);
|
|
1160
|
+
const configPath = join3(dir, CONFIG_FILENAME);
|
|
1161
|
+
if (existsSync3(configPath)) {
|
|
1162
|
+
throw new UsageError(`${configPath} already exists \u2014 edit it instead of re-running init`);
|
|
1163
|
+
}
|
|
1164
|
+
const warnings = [];
|
|
1165
|
+
const parentConfig = findConfigFile(dirname4(dir));
|
|
1166
|
+
if (parentConfig) {
|
|
1167
|
+
warnings.push(`a ${CONFIG_FILENAME} already exists in ${dirname4(parentConfig)} \u2014 the one created here will shadow it for everything under ${dir}`);
|
|
1168
|
+
}
|
|
1169
|
+
const placeholders = !opts.baseUrl || !opts.project;
|
|
1170
|
+
writeFileSync(configPath, configTemplate(opts.baseUrl ?? PLACEHOLDER_URL, opts.project ?? PLACEHOLDER_PROJECT));
|
|
1171
|
+
const ignoreAdded = ensureIgnored(dir, warnings);
|
|
1172
|
+
return { configPath, placeholders, ignoreAdded, warnings };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1007
1175
|
// src/cli/help.ts
|
|
1008
1176
|
var HELP = `tracker \u2014 multi-backend issue tracking for humans and AI agents
|
|
1009
1177
|
|
|
1010
1178
|
usage: tracker <command> [args]
|
|
1011
1179
|
|
|
1180
|
+
setup:
|
|
1181
|
+
init [--base-url <url>] [--project <group/project>]
|
|
1182
|
+
scaffold tracker.config.json + .gitignore entries
|
|
1183
|
+
doctor verify config, token, connectivity, capabilities
|
|
1184
|
+
|
|
1012
1185
|
read commands (local cache, auto-sync when stale; all accept --json):
|
|
1013
1186
|
sync refresh the local cache from the provider
|
|
1014
1187
|
ready [--parent <id>] open + unblocked + unassigned + not in-progress
|
|
@@ -1020,7 +1193,6 @@ read commands (local cache, auto-sync when stale; all accept --json):
|
|
|
1020
1193
|
users <query> resolve usernames/names to user ids
|
|
1021
1194
|
whoami the authenticated user
|
|
1022
1195
|
memories [filter] list project memories
|
|
1023
|
-
doctor verify config, token, connectivity, capabilities
|
|
1024
1196
|
|
|
1025
1197
|
write commands:
|
|
1026
1198
|
create -t <title> [-d <desc>] [--parent <id>] [--epic <id>] [-l a,b]
|
|
@@ -1029,6 +1201,8 @@ write commands:
|
|
|
1029
1201
|
release <id> clear assignee/label, tombstone live claim tokens
|
|
1030
1202
|
close <id> [--reason <text>]
|
|
1031
1203
|
comment <id> <text> post a comment on an item
|
|
1204
|
+
spend <id> <duration> add time spent (1h30m, 45m, 2d; -30m subtracts)
|
|
1205
|
+
estimate <id> <duration> set the time estimate (0 clears it)
|
|
1032
1206
|
dep <id> --blocked-by <other> | --blocks <other>
|
|
1033
1207
|
parent <child-id> <parent-id>
|
|
1034
1208
|
remember <key> <text> store a project memory (key has no whitespace)
|
|
@@ -1098,6 +1272,21 @@ Lists the item's comments oldest-first (system notes are filtered out). Claim
|
|
|
1098
1272
|
notes (\uD83D\uDD12/\uD83D\uDD13) and memory entries (\uD83D\uDCCC) appear here too \u2014 useful for debugging.
|
|
1099
1273
|
|
|
1100
1274
|
example: tracker comments 42 --json`,
|
|
1275
|
+
spend: `usage: tracker spend <id> <duration>
|
|
1276
|
+
|
|
1277
|
+
Adds to the item's time spent. Durations use GitLab conventions:
|
|
1278
|
+
units w/d/h/m/s with 1d = 8h and 1w = 5d. A leading "-" subtracts.
|
|
1279
|
+
|
|
1280
|
+
examples:
|
|
1281
|
+
tracker spend 42 1h30m
|
|
1282
|
+
tracker spend 42 -30m # logged too much, take 30m back`,
|
|
1283
|
+
estimate: `usage: tracker estimate <id> <duration>
|
|
1284
|
+
|
|
1285
|
+
Sets the item's time estimate (same duration format as spend). 0 clears it.
|
|
1286
|
+
|
|
1287
|
+
examples:
|
|
1288
|
+
tracker estimate 42 2d
|
|
1289
|
+
tracker estimate 42 0`,
|
|
1101
1290
|
dep: `usage: tracker dep <id> --blocked-by <other> | --blocks <other>
|
|
1102
1291
|
|
|
1103
1292
|
examples:
|
|
@@ -1133,7 +1322,17 @@ example: tracker users mehmet`,
|
|
|
1133
1322
|
whoami: "usage: tracker whoami [--json]",
|
|
1134
1323
|
doctor: `usage: tracker doctor [--json]
|
|
1135
1324
|
|
|
1136
|
-
Verifies config, token, REST/GraphQL connectivity, capabilities, cache
|
|
1325
|
+
Verifies config, token, REST/GraphQL connectivity, capabilities, cache.`,
|
|
1326
|
+
init: `usage: tracker init [--base-url <url>] [--project <group/project>]
|
|
1327
|
+
|
|
1328
|
+
Scaffolds tracker.config.json in the current directory (placeholders when the
|
|
1329
|
+
flags are omitted) and, inside a git repository, git-ignores the local-only
|
|
1330
|
+
files (tracker.config.json, .tracker/, .env) so instance and project
|
|
1331
|
+
identifiers can never be committed. Refuses to overwrite an existing config.
|
|
1332
|
+
|
|
1333
|
+
examples:
|
|
1334
|
+
tracker init --base-url https://gitlab.example.com --project group/project
|
|
1335
|
+
tracker init # then edit the placeholders in tracker.config.json`
|
|
1137
1336
|
};
|
|
1138
1337
|
function commandHelp(cmd) {
|
|
1139
1338
|
return PER_COMMAND[cmd] ?? HELP;
|
|
@@ -1168,6 +1367,11 @@ function printItemDetail(item, blocks) {
|
|
|
1168
1367
|
console.log(`parent: ${item.parent ? `#${item.parent}` : "-"}`);
|
|
1169
1368
|
console.log(`blocked by: ${item.blockedBy.map((b) => `#${b}`).join(", ") || "-"}`);
|
|
1170
1369
|
console.log(`blocks: ${blocks.map((b) => `#${b}`).join(", ") || "-"}`);
|
|
1370
|
+
if (item.timeSpentSeconds || item.timeEstimateSeconds) {
|
|
1371
|
+
const spent = formatDuration(item.timeSpentSeconds);
|
|
1372
|
+
const estimate = item.timeEstimateSeconds ? ` / estimate ${formatDuration(item.timeEstimateSeconds)}` : "";
|
|
1373
|
+
console.log(`time: spent ${spent}${estimate}`);
|
|
1374
|
+
}
|
|
1171
1375
|
console.log(`updated: ${item.updatedAt}`);
|
|
1172
1376
|
if (item.description) {
|
|
1173
1377
|
console.log("---");
|
|
@@ -1185,7 +1389,7 @@ function parseArgs(args, spec, aliases = {}) {
|
|
|
1185
1389
|
const positionals = [];
|
|
1186
1390
|
for (let i = 0;i < args.length; i++) {
|
|
1187
1391
|
const arg = args[i];
|
|
1188
|
-
if (!arg.startsWith("-") || arg === "-") {
|
|
1392
|
+
if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) {
|
|
1189
1393
|
positionals.push(arg);
|
|
1190
1394
|
continue;
|
|
1191
1395
|
}
|
|
@@ -1217,8 +1421,8 @@ function buildCtx() {
|
|
|
1217
1421
|
token,
|
|
1218
1422
|
nativeBlocking: config.gitlab.native_blocking
|
|
1219
1423
|
});
|
|
1220
|
-
const cachePath =
|
|
1221
|
-
if (!
|
|
1424
|
+
const cachePath = resolve4(config.rootDir, config.cache.path);
|
|
1425
|
+
if (!existsSync4(cachePath)) {
|
|
1222
1426
|
const guard = guardCacheIgnored(config.rootDir, cachePath);
|
|
1223
1427
|
if (guard.action === "added" || guard.action === "warn")
|
|
1224
1428
|
console.error(`(${guard.detail})`);
|
|
@@ -1244,7 +1448,7 @@ async function cmdSync(ctx) {
|
|
|
1244
1448
|
const t0 = performance.now();
|
|
1245
1449
|
const { count } = await syncCache(ctx.adapter, ctx.cache);
|
|
1246
1450
|
const ms = Math.round(performance.now() - t0);
|
|
1247
|
-
console.log(`synced ${count} items in ${ms}ms \u2192 ${
|
|
1451
|
+
console.log(`synced ${count} items in ${ms}ms \u2192 ${resolve4(ctx.config.rootDir, ctx.config.cache.path)}`);
|
|
1248
1452
|
}
|
|
1249
1453
|
async function cmdReady(ctx, args) {
|
|
1250
1454
|
await freshen(ctx);
|
|
@@ -1381,6 +1585,38 @@ async function cmdMemories(ctx, args) {
|
|
|
1381
1585
|
for (const m of memories)
|
|
1382
1586
|
console.log(`${m.key} ${m.ts} ${m.text}`);
|
|
1383
1587
|
}
|
|
1588
|
+
function requireTimeTracking(ctx) {
|
|
1589
|
+
if (!ctx.adapter.capabilities().timeTracking) {
|
|
1590
|
+
throw new UsageError(`the ${ctx.adapter.provider} adapter does not support time tracking`);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
async function cmdSpend(ctx, args) {
|
|
1594
|
+
requireTimeTracking(ctx);
|
|
1595
|
+
const [idArg, durationArg] = args.positionals;
|
|
1596
|
+
const id = normalizeId(idArg);
|
|
1597
|
+
if (!durationArg)
|
|
1598
|
+
throw new UsageError(commandHelp("spend"));
|
|
1599
|
+
const seconds = parseDuration(durationArg);
|
|
1600
|
+
if (seconds === 0)
|
|
1601
|
+
throw new UsageError("spend needs a non-zero duration (e.g. 1h30m or -30m)");
|
|
1602
|
+
await ctx.adapter.addTimeSpent(id, seconds);
|
|
1603
|
+
invalidate(ctx);
|
|
1604
|
+
const item = await ctx.adapter.get(id);
|
|
1605
|
+
console.log(`#${id} ${seconds > 0 ? "spent" : "subtracted"} ${formatDuration(Math.abs(seconds))} \u2192 total ${formatDuration(item.timeSpentSeconds)}`);
|
|
1606
|
+
}
|
|
1607
|
+
async function cmdEstimate(ctx, args) {
|
|
1608
|
+
requireTimeTracking(ctx);
|
|
1609
|
+
const [idArg, durationArg] = args.positionals;
|
|
1610
|
+
const id = normalizeId(idArg);
|
|
1611
|
+
if (durationArg === undefined)
|
|
1612
|
+
throw new UsageError(commandHelp("estimate"));
|
|
1613
|
+
const seconds = parseDuration(durationArg);
|
|
1614
|
+
if (seconds < 0)
|
|
1615
|
+
throw new UsageError("an estimate cannot be negative (use 0 to clear it)");
|
|
1616
|
+
await ctx.adapter.setTimeEstimate(id, seconds);
|
|
1617
|
+
invalidate(ctx);
|
|
1618
|
+
console.log(seconds === 0 ? `#${id} estimate cleared` : `#${id} estimate ${formatDuration(seconds)}`);
|
|
1619
|
+
}
|
|
1384
1620
|
async function cmdComment(ctx, args) {
|
|
1385
1621
|
const [idArg, ...textParts] = args.positionals;
|
|
1386
1622
|
const id = normalizeId(idArg);
|
|
@@ -1462,7 +1698,7 @@ async function cmdDoctor(args) {
|
|
|
1462
1698
|
name: "config",
|
|
1463
1699
|
status: "fail",
|
|
1464
1700
|
detail: e.message,
|
|
1465
|
-
fix: "
|
|
1701
|
+
fix: "run `tracker init` in the project root to create tracker.config.json"
|
|
1466
1702
|
});
|
|
1467
1703
|
}
|
|
1468
1704
|
let ctx = null;
|
|
@@ -1529,7 +1765,7 @@ async function cmdDoctor(args) {
|
|
|
1529
1765
|
status: "ok",
|
|
1530
1766
|
detail: last ? `${ctx.cache.count()} items, synced ${Math.round((Date.now() - last) / 60000)}m ago` : "empty (run: tracker sync)"
|
|
1531
1767
|
});
|
|
1532
|
-
const cachePath =
|
|
1768
|
+
const cachePath = resolve4(config.rootDir, config.cache.path);
|
|
1533
1769
|
const gitRoot = findGitRoot(config.rootDir);
|
|
1534
1770
|
if (gitRoot) {
|
|
1535
1771
|
const ignored = isGitIgnored(gitRoot, cachePath);
|
|
@@ -1557,6 +1793,21 @@ all good.`);
|
|
|
1557
1793
|
}
|
|
1558
1794
|
return failed ? 1 : 0;
|
|
1559
1795
|
}
|
|
1796
|
+
function cmdInit(args) {
|
|
1797
|
+
const result = initProject(process.cwd(), {
|
|
1798
|
+
baseUrl: str(args, "--base-url"),
|
|
1799
|
+
project: str(args, "--project")
|
|
1800
|
+
});
|
|
1801
|
+
console.log(`created ${result.configPath}`);
|
|
1802
|
+
for (const pattern of result.ignoreAdded)
|
|
1803
|
+
console.log(`added "${pattern}" to .gitignore`);
|
|
1804
|
+
for (const warning of result.warnings)
|
|
1805
|
+
console.error(`warning: ${warning}`);
|
|
1806
|
+
if (result.placeholders) {
|
|
1807
|
+
console.log("next: edit gitlab.base_url and gitlab.project in the config");
|
|
1808
|
+
}
|
|
1809
|
+
console.log("then: export TRACKER_GITLAB_TOKEN (or add it to .env) and run: tracker doctor");
|
|
1810
|
+
}
|
|
1560
1811
|
var VALUE_FLAGS = {
|
|
1561
1812
|
ready: { "--parent": "value", "--json": "bool" },
|
|
1562
1813
|
show: { "--json": "bool" },
|
|
@@ -1586,9 +1837,12 @@ var VALUE_FLAGS = {
|
|
|
1586
1837
|
users: { "--json": "bool" },
|
|
1587
1838
|
whoami: { "--json": "bool" },
|
|
1588
1839
|
doctor: { "--json": "bool" },
|
|
1840
|
+
init: { "--base-url": "value", "--project": "value" },
|
|
1589
1841
|
memories: { "--json": "bool" },
|
|
1590
1842
|
comment: {},
|
|
1591
1843
|
comments: { "--json": "bool" },
|
|
1844
|
+
spend: {},
|
|
1845
|
+
estimate: {},
|
|
1592
1846
|
sync: {},
|
|
1593
1847
|
claim: {},
|
|
1594
1848
|
release: {},
|
|
@@ -1624,6 +1878,10 @@ ${HELP}`);
|
|
|
1624
1878
|
return 0;
|
|
1625
1879
|
}
|
|
1626
1880
|
const args = parseArgs(rest, spec, ALIASES[cmd] ?? {});
|
|
1881
|
+
if (cmd === "init") {
|
|
1882
|
+
cmdInit(args);
|
|
1883
|
+
return 0;
|
|
1884
|
+
}
|
|
1627
1885
|
if (cmd === "doctor")
|
|
1628
1886
|
return cmdDoctor(args);
|
|
1629
1887
|
const ctx = buildCtx();
|
|
@@ -1644,6 +1902,8 @@ ${HELP}`);
|
|
|
1644
1902
|
memories: cmdMemories,
|
|
1645
1903
|
comment: cmdComment,
|
|
1646
1904
|
comments: cmdComments,
|
|
1905
|
+
spend: cmdSpend,
|
|
1906
|
+
estimate: cmdEstimate,
|
|
1647
1907
|
search: cmdSearch,
|
|
1648
1908
|
users: cmdUsers,
|
|
1649
1909
|
whoami: cmdWhoami
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackerctl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|