ticketlens 0.2.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 CHANGED
@@ -164,6 +164,27 @@ Bot comments (Jira Automation, Jenkins, GitHub Actions) are automatically ignore
164
164
 
165
165
  ---
166
166
 
167
+ ### Collisions
168
+
169
+ ```bash
170
+ ticketlens collisions # Show branches that overlap with teammates [Team]
171
+ ticketlens collisions --json # Machine-readable output
172
+ ticketlens collisions --plain # Plain text, no ANSI colour
173
+ ```
174
+
175
+ Requires a Team license and at least one teammate in your group. Compares your current git branch's changed files against your teammates' recent branches (within 7 days). Reports each overlap as a collision: your branch, their branch, the shared files, and linked ticket keys.
176
+
177
+ ```
178
+ [1] feat/auth-refactor ↔ Jane Dev (feat/login-redesign)
179
+ Your tickets: PROJ-101
180
+ Their tickets: PROJ-88
181
+ Shared files: src/auth/LoginController.php, src/auth/guards.php
182
+ ```
183
+
184
+ Branches are captured automatically when you run `ticketlens triage --push`. No extra step required.
185
+
186
+ ---
187
+
167
188
  ### Review
168
189
 
169
190
  ```bash
@@ -291,6 +312,25 @@ ticketlens activate <KEY> # Activate a Pro or Team license
291
312
 
292
313
  ---
293
314
 
315
+ ### Update Skill
316
+
317
+ ```bash
318
+ ticketlens update-skill # Sync /jtb skill to all detected AI assistants
319
+ ticketlens update-skill --dry-run # Preview what would be updated (no writes)
320
+ ticketlens update-skill --path=~/.gemini/commands # Sync to a specific assistant directory
321
+ ticketlens update-skill --quiet # Suppress output (useful in scripts)
322
+ ```
323
+
324
+ Copies the latest `SKILL.md` to every AI assistant command directory where `/jtb` is already installed. Runs automatically on `npm install -g ticketlens` — for most users, upgrading the CLI is enough. Use `--dry-run` to confirm what would change before writing.
325
+
326
+ Supported assistants detected automatically:
327
+ - Claude Code — `~/.claude/commands/jtb.md`
328
+ - Claude Code (work) — `~/.claude-work/commands/jtb.md`
329
+ - Gemini CLI — `~/.gemini/commands/jtb.md`
330
+ - Copilot CLI — `~/.copilot-cli/commands/jtb.md`
331
+
332
+ ---
333
+
294
334
  ### /jtb — Jira TicketBrief for Claude Code
295
335
 
296
336
  `/jtb` is a Claude Code slash command that fetches full ticket context and drops a structured implementation brief directly into your session, then enters plan mode.
@@ -301,11 +341,25 @@ ticketlens activate <KEY> # Activate a Pro or Team license
301
341
 
302
342
  ```bash
303
343
  npm install -g ticketlens && ticketlens init
304
- cp $(npm root -g)/ticketlens/skills/jtb/SKILL.md ~/.claude/commands/jtb.md
344
+ ticketlens update-skill # copies /jtb skill into ~/.claude/commands/jtb.md
305
345
  # Restart Claude Code, then:
306
346
  # /jtb CNV1-2
307
347
  ```
308
348
 
349
+ **Keeping the skill up to date:**
350
+
351
+ ```bash
352
+ npm install -g ticketlens@latest # update the CLI
353
+ ticketlens update-skill # sync the /jtb skill to the new version
354
+ ```
355
+
356
+ `update-skill` runs automatically on `npm install -g`, so for most users the second step is handled. If you manage Claude Code across multiple machines or accounts, run it manually after updating.
357
+
358
+ ```bash
359
+ ticketlens update-skill --dry-run # preview what would change
360
+ ticketlens update-skill --path=~/.gemini/commands # sync to a different AI assistant
361
+ ```
362
+
309
363
  **Usage in Claude Code:**
310
364
 
311
365
  ```
@@ -384,6 +438,11 @@ ticketlens triage --profile=acme --stale=3 --static # Combine flags
384
438
  ticketlens triage --plain > my-tickets.md
385
439
  ticketlens triage --plain | llm "Which ticket is most urgent and why?"
386
440
 
441
+ # ── Collisions ────────────────────────────────────────────────────────────────
442
+ ticketlens collisions # Show branch collisions with teammates [Team]
443
+ ticketlens collisions --json # Machine-readable JSON output
444
+ ticketlens collisions --plain # Plain text, no ANSI colour
445
+
387
446
  # ── PR Review ─────────────────────────────────────────────────────────────────
388
447
  ticketlens review # Assemble PR review context from current branch
389
448
  ticketlens review --branch=main # Compare against main (auto-detected by default)
@@ -432,6 +491,11 @@ ticketlens login --manual # Paste flow — for CI/headless e
432
491
  ticketlens license # Show license tier and status
433
492
  ticketlens activate <LICENSE-KEY> # Activate a license key
434
493
 
494
+ # ── Skill maintenance ─────────────────────────────────────────────────────────
495
+ ticketlens update-skill # Sync /jtb skill to all detected AI assistants
496
+ ticketlens update-skill --dry-run # Preview what would be updated
497
+ ticketlens update-skill --path=~/.gemini/commands # Sync to a custom assistant directory
498
+
435
499
  # ── Help and version ──────────────────────────────────────────────────────────
436
500
  ticketlens --help # Main help
437
501
  ticketlens --version # Show installed version
@@ -52,6 +52,15 @@ switch (command) {
52
52
  });
53
53
  break;
54
54
 
55
+ case 'collisions': {
56
+ const { runCollisions } = await import('../skills/jtb/scripts/lib/run-collisions.mjs');
57
+ runCollisions(cmdArgs).catch(err => {
58
+ process.stderr.write(`Error: ${err.message}\n`);
59
+ process.exitCode = 1;
60
+ });
61
+ break;
62
+ }
63
+
55
64
  case 'init':
56
65
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
57
66
  runInit().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.2.0 -->
1
+ <!-- jtb-skill-version: 0.3.0 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -27,6 +27,10 @@ Fetches a Jira ticket and produces a structured brief with code references, then
27
27
  /jtb triage --stale=3 # custom aging threshold (days)
28
28
  /jtb triage --status=CR,QA # only check specific statuses
29
29
  /jtb triage --profile=acme # explicit profile override
30
+ /jtb triage --push # push snapshot + git branches to Console (Team)
31
+ /jtb triage --share # generate 24h share URL (Team)
32
+ /jtb collisions # show branch collisions with teammates (Team)
33
+ /jtb collisions --json # machine-readable output
30
34
  ```
31
35
 
32
36
  ## Prerequisites
@@ -63,6 +67,23 @@ Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=
63
67
 
64
68
  ---
65
69
 
70
+ ### Collisions subcommand
71
+
72
+ If the first argument is `collisions`:
73
+
74
+ Run:
75
+ ```bash
76
+ node ~/.agents/skills/jtb/scripts/lib/run-collisions.mjs $EXTRA_ARGS
77
+ ```
78
+
79
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--json`, `--plain`).
80
+
81
+ Requires a Team license and at least one teammate in the same group. Compares your current branch's changed files against teammates' recent branches. Outputs a collision report or an empty-state message.
82
+
83
+ Display the script's stdout directly. No plan mode. Stop here.
84
+
85
+ ---
86
+
66
87
  ### Fetch ticket workflow (default)
67
88
 
68
89
  ### Step 1: Validate environment
@@ -354,7 +354,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
354
354
 
355
355
  if (pushFlag) {
356
356
  const { pushTriageSnapshot } = await import('./lib/triage-push.mjs');
357
+ const { scanCurrentBranch } = await import('./lib/branch-scanner.mjs');
357
358
  const pushFn = opts.pushFn ?? pushTriageSnapshot;
359
+ const scanFn = opts.scanFn ?? scanCurrentBranch;
358
360
  const licenseKey = readLicense(configDir)?.key ?? null;
359
361
  const printFn = opts.print ?? ((s) => process.stdout.write(s));
360
362
  await pushFn({
@@ -363,6 +365,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
363
365
  profile: profileName ?? 'default',
364
366
  baseUrl: conn.baseUrl,
365
367
  licenseKey,
368
+ gitBranches: scanFn(),
366
369
  fetcher,
367
370
  print: printFn,
368
371
  });
@@ -0,0 +1,56 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ const TICKET_KEY_RE = /([A-Z][A-Z0-9]+-\d+)/g;
6
+ const SPAWN_OPTS = { encoding: 'utf8', timeout: 10_000 };
7
+ const MAX_FILES = 200;
8
+ const BASE_CANDIDATES = ['origin/main', 'origin/master', 'origin/develop', 'main', 'master'];
9
+
10
+ function run(execFn, args, cwd) {
11
+ const result = execFn('git', args, { ...SPAWN_OPTS, cwd, stdio: 'pipe' });
12
+ return result.status === 0 ? (result.stdout ?? '') : null;
13
+ }
14
+
15
+ function extractTicketKeys(text) {
16
+ return [...new Set([...text.matchAll(TICKET_KEY_RE)].map(m => m[1]))];
17
+ }
18
+
19
+ function detectBase(execFn, cwd) {
20
+ for (const candidate of BASE_CANDIDATES) {
21
+ if (run(execFn, ['rev-parse', '--verify', candidate], cwd) !== null) return candidate;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * Scans the current git branch for changed files and linked ticket keys.
28
+ *
29
+ * @param {object} [opts]
30
+ * @param {string} [opts.cwd] Working directory (default: process.cwd())
31
+ * @param {Function} [opts.execFn] spawnSync replacement (injectable for tests)
32
+ * @param {Function} [opts.fsCheck] existsSync replacement (injectable for tests)
33
+ * @returns {Array<{branch:string, base:string|null, tickets:string[], files:string[]}>|null}
34
+ * One-element array for the current branch, or null if not in a git repo / detached HEAD.
35
+ */
36
+ export function scanCurrentBranch({ cwd = process.cwd(), execFn = spawnSync, fsCheck = existsSync } = {}) {
37
+ if (!fsCheck(join(cwd, '.git'))) return null;
38
+
39
+ const branch = run(execFn, ['rev-parse', '--abbrev-ref', 'HEAD'], cwd)?.trim();
40
+ if (!branch || branch === 'HEAD') return null;
41
+
42
+ const base = detectBase(execFn, cwd);
43
+
44
+ let files = [];
45
+ if (base) {
46
+ const diffOut = run(execFn, ['diff', '--name-only', `${base}..HEAD`], cwd);
47
+ if (diffOut) {
48
+ files = diffOut.trim().split('\n').filter(Boolean).slice(0, MAX_FILES);
49
+ }
50
+ }
51
+
52
+ const recentLog = run(execFn, ['log', '--oneline', '-20'], cwd) ?? '';
53
+ const tickets = extractTicketKeys(`${branch} ${recentLog}`);
54
+
55
+ return [{ branch, base, tickets, files }];
56
+ }
@@ -0,0 +1,45 @@
1
+ const INDENT = ' ';
2
+
3
+ /**
4
+ * Formats a collision list from the /v1/triage/collisions API response.
5
+ *
6
+ * @param {object[]} collisions
7
+ * @param {object} [opts]
8
+ * @param {boolean} [opts.plain] Suppress ANSI (forced when not TTY)
9
+ * @param {boolean} [opts.isTTY] Enable ANSI escape sequences
10
+ * @param {boolean} [opts.json] Emit raw JSON instead of text
11
+ * @returns {string}
12
+ */
13
+ export function formatCollisions(collisions, { plain = false, isTTY = false, json = false } = {}) {
14
+ if (json) return JSON.stringify(collisions, null, 2) + '\n';
15
+
16
+ const useAnsi = isTTY && !plain;
17
+ const warn = useAnsi ? '\x1b[33m⚠\x1b[0m' : '⚠';
18
+ const ok = useAnsi ? '\x1b[32m✓\x1b[0m' : '✓';
19
+ const bold = useAnsi ? (s) => `\x1b[1m${s}\x1b[0m` : (s) => s;
20
+ const dim = useAnsi ? (s) => `\x1b[2m${s}\x1b[0m` : (s) => s;
21
+
22
+ if (collisions.length === 0) {
23
+ return `${ok} No branch collisions detected.\n`;
24
+ }
25
+
26
+ const n = collisions.length;
27
+ const lines = [`${warn} ${bold(`${n} collision${n === 1 ? '' : 's'} detected`)}\n`];
28
+
29
+ for (let i = 0; i < n; i++) {
30
+ const c = collisions[i];
31
+ const yourKeys = c.your_tickets?.length ? c.your_tickets.join(', ') : '—';
32
+ const theirKeys = c.their_tickets?.length ? c.their_tickets.join(', ') : '—';
33
+ const count = c.shared_files?.length ?? 0;
34
+
35
+ lines.push(
36
+ ` [${i + 1}] ${bold('You')} ${dim(`(${c.your_branch})`)} ↔ ${bold(c.teammate)} ${dim(`(${c.their_branch})`)}`,
37
+ ` Tickets: ${yourKeys} ↔ ${theirKeys}`,
38
+ ` Shared files (${count}):`,
39
+ ...(c.shared_files ?? []).map(f => `${INDENT}${f}`),
40
+ '',
41
+ );
42
+ }
43
+
44
+ return lines.join('\n');
45
+ }
@@ -3,20 +3,24 @@
3
3
  * Single source of truth — import from here, do not redefine locally.
4
4
  */
5
5
 
6
- import { readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
6
+ import { readFileSync, realpathSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import { homedir } from 'node:os';
9
10
 
10
11
  /** Canonical config directory: ~/.ticketlens */
11
12
  export const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
12
13
 
13
- /** Read package.json version once per process, memoized. */
14
+ /** Read package.json version once per process, memoized.
15
+ * Uses realpathSync to resolve symlinks before navigating, so the correct
16
+ * package.json is found whether the module is loaded via a symlink or directly.
17
+ */
14
18
  let _version;
15
19
  export function getVersion() {
16
20
  if (_version) return _version;
17
21
  try {
18
- const pkgPath = new URL('../../../../package.json', import.meta.url);
19
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
22
+ const realDir = dirname(realpathSync(fileURLToPath(import.meta.url)));
23
+ const pkg = JSON.parse(readFileSync(join(realDir, '..', '..', '..', '..', 'package.json'), 'utf8'));
20
24
  _version = pkg.version || '0.0.0';
21
25
  } catch {
22
26
  _version = '0.0.0';
@@ -38,6 +38,7 @@ export function printHelp({ stream = process.stdout } = {}) {
38
38
  ` ${s.brand('ticketlens')} ${s.dim('<TICKET-KEY>')} ${s.dim('[options]')} Fetch a ticket brief`,
39
39
  ` ${s.brand('ticketlens')} get ${s.dim('<TICKET-KEY>')} Same as above ${s.dim('(explicit alias)')}`,
40
40
  ` ${s.brand('ticketlens')} triage ${s.dim('[options]')} Scan your assigned tickets`,
41
+ ` ${s.brand('ticketlens')} collisions ${s.dim('[--json]')} Show branch collisions with teammates ${s.dim('[Team]')}`,
41
42
  ` ${s.brand('ticketlens')} review ${s.dim('[--branch=BRANCH]')} Code-review context brief from current branch`,
42
43
  ` ${s.brand('ticketlens')} standup ${s.dim('[--since=N]')} Standup summary from git log ${s.dim('(last 24h by default)')}`,
43
44
  ` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
@@ -0,0 +1,64 @@
1
+ import { readLicense } from './license.mjs';
2
+ import { formatCollisions } from './collision-reporter.mjs';
3
+
4
+ const COLLISIONS_PATH = '/v1/triage/collisions';
5
+ const DEFAULT_API_BASE = 'http://ticketlens.test';
6
+
7
+ function apiBase() {
8
+ return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
9
+ }
10
+
11
+ /**
12
+ * Fetches cross-team branch collision data from the TicketLens API and prints results.
13
+ *
14
+ * @param {string[]} args CLI argument array (e.g. ['--json', '--plain'])
15
+ * @param {object} [opts]
16
+ * @param {Function} [opts.fetcher] Injectable fetch
17
+ * @param {Function} [opts.print] Output function
18
+ * @param {Function} [opts.readLicenseFn] Injectable license reader
19
+ * @returns {Promise<{ ok: boolean, status?: number }>}
20
+ */
21
+ export async function runCollisions(args = [], opts = {}) {
22
+ const jsonFlag = args.includes('--json');
23
+ const plainFlag = args.includes('--plain');
24
+ const fetcher = opts.fetcher ?? globalThis.fetch;
25
+ const print = opts.print ?? ((s) => process.stdout.write(s));
26
+ const readLicenseFn = opts.readLicenseFn ?? (() => readLicense());
27
+
28
+ const licenseKey = readLicenseFn()?.key ?? null;
29
+ if (!licenseKey) {
30
+ print('✗ collisions requires an active Team license (ticketlens activate <key>)\n');
31
+ return { ok: false };
32
+ }
33
+
34
+ try {
35
+ const res = await fetcher(`${apiBase()}${COLLISIONS_PATH}`, {
36
+ headers: { Authorization: `Bearer ${licenseKey}` },
37
+ });
38
+
39
+ if (!res.ok) {
40
+ if (res.status === 401) { print('✗ Invalid license key\n'); return { ok: false, status: 401 }; }
41
+ if (res.status === 403) { print('✗ collisions requires a Team license\n'); return { ok: false, status: 403 }; }
42
+ print(`⚠ Failed to fetch collisions (${res.status})\n`);
43
+ return { ok: false, status: res.status };
44
+ }
45
+
46
+ const { collisions = [], message } = await res.json();
47
+
48
+ if (message && collisions.length === 0) {
49
+ print(message + '\n');
50
+ return { ok: true };
51
+ }
52
+
53
+ print(formatCollisions(collisions, {
54
+ json: jsonFlag,
55
+ plain: plainFlag,
56
+ isTTY: process.stdout.isTTY ?? false,
57
+ }));
58
+
59
+ return { ok: true };
60
+ } catch {
61
+ print('⚠ Failed to fetch collisions (network error)\n');
62
+ return { ok: false };
63
+ }
64
+ }
@@ -44,6 +44,7 @@ function buildTicketPayload(scored, rawMap, baseUrl) {
44
44
  * @param {string} [opts.baseUrl] - Jira base URL for URL construction
45
45
  * @param {string} [opts.licenseKey] - Bearer token for the API
46
46
  * @param {string} [opts.capturedAt] - ISO 8601 timestamp (defaults to now)
47
+ * @param {Array} [opts.gitBranches] - Branch metadata from scanCurrentBranch (null = not in git repo)
47
48
  * @param {Function} [opts.fetcher] - Injectable fetch (default: globalThis.fetch)
48
49
  * @param {Function} [opts.print] - Output fn (default: process.stdout.write)
49
50
  * @returns {Promise<{ ok: boolean, status?: number }>}
@@ -55,6 +56,7 @@ export async function pushTriageSnapshot({
55
56
  baseUrl,
56
57
  licenseKey,
57
58
  capturedAt,
59
+ gitBranches,
58
60
  fetcher = globalThis.fetch,
59
61
  print = (s) => process.stdout.write(s),
60
62
  } = {}) {
@@ -67,6 +69,7 @@ export async function pushTriageSnapshot({
67
69
  profile: String(profile ?? 'default').slice(0, 100),
68
70
  captured_at: capturedAt ?? new Date().toISOString(),
69
71
  tickets: sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl)),
72
+ ...(gitBranches != null && { git_branches: gitBranches }),
70
73
  };
71
74
 
72
75
  try {