ticketlens 0.38.2 → 0.38.4
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 +1 -1
- package/package.json +1 -1
- package/scripts/leak-scanner.mjs +86 -0
- package/scripts/preflight.mjs +39 -7
- package/scripts/publish.mjs +12 -0
- package/skills/jtb/SKILL.md +19 -5
- package/skills/jtb/scripts/fetch-my-tickets.mjs +1 -1
package/README.md
CHANGED
|
@@ -451,7 +451,7 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
|
|
|
451
451
|
|
|
452
452
|
`ticketlens create` creates a new ticket with a fixed minimal field set — no arbitrary custom fields. Unlike every other write command, there's no existing ticket to target, so `--profile` (or your default profile) picks the tracker instead of a ticket key. `--project` is the Jira project key or Linear team key — required for both, ignored on GitHub since its target repo is already fixed by the profile. `--type` is Jira's issue type (e.g. `"Task"`, `"Bug"`) — required for Jira, ignored elsewhere. No `--confirm` gate, same risk tier as `update`/`assign` — but this is the highest-blast-radius command in the whole family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker, so an invalid value surfaces the tracker's own error rather than a silent guess.
|
|
453
453
|
|
|
454
|
-
**A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1,
|
|
454
|
+
**A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, CNV2.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 24h so a burst of failed attempts doesn't re-fetch every time.
|
|
455
455
|
|
|
456
456
|
All six write actions (comment/transition/assign/link/update/create) have a short local debounce (10s) against an accidental double-fire (a flaky retry, hitting enter twice), and every successful write is appended to a local, append-only audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — unlike Recall notes, ticket writes aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated. `duplicates` has neither, since nothing is written.
|
|
457
457
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// This module's own resolved path — its BANNED_PATTERNS regex literals
|
|
6
|
+
// necessarily contain the banned substrings as source text, so it must
|
|
7
|
+
// exclude itself from any scan or it would permanently flag itself as a
|
|
8
|
+
// leak. Exposed as a parameter (not hardcoded in scanForLeaks) so tests can
|
|
9
|
+
// verify the exclusion is path-exact, not a basename match that could also
|
|
10
|
+
// skip an unrelated shipped file that happens to share this name.
|
|
11
|
+
const SELF_PATH = resolve(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
// Files npm ships in every tarball regardless of package.json's `files`
|
|
14
|
+
// list (npm always includes these three, unconditionally).
|
|
15
|
+
export const ALWAYS_SHIPPED = ['package.json', 'README.md', 'LICENSE'];
|
|
16
|
+
|
|
17
|
+
export const BANNED_PATTERNS = [
|
|
18
|
+
// Not an English word — safe to match bare, without requiring the "-NNNN" suffix.
|
|
19
|
+
{ name: 'Advent Jira ticket prefix', pattern: /\bECNT\b/i },
|
|
20
|
+
// "ASAP" is ordinary English; only match the ticket-key shape to avoid false positives.
|
|
21
|
+
{ name: 'ASAP gateway ticket prefix', pattern: /\bASAP-\d+\b/i },
|
|
22
|
+
{ name: 'pilot-client wrapper command name', pattern: /advent-ticket/i },
|
|
23
|
+
// Case-sensitive: "Advent" (proper noun, the employer) vs. lowercase "advent"
|
|
24
|
+
// (ordinary word, e.g. "the advent of AI-assisted development").
|
|
25
|
+
{ name: 'employer name', pattern: /\bAdvent\b/ },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function isDirEntry(absPath, entry) {
|
|
29
|
+
return entry.endsWith('/') || statSync(absPath).isDirectory();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function collectFiles(root, entry) {
|
|
33
|
+
const absPath = join(root, entry);
|
|
34
|
+
if (!existsSync(absPath)) return [];
|
|
35
|
+
if (!isDirEntry(absPath, entry)) return [absPath];
|
|
36
|
+
|
|
37
|
+
const files = [];
|
|
38
|
+
const walk = (dir) => {
|
|
39
|
+
for (const dirent of readdirSync(dir, { withFileTypes: true })) {
|
|
40
|
+
const full = join(dir, dirent.name);
|
|
41
|
+
if (dirent.isDirectory()) walk(full);
|
|
42
|
+
else files.push(full);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(absPath);
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findViolations(root, absPath) {
|
|
50
|
+
const relPath = relative(root, absPath);
|
|
51
|
+
let content;
|
|
52
|
+
try {
|
|
53
|
+
content = readFileSync(absPath, 'utf8');
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return [`${relPath} — could not read file to scan for leaks: ${err.message}`];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const violations = [];
|
|
59
|
+
content.split('\n').forEach((line, index) => {
|
|
60
|
+
for (const { name, pattern } of BANNED_PATTERNS) {
|
|
61
|
+
if (pattern.test(line)) {
|
|
62
|
+
violations.push(`${relPath}:${index + 1} — ${name} (matched ${pattern})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return violations;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Scans every file npm actually ships — package.json's `files` list plus
|
|
72
|
+
* the README/LICENSE/package.json npm always includes regardless of `files`
|
|
73
|
+
* — for employer/pilot-client identifying patterns.
|
|
74
|
+
* @param {string} root absolute path to the package root
|
|
75
|
+
* @param {string[]} filesList package.json's `files` array
|
|
76
|
+
* @param {string} [selfPath] path to exclude from the scan (defaults to this module's own path)
|
|
77
|
+
* @returns {string[]} violation descriptions ("path:line — name (matched /re/)"), empty if clean
|
|
78
|
+
*/
|
|
79
|
+
export function scanForLeaks(root, filesList, selfPath = SELF_PATH) {
|
|
80
|
+
const entries = [...new Set([...filesList, ...ALWAYS_SHIPPED])];
|
|
81
|
+
|
|
82
|
+
return entries
|
|
83
|
+
.flatMap(entry => collectFiles(root, entry))
|
|
84
|
+
.filter(absPath => resolve(absPath) !== resolve(selfPath))
|
|
85
|
+
.flatMap(absPath => findViolations(root, absPath));
|
|
86
|
+
}
|
package/scripts/preflight.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { scanForLeaks } from './leak-scanner.mjs';
|
|
4
5
|
|
|
5
6
|
const LOCAL_RE = /localhost|127\.0\.0\.1|\.test(:\d+)?(\/|$)/i;
|
|
6
7
|
|
|
@@ -24,23 +25,54 @@ export function checkApiBase(defaultApiBase, tag = 'latest') {
|
|
|
24
25
|
return { ok: true, reason: `production URL looks good (${defaultApiBase})` };
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} root absolute path to the package root
|
|
30
|
+
* @param {string[]} filesList package.json's `files` array
|
|
31
|
+
* @returns {{ ok: boolean, reason: string }}
|
|
32
|
+
*/
|
|
33
|
+
export function checkForLeaks(root, filesList) {
|
|
34
|
+
const violations = scanForLeaks(root, filesList);
|
|
35
|
+
if (violations.length > 0) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
reason: `Found ${violations.length} employer/pilot-client leak(s) in shipped files:\n` +
|
|
39
|
+
violations.map(v => ` - ${v}`).join('\n'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, reason: 'no employer/pilot-client leaks found in shipped files' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Prints each check's reason. On any failure, prints failures to stderr as
|
|
47
|
+
* ERRORs and exits 1 (no successes are printed in that case). Only reached
|
|
48
|
+
* when every check passes does it print each success reason to stdout.
|
|
49
|
+
* @param {{ ok: boolean, reason: string }[]} checks
|
|
50
|
+
* @param {string} logPrefix e.g. '[preflight]' or '[publish] Preflight:'
|
|
51
|
+
*/
|
|
52
|
+
export function runChecks(checks, logPrefix) {
|
|
53
|
+
const failed = checks.filter(c => !c.ok);
|
|
54
|
+
if (failed.length > 0) {
|
|
55
|
+
for (const { reason } of failed) process.stderr.write(`\n${logPrefix} ERROR: ${reason}\n`);
|
|
56
|
+
process.stderr.write('\n');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
for (const { reason } of checks) process.stdout.write(`${logPrefix} ${reason}\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
27
62
|
// Only run when invoked directly (not when imported by tests)
|
|
28
63
|
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
29
64
|
if (isMain) {
|
|
30
65
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
const root = resolve(__dir, '..');
|
|
31
67
|
const apiUtilsPath = join(__dir, '../skills/jtb/scripts/lib/api-utils.mjs');
|
|
32
68
|
const source = readFileSync(apiUtilsPath, 'utf8');
|
|
33
69
|
const match = source.match(/export const DEFAULT_API_BASE\s*=\s*'([^']+)'/);
|
|
34
70
|
const defaultApiBase = match?.[1] ?? '';
|
|
71
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
35
72
|
|
|
36
73
|
const tag = process.env.npm_config_tag;
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
if (!ok) {
|
|
40
|
-
process.stderr.write(`\n[preflight] ERROR: ${reason}\n\n`);
|
|
41
|
-
process.exit(1);
|
|
42
|
-
}
|
|
74
|
+
const checks = [checkApiBase(defaultApiBase, tag), checkForLeaks(root, pkg.files)];
|
|
43
75
|
|
|
44
|
-
|
|
76
|
+
runChecks(checks, '[preflight]');
|
|
45
77
|
process.exit(0);
|
|
46
78
|
}
|
package/scripts/publish.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
|
16
16
|
import { execSync } from 'node:child_process';
|
|
17
17
|
import { dirname, join, resolve } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { checkApiBase, checkForLeaks, runChecks } from './preflight.mjs';
|
|
19
20
|
|
|
20
21
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
21
22
|
const ROOT = resolve(__dir, '..');
|
|
@@ -26,6 +27,17 @@ const args = process.argv.slice(2);
|
|
|
26
27
|
const tag = args.find(a => a.startsWith('--tag='))?.split('=')[1] ?? 'beta';
|
|
27
28
|
const prodUrl = args.find(a => a.startsWith('--prod-url='))?.split('=')[1] ?? PROD_URL;
|
|
28
29
|
|
|
30
|
+
// `npm publish <tarball-file>` (below) does NOT trigger npm's `prepublishOnly`
|
|
31
|
+
// lifecycle hook — that only fires when npm packs a directory itself, not
|
|
32
|
+
// when handed an already-built tarball. So these checks are run directly
|
|
33
|
+
// here, not left to package.json's `prepublishOnly` script, which would
|
|
34
|
+
// silently never execute during a real publish:beta/publish:latest run.
|
|
35
|
+
// checkApiBase validates `prodUrl` (the value about to be swapped in and
|
|
36
|
+
// shipped), not the on-disk source — the source is *supposed* to stay local.
|
|
37
|
+
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
|
38
|
+
const preflightChecks = [checkApiBase(prodUrl, tag), checkForLeaks(ROOT, pkg.files)];
|
|
39
|
+
runChecks(preflightChecks, '[publish] Preflight:');
|
|
40
|
+
|
|
29
41
|
const original = readFileSync(API_UTILS, 'utf8');
|
|
30
42
|
const swapped = original.replace(
|
|
31
43
|
/export const DEFAULT_API_BASE\s*=\s*'[^']+'/,
|
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.30.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.
|
|
@@ -268,16 +268,22 @@ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account wit
|
|
|
268
268
|
|
|
269
269
|
---
|
|
270
270
|
|
|
271
|
-
## Comment, Transition, Assign &
|
|
271
|
+
## Comment, Transition, Assign, Duplicates, Link, Update & Create — write back to the tracker (Pro)
|
|
272
272
|
|
|
273
|
-
Unlike Recall (a local note about a ticket),
|
|
273
|
+
Unlike Recall (a local note about a ticket), these seven commands write directly to the ticket's real tracker — Jira, GitHub, or Linear. Only dispatch a write when the user has actually asked for it — never as a routine end-of-session action the way Recall capture is. `duplicates` is read-only and safe to run more freely — it never mutates anything.
|
|
274
274
|
|
|
275
275
|
```bash
|
|
276
276
|
ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
|
|
277
|
+
ticketlens comment PROD-1234 --body="See screenshot" --attach=./bug.png # attach local files
|
|
277
278
|
ticketlens transition PROD-1234 # list valid transitions — read-only
|
|
278
279
|
ticketlens transition PROD-1234 --target="Done" --confirm # execute
|
|
279
280
|
ticketlens assign PROD-1234 --to=me # assign to yourself
|
|
280
281
|
ticketlens duplicates PROD-1234 # find likely duplicates — read-only
|
|
282
|
+
ticketlens link PROD-1234 PROD-5678 # list valid link types — read-only
|
|
283
|
+
ticketlens link PROD-1234 PROD-5678 --type="Duplicate" --confirm # execute the link
|
|
284
|
+
ticketlens update PROD-1234 --title="Fix login on mobile" # update title/description/labels/priority
|
|
285
|
+
ticketlens update PROD-1234 --add-labels=urgent --remove-labels=stale
|
|
286
|
+
ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile" # create a new ticket
|
|
281
287
|
```
|
|
282
288
|
|
|
283
289
|
`transition` called with just a ticket key never mutates anything — it lists the tracker's current valid options (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Only add `--target` **and** `--confirm` once the target has actually been confirmed with the user — `--confirm` is a deliberate two-step gate, not a formality to route around. Never guess a `--target` value; always list first, then use one of the names shown.
|
|
@@ -286,11 +292,19 @@ ticketlens duplicates PROD-1234 # find likely dupl
|
|
|
286
292
|
|
|
287
293
|
`duplicates` lists likely-duplicate tickets in the same project. On Jira, any ticket already linked as a "Duplicate" is always listed first — that's a confirmed relationship a human already recorded, not a heuristic. Everything else is ranked by local title/description overlap — no tracker scores similarity server-side, so treat those as a nudge for the user to check manually, never as a confirmed duplicate to act on unprompted (e.g. don't auto-close or auto-comment based on a match). `--threshold=N` (0–1, default 0.35) tightens or loosens what counts as a text-match — it has no effect on Jira-linked duplicates, which are always shown.
|
|
288
294
|
|
|
289
|
-
|
|
295
|
+
`link SOURCE-KEY TARGET-KEY` links two tickets — direction matters: SOURCE "types" TARGET (e.g. `link A B --type=Duplicate` means A duplicates B, not the other way around). Called with just the two keys, it lists the tracker's current valid link types without changing anything — never guess `--type`; always list first, then use one of the names shown. GitHub has no generic link relationship, so linking on a GitHub-tracked ticket *closes SOURCE as a duplicate of TARGET* — a real state change, not just a relationship add — and prints an explicit warning immediately before that happens, on top of the same `--confirm` gate.
|
|
296
|
+
|
|
297
|
+
`update TICKET-KEY` updates a narrow, named field set — title, description, labels, priority. At least one field is required. Labels are always add/remove (`--add-labels=a,b` / `--remove-labels=c`), never a wholesale replace — an unnamed existing label is left alone, never silently dropped. No `--confirm` needed — these are reversible metadata edits, same risk tier as `assign`.
|
|
298
|
+
|
|
299
|
+
`create` makes a brand-new ticket — there's no existing ticket to target, so `--project` (Jira project key / Linear team key) and `--type` (Jira issue type, ignored elsewhere) pick the destination instead of a ticket key. This is the highest-blast-radius command in the family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker. No `--confirm` gate — double-check the values with the user before calling it, since an invalid value surfaces the tracker's own error rather than a silent guess.
|
|
300
|
+
|
|
301
|
+
`--attach=path1,path2` (comma-separated local file paths) is available on `comment` and `create` only. Images render as an inline thumbnail on Jira and Linear; GitHub has no attachment upload API, so `--attach` is unsupported there.
|
|
302
|
+
|
|
303
|
+
The six write actions (comment/transition/assign/link/update/create) have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is. `duplicates` has neither, since nothing is written.
|
|
290
304
|
|
|
291
305
|
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (tools named `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link`/`ticket_update`/`ticket_create` — often shown as `mcp__ticketlens__ticket_comment` etc. — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same cooldown, same audit log. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list; if that's because this project has never registered the server, see the `ticketlens mcp install` note above (Recall section) — same guidance applies here.
|
|
292
306
|
|
|
293
|
-
Requires a Pro license — on Free, all
|
|
307
|
+
Requires a Pro license — on Free, all seven no-op with an upgrade hint on stderr.
|
|
294
308
|
|
|
295
309
|
---
|
|
296
310
|
|
|
@@ -111,7 +111,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
111
111
|
if (pushFlag) {
|
|
112
112
|
const pushToken = opts.cliToken ?? readCliToken(configDir) ?? null;
|
|
113
113
|
if (!pushToken) {
|
|
114
|
-
process.stderr.write('Error: --push requires authentication. Run `ticketlens
|
|
114
|
+
process.stderr.write('Error: --push requires authentication. Run `ticketlens login` to connect your account.\n');
|
|
115
115
|
process.exitCode = 1;
|
|
116
116
|
return;
|
|
117
117
|
}
|