ticketlens 0.2.0 → 0.2.1
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 +39 -1
- package/bin/ticketlens.mjs +9 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +1 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +3 -0
- package/skills/jtb/scripts/lib/branch-scanner.mjs +56 -0
- package/skills/jtb/scripts/lib/collision-reporter.mjs +45 -0
- package/skills/jtb/scripts/lib/config.mjs +9 -5
- package/skills/jtb/scripts/lib/run-collisions.mjs +64 -0
- package/skills/jtb/scripts/lib/triage-push.mjs +3 -0
package/README.md
CHANGED
|
@@ -291,6 +291,25 @@ ticketlens activate <KEY> # Activate a Pro or Team license
|
|
|
291
291
|
|
|
292
292
|
---
|
|
293
293
|
|
|
294
|
+
### Update Skill
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
ticketlens update-skill # Sync /jtb skill to all detected AI assistants
|
|
298
|
+
ticketlens update-skill --dry-run # Preview what would be updated (no writes)
|
|
299
|
+
ticketlens update-skill --path=~/.gemini/commands # Sync to a specific assistant directory
|
|
300
|
+
ticketlens update-skill --quiet # Suppress output (useful in scripts)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
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.
|
|
304
|
+
|
|
305
|
+
Supported assistants detected automatically:
|
|
306
|
+
- Claude Code — `~/.claude/commands/jtb.md`
|
|
307
|
+
- Claude Code (work) — `~/.claude-work/commands/jtb.md`
|
|
308
|
+
- Gemini CLI — `~/.gemini/commands/jtb.md`
|
|
309
|
+
- Copilot CLI — `~/.copilot-cli/commands/jtb.md`
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
294
313
|
### /jtb — Jira TicketBrief for Claude Code
|
|
295
314
|
|
|
296
315
|
`/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 +320,25 @@ ticketlens activate <KEY> # Activate a Pro or Team license
|
|
|
301
320
|
|
|
302
321
|
```bash
|
|
303
322
|
npm install -g ticketlens && ticketlens init
|
|
304
|
-
|
|
323
|
+
ticketlens update-skill # copies /jtb skill into ~/.claude/commands/jtb.md
|
|
305
324
|
# Restart Claude Code, then:
|
|
306
325
|
# /jtb CNV1-2
|
|
307
326
|
```
|
|
308
327
|
|
|
328
|
+
**Keeping the skill up to date:**
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
npm install -g ticketlens@latest # update the CLI
|
|
332
|
+
ticketlens update-skill # sync the /jtb skill to the new version
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`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.
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
ticketlens update-skill --dry-run # preview what would change
|
|
339
|
+
ticketlens update-skill --path=~/.gemini/commands # sync to a different AI assistant
|
|
340
|
+
```
|
|
341
|
+
|
|
309
342
|
**Usage in Claude Code:**
|
|
310
343
|
|
|
311
344
|
```
|
|
@@ -432,6 +465,11 @@ ticketlens login --manual # Paste flow — for CI/headless e
|
|
|
432
465
|
ticketlens license # Show license tier and status
|
|
433
466
|
ticketlens activate <LICENSE-KEY> # Activate a license key
|
|
434
467
|
|
|
468
|
+
# ── Skill maintenance ─────────────────────────────────────────────────────────
|
|
469
|
+
ticketlens update-skill # Sync /jtb skill to all detected AI assistants
|
|
470
|
+
ticketlens update-skill --dry-run # Preview what would be updated
|
|
471
|
+
ticketlens update-skill --path=~/.gemini/commands # Sync to a custom assistant directory
|
|
472
|
+
|
|
435
473
|
# ── Help and version ──────────────────────────────────────────────────────────
|
|
436
474
|
ticketlens --help # Main help
|
|
437
475
|
ticketlens --version # Show installed version
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -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
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.2.
|
|
1
|
+
<!-- jtb-skill-version: 0.2.1 -->
|
|
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.
|
|
@@ -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
|
|
19
|
-
const pkg = JSON.parse(readFileSync(
|
|
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';
|
|
@@ -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 {
|