ticketlens 0.3.0 → 0.4.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 +63 -0
- package/bin/ticketlens.mjs +5 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +7 -1
- package/skills/jtb/scripts/lib/brief-cache.mjs +1 -1
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/error-classifier.mjs +1 -1
- package/skills/jtb/scripts/lib/help.mjs +31 -0
- package/skills/jtb/scripts/lib/jira-client.mjs +10 -4
- package/skills/jtb/scripts/lib/ledger.mjs +2 -1
- package/skills/jtb/scripts/lib/license.mjs +12 -3
- package/skills/jtb/scripts/lib/profile-resolver.mjs +22 -19
- package/skills/jtb/scripts/lib/run-collisions.mjs +1 -0
- package/skills/jtb/scripts/lib/triage-history.mjs +2 -4
- package/skills/jtb/scripts/lib/triage-push.mjs +41 -4
- package/skills/jtb/scripts/lib/triage-share.mjs +1 -0
package/README.md
CHANGED
|
@@ -211,6 +211,53 @@ Flag validation provides actionable hints:
|
|
|
211
211
|
|
|
212
212
|
---
|
|
213
213
|
|
|
214
|
+
### Compliance
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
218
|
+
ticketlens compliance <TICKET-KEY> --profile=acme # Specify a profile
|
|
219
|
+
ticketlens compliance <TICKET-KEY> --plain # Plain markdown output
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Runs the same compliance check as `ticketlens CNV1-2 --compliance` but as a dedicated subcommand — useful when you want to check compliance without fetching the full ticket brief. Free accounts get 3 checks per month; Pro is unlimited.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
### Compliance Ledger
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
ticketlens ledger # View the local compliance audit ledger [Pro]
|
|
230
|
+
ticketlens ledger --plain # Plain markdown output
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Displays the append-only local ledger of all compliance checks run on this machine. Useful for SOC 2 / HIPAA audit trails. Requires a Pro license.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
### Git Hook
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
ticketlens install-hooks # Install pre-push compliance gate [Pro]
|
|
241
|
+
ticketlens install-hooks --uninstall # Remove installed hooks
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Installs a `pre-push` git hook that runs `ticketlens compliance` on every push. Blocks the push if compliance coverage falls below the configured threshold. Requires a Pro license.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
### PR Description
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
ticketlens pr <TICKET-KEY> # Generate PR description from ticket [Pro]
|
|
252
|
+
ticketlens pr <TICKET-KEY> --profile=acme # Specify a profile
|
|
253
|
+
ticketlens pr <TICKET-KEY> --plain # Plain markdown output
|
|
254
|
+
ticketlens pr <TICKET-KEY> | pbcopy # Copy to clipboard
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Generates a PR description template pre-filled with the ticket summary, acceptance criteria, and compliance coverage. Requires a Pro license.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
214
261
|
### Standup
|
|
215
262
|
|
|
216
263
|
```bash
|
|
@@ -295,12 +342,16 @@ Stores the schedule as a cron entry. Delivers your triage digest at the configur
|
|
|
295
342
|
```bash
|
|
296
343
|
ticketlens login # Open browser → authorize in Console → token saved automatically
|
|
297
344
|
ticketlens login --manual # Paste a token instead (CI/headless environments)
|
|
345
|
+
ticketlens logout # Revoke and remove the stored CLI token
|
|
346
|
+
ticketlens sync # Pull your latest tracker profiles from the Console
|
|
298
347
|
```
|
|
299
348
|
|
|
300
349
|
`ticketlens login` opens the TicketLens Console in your default browser. Click **Authorize**, and the CLI receives your token via a one-shot localhost callback — no copy-pasting. Cancelling in the browser exits the CLI cleanly.
|
|
301
350
|
|
|
302
351
|
Use `--manual` when there is no GUI (CI runners, SSH sessions, containers).
|
|
303
352
|
|
|
353
|
+
`ticketlens sync` pulls any tracker profiles you have configured in the Console and writes them locally, keeping your CLI in sync with your team settings without re-running `init`.
|
|
354
|
+
|
|
304
355
|
---
|
|
305
356
|
|
|
306
357
|
### License
|
|
@@ -483,9 +534,21 @@ ticketlens schedule # Interactive wizard — set time,
|
|
|
483
534
|
ticketlens schedule --stop # Cancel the scheduled digest [Pro]
|
|
484
535
|
ticketlens schedule --status # Show current schedule [Pro]
|
|
485
536
|
|
|
537
|
+
# ── Compliance ────────────────────────────────────────────────────────────────
|
|
538
|
+
ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
539
|
+
ticketlens ledger # View local compliance audit ledger [Pro]
|
|
540
|
+
ticketlens install-hooks # Install pre-push compliance gate [Pro]
|
|
541
|
+
ticketlens install-hooks --uninstall # Remove installed hooks [Pro]
|
|
542
|
+
|
|
543
|
+
# ── PR Description ─────────────────────────────────────────────────────────────
|
|
544
|
+
ticketlens pr <TICKET-KEY> # Generate PR description from ticket [Pro]
|
|
545
|
+
ticketlens pr <TICKET-KEY> | pbcopy # Copy to clipboard [Pro]
|
|
546
|
+
|
|
486
547
|
# ── Login ─────────────────────────────────────────────────────────────────────
|
|
487
548
|
ticketlens login # Browser flow — opens Console, token saved automatically
|
|
488
549
|
ticketlens login --manual # Paste flow — for CI/headless environments
|
|
550
|
+
ticketlens logout # Revoke and remove stored CLI token
|
|
551
|
+
ticketlens sync # Pull tracker profiles from the Console
|
|
489
552
|
|
|
490
553
|
# ── License and account ────────────────────────────────────────────────────────
|
|
491
554
|
ticketlens license # Show license tier and status
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
printProfilesHelp, printScheduleHelp,
|
|
25
25
|
printInitHelp, printSwitchHelp, printConfigHelp,
|
|
26
26
|
printReviewHelp, printStandupHelp, printUpdateSkillHelp,
|
|
27
|
+
printCollisionsHelp,
|
|
27
28
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
28
29
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
29
30
|
import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
|
|
@@ -53,6 +54,7 @@ switch (command) {
|
|
|
53
54
|
break;
|
|
54
55
|
|
|
55
56
|
case 'collisions': {
|
|
57
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCollisionsHelp(); break; }
|
|
56
58
|
const { runCollisions } = await import('../skills/jtb/scripts/lib/run-collisions.mjs');
|
|
57
59
|
runCollisions(cmdArgs).catch(err => {
|
|
58
60
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
@@ -127,6 +129,9 @@ switch (command) {
|
|
|
127
129
|
process.stderr.write(`\n ${s.red('✖')} Activation failed: ${result.error}\n\n`);
|
|
128
130
|
process.exitCode = 1;
|
|
129
131
|
}
|
|
132
|
+
}).catch(err => {
|
|
133
|
+
process.stderr.write(`\n ${s.red('✖')} Activation error: ${err.message}\n\n`);
|
|
134
|
+
process.exitCode = 1;
|
|
130
135
|
});
|
|
131
136
|
break;
|
|
132
137
|
}
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.4.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.
|
|
@@ -249,6 +249,12 @@ Generates a structured handoff brief synthesised from the ticket's full comment
|
|
|
249
249
|
|
|
250
250
|
Output is a concise markdown document, not a full TicketBrief. No plan mode — output is displayed and the workflow stops.
|
|
251
251
|
|
|
252
|
+
### triage --push compliance enrichment (Pro)
|
|
253
|
+
|
|
254
|
+
When `--push` is run by a Pro-licensed user, the local compliance ledger (written by `--compliance` runs) is read and merged into the snapshot before sending. Each ticket's `compliance_status` (`pass`/`gap`) and `compliance_coverage` (%) are included in the push payload. This feeds the **Compliance Analytics** dashboard in the Console (`/console/admin/compliance-analytics`), which shows gap-rate trends by project, ticket status, and week.
|
|
255
|
+
|
|
256
|
+
Non-Pro users push with `compliance_status: unknown` — no data is lost, and the analytics page simply shows no compliance data for those snapshots.
|
|
257
|
+
|
|
252
258
|
### --plain / --styled
|
|
253
259
|
|
|
254
260
|
Control output formatting:
|
|
@@ -47,7 +47,7 @@ export function readBriefCache(ticketKey, profileName, depth, configDir = DEFAUL
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const age = Date.now() - new Date(data.fetchedAt).getTime();
|
|
50
|
-
if (age > ttlMs) {
|
|
50
|
+
if (isNaN(age) || age > ttlMs) {
|
|
51
51
|
try { fs.unlinkSync(filePath); } catch { /* non-fatal */ }
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
@@ -106,6 +106,10 @@ export function parseCommand(args) {
|
|
|
106
106
|
return { command: 'update-skill', args: args.slice(1) };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
if (first === 'collisions') {
|
|
110
|
+
return { command: 'collisions', args: args.slice(1) };
|
|
111
|
+
}
|
|
112
|
+
|
|
109
113
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
110
114
|
return { command: 'fetch', args };
|
|
111
115
|
}
|
|
@@ -71,7 +71,7 @@ export function classifyError(err, { baseUrl, profileName } = {}) {
|
|
|
71
71
|
if (status === 401) {
|
|
72
72
|
return {
|
|
73
73
|
message: `Authentication failed for ${profile}`,
|
|
74
|
-
hint: 'Your credentials may have expired. Check your API token or PAT in
|
|
74
|
+
hint: 'Your credentials may have expired. Check your API token or PAT in your TicketLens credentials file.',
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -644,6 +644,37 @@ export function printUpdateSkillHelp({ stream = process.stdout } = {}) {
|
|
|
644
644
|
stream.write(lines.join('\n') + '\n');
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
+
export function printCollisionsHelp({ stream = process.stdout } = {}) {
|
|
648
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
649
|
+
const lines = [
|
|
650
|
+
'',
|
|
651
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('collisions')} ${s.dim('[--json] [--plain]')}`,
|
|
652
|
+
'',
|
|
653
|
+
` Show branches where your changed files overlap with a teammate's.`,
|
|
654
|
+
` Reads the most recent snapshot pushed by you and each teammate (within 7 days).`,
|
|
655
|
+
` Requires a ${s.bold('Team')} license and at least one teammate in your group.`,
|
|
656
|
+
'',
|
|
657
|
+
` ${s.bold('OPTIONS')}`,
|
|
658
|
+
'',
|
|
659
|
+
` ${s.brand('--json')} Output raw JSON array of collision objects`,
|
|
660
|
+
` ${s.brand('--plain')} Plain text output ${s.dim('(no ANSI colour)')}`,
|
|
661
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
662
|
+
'',
|
|
663
|
+
` ${s.bold('EXAMPLES')}`,
|
|
664
|
+
'',
|
|
665
|
+
` ${s.dim('$')} ticketlens collisions`,
|
|
666
|
+
` ${s.dim('$')} ticketlens collisions --json`,
|
|
667
|
+
` ${s.dim('$')} ticketlens collisions --plain`,
|
|
668
|
+
'',
|
|
669
|
+
` ${s.bold('NOTES')}`,
|
|
670
|
+
'',
|
|
671
|
+
` Branch data is captured automatically when you run ${s.brand('ticketlens triage --push')}.`,
|
|
672
|
+
` Snapshots older than 7 days are ignored.`,
|
|
673
|
+
'',
|
|
674
|
+
];
|
|
675
|
+
stream.write(lines.join('\n') + '\n');
|
|
676
|
+
}
|
|
677
|
+
|
|
647
678
|
export function printStandupHelp({ stream = process.stdout } = {}) {
|
|
648
679
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
649
680
|
const lines = [
|
|
@@ -75,7 +75,9 @@ export async function fetchCurrentUser(opts = {}) {
|
|
|
75
75
|
const response = await fetcher(url, fetchOpts);
|
|
76
76
|
|
|
77
77
|
if (!response.ok) {
|
|
78
|
-
|
|
78
|
+
const err = new Error(`Jira API error ${response.status} fetching current user`);
|
|
79
|
+
err.status = response.status;
|
|
80
|
+
throw err;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
const raw = await response.json();
|
|
@@ -98,7 +100,9 @@ export async function fetchStatuses(opts = {}) {
|
|
|
98
100
|
const response = await fetcher(url, fetchOpts);
|
|
99
101
|
|
|
100
102
|
if (!response.ok) {
|
|
101
|
-
|
|
103
|
+
const err = new Error(`Jira API error ${response.status} fetching statuses`);
|
|
104
|
+
err.status = response.status;
|
|
105
|
+
throw err;
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
const raw = await response.json();
|
|
@@ -121,7 +125,7 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
121
125
|
if (!response.ok) {
|
|
122
126
|
let detail = '';
|
|
123
127
|
try { const body = await response.json(); detail = (body.errorMessages || []).join('; '); } catch {}
|
|
124
|
-
const err = new Error(`Jira API error ${response.status}
|
|
128
|
+
const err = new Error(`Jira API error ${response.status} searching tickets`);
|
|
125
129
|
err.status = response.status;
|
|
126
130
|
err.detail = detail;
|
|
127
131
|
throw err;
|
|
@@ -159,7 +163,9 @@ export async function fetchTicket(ticketKey, opts = {}) {
|
|
|
159
163
|
const response = await fetcher(url, fetchOpts);
|
|
160
164
|
|
|
161
165
|
if (!response.ok) {
|
|
162
|
-
|
|
166
|
+
const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
|
|
167
|
+
err.status = response.status;
|
|
168
|
+
throw err;
|
|
163
169
|
}
|
|
164
170
|
|
|
165
171
|
const raw = await response.json();
|
|
@@ -47,7 +47,8 @@ export function readLedger({ configDir = DEFAULT_CONFIG_DIR, fsModule = _fs, sin
|
|
|
47
47
|
return raw
|
|
48
48
|
.split('\n')
|
|
49
49
|
.filter(line => line.trim().length > 0)
|
|
50
|
-
.map(line => JSON.parse(line))
|
|
50
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
51
|
+
.filter(record => record !== null)
|
|
51
52
|
.filter(record => sinceMs === null || new Date(record.ts).getTime() >= sinceMs);
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -5,6 +5,9 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
6
6
|
import { createStyler } from './ansi.mjs';
|
|
7
7
|
|
|
8
|
+
export // Mixed into the HMAC key so that knowing the license key alone is not sufficient
|
|
9
|
+
// to forge a valid signature — an attacker also needs this constant from the source.
|
|
10
|
+
const LICENSE_HMAC_SALT = 'tl-lic-v1';
|
|
8
11
|
export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
|
|
9
12
|
const LICENSE_FILE = 'license.json';
|
|
10
13
|
const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
|
|
@@ -24,9 +27,14 @@ export function readLicense(configDir = DEFAULT_CONFIG_DIR) {
|
|
|
24
27
|
const { sig, ...payload } = data;
|
|
25
28
|
// Legacy unsigned files are trusted; will be re-signed on next write
|
|
26
29
|
if (!sig) return payload;
|
|
27
|
-
const
|
|
30
|
+
const sigKey = `${LICENSE_HMAC_SALT}:${payload.key || ''}`;
|
|
31
|
+
const expected = crypto.createHmac('sha256', sigKey)
|
|
28
32
|
.update(JSON.stringify(payload)).digest('hex');
|
|
29
|
-
|
|
33
|
+
const sigBuf = Buffer.from(sig, 'hex');
|
|
34
|
+
const expBuf = Buffer.from(expected, 'hex');
|
|
35
|
+
// timingSafeEqual requires equal-length buffers; unequal lengths mean tampered sig
|
|
36
|
+
if (sigBuf.length !== expBuf.length) return null;
|
|
37
|
+
return crypto.timingSafeEqual(sigBuf, expBuf) ? payload : null;
|
|
30
38
|
} catch {
|
|
31
39
|
return null;
|
|
32
40
|
}
|
|
@@ -36,7 +44,8 @@ export function writeLicense(data, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
36
44
|
fs.mkdirSync(configDir, { recursive: true });
|
|
37
45
|
const filePath = path.join(configDir, LICENSE_FILE);
|
|
38
46
|
const { sig: _, ...payload } = data; // strip any existing sig before re-signing
|
|
39
|
-
const
|
|
47
|
+
const sigKey = `${LICENSE_HMAC_SALT}:${payload.key || ''}`;
|
|
48
|
+
const mac = crypto.createHmac('sha256', sigKey)
|
|
40
49
|
.update(JSON.stringify(payload)).digest('hex');
|
|
41
50
|
fs.writeFileSync(filePath, JSON.stringify({ ...payload, sig: mac }), 'utf8');
|
|
42
51
|
fs.chmodSync(filePath, 0o600);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Resolution order: --profile flag → ticket prefix match → default profile → env vars
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
@@ -51,8 +51,7 @@ export function saveDefault(name, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
51
51
|
const profilesPath = join(configDir, 'profiles.json');
|
|
52
52
|
const config = loadProfiles(configDir) || { profiles: {} };
|
|
53
53
|
config.default = name;
|
|
54
|
-
writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
55
|
-
chmodSync(profilesPath, 0o600);
|
|
54
|
+
writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
56
55
|
invalidateProfilesCache(configDir);
|
|
57
56
|
}
|
|
58
57
|
|
|
@@ -61,14 +60,12 @@ export function saveProfile(name, profileData, credData, configDir = DEFAULT_CON
|
|
|
61
60
|
const profilesPath = join(configDir, 'profiles.json');
|
|
62
61
|
const config = loadProfiles(configDir) || { profiles: {} };
|
|
63
62
|
config.profiles[name] = profileData;
|
|
64
|
-
writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
65
|
-
chmodSync(profilesPath, 0o600);
|
|
63
|
+
writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
66
64
|
if (credData && Object.keys(credData).length > 0) {
|
|
67
65
|
const credPath = join(configDir, 'credentials.json');
|
|
68
66
|
const creds = loadCredentials(configDir);
|
|
69
67
|
creds[name] = credData;
|
|
70
|
-
writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
|
|
71
|
-
chmodSync(credPath, 0o600);
|
|
68
|
+
writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
72
69
|
}
|
|
73
70
|
invalidateProfilesCache(configDir);
|
|
74
71
|
}
|
|
@@ -81,8 +78,7 @@ export function deleteProfile(name, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
81
78
|
delete config.profiles[name];
|
|
82
79
|
if (config.default === name) delete config.default;
|
|
83
80
|
|
|
84
|
-
writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
85
|
-
chmodSync(profilesPath, 0o600);
|
|
81
|
+
writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
86
82
|
|
|
87
83
|
// Remove credentials entry
|
|
88
84
|
const credPath = join(configDir, 'credentials.json');
|
|
@@ -90,8 +86,7 @@ export function deleteProfile(name, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
90
86
|
const creds = loadCredentials(configDir);
|
|
91
87
|
if (creds[name]) {
|
|
92
88
|
delete creds[name];
|
|
93
|
-
writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
|
|
94
|
-
chmodSync(credPath, 0o600);
|
|
89
|
+
writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
95
90
|
}
|
|
96
91
|
}
|
|
97
92
|
|
|
@@ -103,9 +98,13 @@ export function loadProfiles(configDir = DEFAULT_CONFIG_DIR) {
|
|
|
103
98
|
if (_profilesCache.has(configDir)) return _profilesCache.get(configDir);
|
|
104
99
|
const profilesPath = join(configDir, 'profiles.json');
|
|
105
100
|
if (!existsSync(profilesPath)) return null;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
try {
|
|
102
|
+
const data = JSON.parse(readFileSync(profilesPath, 'utf8'));
|
|
103
|
+
_profilesCache.set(configDir, data);
|
|
104
|
+
return data;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
export function loadCredentials(configDir = DEFAULT_CONFIG_DIR) {
|
|
@@ -115,9 +114,14 @@ export function loadCredentials(configDir = DEFAULT_CONFIG_DIR) {
|
|
|
115
114
|
_credentialsCache.set(configDir, {});
|
|
116
115
|
return {};
|
|
117
116
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
try {
|
|
118
|
+
const data = JSON.parse(readFileSync(credPath, 'utf8'));
|
|
119
|
+
_credentialsCache.set(configDir, data);
|
|
120
|
+
return data;
|
|
121
|
+
} catch {
|
|
122
|
+
_credentialsCache.set(configDir, {});
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
export function saveCredentialKey(key, value, configDir = DEFAULT_CONFIG_DIR) {
|
|
@@ -129,8 +133,7 @@ export function saveCredentialKey(key, value, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
129
133
|
} else {
|
|
130
134
|
creds[key] = value;
|
|
131
135
|
}
|
|
132
|
-
writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
|
|
133
|
-
chmodSync(credPath, 0o600);
|
|
136
|
+
writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
134
137
|
invalidateProfilesCache(configDir);
|
|
135
138
|
}
|
|
136
139
|
|
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as defaultFs from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
8
9
|
import { join } from 'node:path';
|
|
9
10
|
|
|
10
|
-
const DEFAULT_CONFIG_DIR = join(
|
|
11
|
-
process.env.HOME ?? process.env.USERPROFILE ?? '',
|
|
12
|
-
'.ticketlens'
|
|
13
|
-
);
|
|
11
|
+
const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
|
|
14
12
|
|
|
15
13
|
const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'clear': 2 };
|
|
16
14
|
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* All errors are caught — push failure must never break triage output.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { readLedger } from './ledger.mjs';
|
|
7
|
+
import { isLicensed } from './license.mjs';
|
|
8
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
9
|
+
|
|
6
10
|
const PUSH_PATH = '/v1/triage/push';
|
|
7
11
|
// Local dev: http://ticketlens.test — production override via TICKETLENS_API_URL env var
|
|
8
12
|
const DEFAULT_API_BASE = 'http://ticketlens.test';
|
|
@@ -12,10 +16,15 @@ function apiBase() {
|
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
// Derive console queue URL from API base:
|
|
15
|
-
// http://ticketlens.test
|
|
19
|
+
// http://api.ticketlens.test → http://ticketlens.test/console/queue
|
|
16
20
|
// https://api.ticketlens.com → https://app.ticketlens.com/console/queue
|
|
17
|
-
function queueUrl(base) {
|
|
18
|
-
|
|
21
|
+
export function queueUrl(base) {
|
|
22
|
+
const noApi = base.replace(/^(https?:\/\/)api\./, '$1');
|
|
23
|
+
// Production: ticketlens.com → app.ticketlens.com
|
|
24
|
+
const console = /ticketlens\.com/.test(noApi)
|
|
25
|
+
? noApi.replace('://', '://app.')
|
|
26
|
+
: noApi;
|
|
27
|
+
return console + '/console/queue';
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
function buildTicketPayload(scored, rawMap, baseUrl) {
|
|
@@ -59,16 +68,43 @@ export async function pushTriageSnapshot({
|
|
|
59
68
|
gitBranches,
|
|
60
69
|
fetcher = globalThis.fetch,
|
|
61
70
|
print = (s) => process.stdout.write(s),
|
|
71
|
+
readLedgerFn = readLedger,
|
|
72
|
+
isLicensedFn = isLicensed,
|
|
73
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
62
74
|
} = {}) {
|
|
63
75
|
if (!licenseKey) {
|
|
64
76
|
print('✗ --push requires an active Team license (ticketlens activate <key>)\n');
|
|
65
77
|
return { ok: false };
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
let tickets = sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl));
|
|
81
|
+
|
|
82
|
+
if (isLicensedFn('pro', configDir)) {
|
|
83
|
+
try {
|
|
84
|
+
const entries = readLedgerFn({ configDir });
|
|
85
|
+
const latestByKey = new Map();
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const prev = latestByKey.get(entry.ticketKey);
|
|
88
|
+
if (!prev || entry.ts > prev.ts) latestByKey.set(entry.ticketKey, entry);
|
|
89
|
+
}
|
|
90
|
+
tickets = tickets.map(t => {
|
|
91
|
+
const entry = latestByKey.get(t.key);
|
|
92
|
+
if (!entry) return t;
|
|
93
|
+
return {
|
|
94
|
+
...t,
|
|
95
|
+
compliance_coverage: entry.coverage ?? null,
|
|
96
|
+
compliance_status: (entry.missing?.length ?? 0) === 0 ? 'pass' : 'gap',
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
// non-fatal — ledger errors must not break triage push
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
const payload = {
|
|
69
105
|
profile: String(profile ?? 'default').slice(0, 100),
|
|
70
106
|
captured_at: capturedAt ?? new Date().toISOString(),
|
|
71
|
-
tickets
|
|
107
|
+
tickets,
|
|
72
108
|
...(gitBranches != null && { git_branches: gitBranches }),
|
|
73
109
|
};
|
|
74
110
|
|
|
@@ -80,6 +116,7 @@ export async function pushTriageSnapshot({
|
|
|
80
116
|
'Authorization': `Bearer ${licenseKey}`,
|
|
81
117
|
},
|
|
82
118
|
body: JSON.stringify(payload),
|
|
119
|
+
signal: AbortSignal.timeout(15_000),
|
|
83
120
|
});
|
|
84
121
|
|
|
85
122
|
if (res.ok) {
|