ticketlens 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/ticketlens.mjs +376 -0
  4. package/package.json +37 -0
  5. package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
  6. package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
  7. package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
  8. package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
  9. package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
  10. package/skills/jtb/scripts/lib/ansi.mjs +87 -0
  11. package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
  12. package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
  13. package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
  14. package/skills/jtb/scripts/lib/banner.mjs +201 -0
  15. package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
  16. package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
  17. package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
  18. package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
  19. package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
  20. package/skills/jtb/scripts/lib/cli.mjs +87 -0
  21. package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
  22. package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
  23. package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
  24. package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
  25. package/skills/jtb/scripts/lib/config.mjs +63 -0
  26. package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
  27. package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
  28. package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
  29. package/skills/jtb/scripts/lib/help.mjs +253 -0
  30. package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
  31. package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
  32. package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
  33. package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
  34. package/skills/jtb/scripts/lib/ledger.mjs +96 -0
  35. package/skills/jtb/scripts/lib/license.mjs +195 -0
  36. package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
  37. package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
  38. package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
  39. package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
  40. package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
  41. package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
  42. package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
  43. package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
  44. package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
  45. package/skills/jtb/scripts/lib/spinner.mjs +44 -0
  46. package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
  47. package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
  48. package/skills/jtb/scripts/lib/sync.mjs +119 -0
  49. package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
  50. package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
  51. package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
  52. package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
  53. package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
  54. package/skills/jtb/scripts/lib/vcs-detector.mjs +12 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * ticketlens sync — Pull tracker profile shapes from the TicketLens console.
3
+ *
4
+ * Credentials are NEVER synced — they stay on the local machine.
5
+ * Server wins on all non-credential fields (shape, prefixes, URLs, etc.).
6
+ */
7
+
8
+ import { readCliToken } from './cli-auth.mjs';
9
+ import {
10
+ loadProfiles,
11
+ loadCredentials,
12
+ saveProfile,
13
+ invalidateProfilesCache,
14
+ } from './profile-resolver.mjs';
15
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
16
+
17
+ const DEFAULT_API_BASE = 'https://ticketlens.app';
18
+
19
+ export const getApiBase = () => process.env.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
20
+
21
+ /**
22
+ * Convert a server profile (snake_case) to the CLI profile shape (camelCase).
23
+ * Returns { name, profileData } — no credential fields.
24
+ */
25
+ export function serverToCliProfile(serverProfile) {
26
+ const p = serverProfile;
27
+ const profileData = {
28
+ baseUrl: p.base_url,
29
+ auth: p.auth_method,
30
+ ...(p.email ? { email: p.email } : {}),
31
+ ...(p.ticket_prefixes?.length ? { ticketPrefixes: p.ticket_prefixes } : {}),
32
+ ...(p.project_paths?.length ? { projectPaths: p.project_paths } : {}),
33
+ ...(p.triage_statuses?.length ? { triageStatuses: p.triage_statuses } : {}),
34
+ };
35
+ return { name: p.name, profileData };
36
+ }
37
+
38
+ /**
39
+ * Determine whether a profile is missing the credentials it needs to function.
40
+ */
41
+ export function profileNeedsCredentials(profileData, creds) {
42
+ if (!creds) return true;
43
+ const auth = profileData.auth;
44
+ if (auth === 'pat') return !creds.pat;
45
+ return !creds.apiToken; // cloud, basic, github all use apiToken
46
+ }
47
+
48
+ /**
49
+ * Sync tracker profile shapes from the TicketLens console API.
50
+ *
51
+ * @param {object} [opts]
52
+ * @param {string} [opts.configDir]
53
+ * @param {Function} [opts.fetcher] — injectable for tests
54
+ *
55
+ * @returns {Promise<
56
+ * | { error: 'no-token' | 'unauthorized' | string }
57
+ * | { added: string[], updated: string[], unchanged: string[], needsCredentials: string[] }
58
+ * >}
59
+ */
60
+ export async function syncProfiles({
61
+ configDir = DEFAULT_CONFIG_DIR,
62
+ fetcher = globalThis.fetch,
63
+ } = {}) {
64
+ const token = readCliToken(configDir);
65
+ if (!token) return { error: 'no-token' };
66
+
67
+ let res;
68
+ try {
69
+ res = await fetcher(`${getApiBase()}/v1/profiles`, {
70
+ headers: {
71
+ Authorization: `Bearer ${token}`,
72
+ Accept: 'application/json',
73
+ },
74
+ signal: AbortSignal.timeout(15000),
75
+ });
76
+ } catch (err) {
77
+ return { error: err.name === 'TimeoutError' ? 'timeout' : `network: ${err.message}` };
78
+ }
79
+
80
+ if (res.status === 401) return { error: 'unauthorized' };
81
+ if (!res.ok) return { error: `http-${res.status}` };
82
+
83
+ let json;
84
+ try { json = await res.json(); } catch { return { error: 'invalid-json' }; }
85
+
86
+ const remoteProfiles = Array.isArray(json?.profiles) ? json.profiles : [];
87
+ const localConfig = loadProfiles(configDir) || { profiles: {} };
88
+ const localCreds = loadCredentials(configDir);
89
+
90
+ const added = [];
91
+ const updated = [];
92
+ const unchanged = [];
93
+ const needsCredentials = [];
94
+
95
+ for (const remote of remoteProfiles) {
96
+ const { name, profileData } = serverToCliProfile(remote);
97
+ const existing = localConfig.profiles[name];
98
+
99
+ if (!existing) {
100
+ saveProfile(name, profileData, {}, configDir);
101
+ added.push(name);
102
+ } else if (JSON.stringify(existing) !== JSON.stringify(profileData)) {
103
+ saveProfile(name, profileData, {}, configDir);
104
+ updated.push(name);
105
+ } else {
106
+ unchanged.push(name);
107
+ }
108
+
109
+ if (profileNeedsCredentials(profileData, localCreds[name])) {
110
+ needsCredentials.push(name);
111
+ }
112
+ }
113
+
114
+ if (added.length > 0 || updated.length > 0) {
115
+ invalidateProfilesCache(configDir);
116
+ }
117
+
118
+ return { added, updated, unchanged, needsCredentials };
119
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Plain-text table formatter for terminal output.
3
+ * Uses box-drawing characters for clean rendering without a markdown parser.
4
+ */
5
+
6
+ // Strip ANSI SGR sequences and OSC 8 hyperlinks to get visible (printable) length.
7
+ const ESCAPE_RE = /\x1b\[[0-9;]*m|\x1b\]8;[^\x07]*\x07/g;
8
+ const visibleLength = (str) => str.replace(ESCAPE_RE, '').length;
9
+
10
+ export function formatTable(headers, rows, opts = {}) {
11
+ const { maxWidths = {} } = opts;
12
+
13
+ // Strip control characters that can corrupt table layout.
14
+ // Preserves \x07 (BEL) used as OSC 8 hyperlink terminator and \x1b used in escape sequences.
15
+ const sanitize = (str) => str.replace(/[\x00-\x06\x08\x0b\x0c\x0e-\x1a\r]/g, '');
16
+
17
+ // Apply truncation based on visible length so ANSI-styled cells are not cut mid-sequence.
18
+ const truncate = (str, max) => {
19
+ if (!max || visibleLength(str) <= max) return str;
20
+ // Strip styles, truncate, re-apply is complex; these cells are plain text from callers.
21
+ return str.slice(0, max - 3) + '...';
22
+ };
23
+
24
+ const processedRows = rows.map(row =>
25
+ row.map((cell, i) => truncate(sanitize(String(cell ?? '')), maxWidths[i]))
26
+ );
27
+ const processedHeaders = headers.map((h, i) => truncate(String(h), maxWidths[i]));
28
+
29
+ // Calculate column widths using visible length to ignore ANSI escape sequences.
30
+ const colWidths = processedHeaders.map((h, i) => {
31
+ const cellMax = processedRows.reduce((max, row) => Math.max(max, visibleLength(row[i] || '')), 0);
32
+ return Math.max(visibleLength(h), cellMax);
33
+ });
34
+
35
+ // Pad using visible length so styled cells (with invisible escape codes) align correctly.
36
+ const pad = (str, width) => str + ' '.repeat(Math.max(0, width - visibleLength(str)));
37
+ const formatRow = (cells) => ' ' + cells.map((c, i) => pad(String(c || ''), colWidths[i])).join(' ');
38
+
39
+ const lines = [];
40
+ lines.push(formatRow(processedHeaders));
41
+ lines.push(' ' + colWidths.map(w => '─'.repeat(w)).join(' '));
42
+
43
+ for (const row of processedRows) {
44
+ lines.push(formatRow(row));
45
+ }
46
+
47
+ return lines.join('\n');
48
+ }
@@ -0,0 +1,93 @@
1
+ import { writeFileSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
4
+
5
+ /**
6
+ * Export triage results to CSV or JSON.
7
+ * @param {object} opts
8
+ * @param {Array} opts.tickets - Scored ticket objects from attention-scorer
9
+ * @param {'csv'|'json'} opts.format
10
+ * @param {string} [opts.profile]
11
+ * @param {string} [opts.configDir]
12
+ * @returns {string} Absolute path to written file
13
+ */
14
+ export function exportTriage({ tickets, format, profile = 'default', configDir = DEFAULT_CONFIG_DIR }) {
15
+ // Check for path traversal attempts in profile name
16
+ if (profile.includes('/') || profile.includes('\\') || profile.includes('..')) {
17
+ throw new Error(`Invalid profile name: contains path traversal characters`);
18
+ }
19
+
20
+ const exportsDir = join(configDir, 'exports');
21
+ assertSafePath(exportsDir, configDir);
22
+ mkdirSync(exportsDir, { recursive: true });
23
+
24
+ const dateStr = formatDate(new Date());
25
+ const safeProfile = profile.replace(/[^a-z0-9_-]/gi, '_').slice(0, 40);
26
+ const filename = `${dateStr}-${safeProfile}.${format}`;
27
+ const outputPath = join(exportsDir, filename);
28
+ const tmpPath = `${outputPath}.tmp`;
29
+
30
+ const content = format === 'csv' ? buildCsv(tickets) : buildJson(tickets, profile);
31
+
32
+ writeFileSync(tmpPath, content, 'utf8');
33
+ renameSync(tmpPath, outputPath);
34
+
35
+ return outputPath;
36
+ }
37
+
38
+ function buildCsv(tickets) {
39
+ const header = '#,Ticket,Summary,Status,Urgency,LastCommentFrom,LastCommentDate,DaysSinceUpdate,URL';
40
+ const rows = tickets.map((t, i) => [
41
+ i + 1,
42
+ t.ticketKey ?? '',
43
+ escapeCsv(t.summary),
44
+ t.status ?? '',
45
+ t.urgency ?? '',
46
+ escapeCsv(t.lastComment?.author ?? ''),
47
+ t.lastComment?.created ?? '',
48
+ t.daysSinceUpdate ?? '',
49
+ t.url ?? '',
50
+ ].join(','));
51
+ return [header, ...rows].join('\n') + '\n';
52
+ }
53
+
54
+ function buildJson(tickets, profile) {
55
+ const needsResponse = tickets.filter(t => t.urgency === 'needs-response').length;
56
+ const aging = tickets.filter(t => t.urgency === 'aging').length;
57
+ return JSON.stringify({
58
+ exportedAt: new Date().toISOString(),
59
+ profile,
60
+ summary: { total: tickets.length, needsResponse, aging },
61
+ tickets: tickets.map(t => ({
62
+ ticketKey: t.ticketKey ?? null,
63
+ summary: t.summary ?? null,
64
+ status: t.status ?? null,
65
+ urgency: t.urgency ?? null,
66
+ lastComment: t.lastComment ?? null,
67
+ daysSinceUpdate: t.daysSinceUpdate ?? null,
68
+ url: t.url ?? null,
69
+ })),
70
+ }, null, 2);
71
+ }
72
+
73
+ function escapeCsv(str) {
74
+ if (str == null) return '';
75
+ const s = String(str).replace(/[\r\n]+/g, ' ');
76
+ if (s.includes(',') || s.includes('"')) {
77
+ return `"${s.replace(/"/g, '""')}"`;
78
+ }
79
+ return s;
80
+ }
81
+
82
+ function formatDate(d) {
83
+ const pad = n => String(n).padStart(2, '0');
84
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}-${pad(d.getMinutes())}`;
85
+ }
86
+
87
+ function assertSafePath(targetPath, basePath) {
88
+ const resolved = resolve(targetPath);
89
+ const base = resolve(basePath);
90
+ if (!resolved.startsWith(base + '/') && resolved !== base) {
91
+ throw new Error(`Invalid path: ${targetPath} is outside ${basePath}`);
92
+ }
93
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Triage history: save/load daily snapshots and compute delta reports.
3
+ * Named exports only, no default export.
4
+ * All I/O deps are injectable (fsModule, configDir, now).
5
+ */
6
+
7
+ import * as defaultFs from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ const DEFAULT_CONFIG_DIR = join(
11
+ process.env.HOME ?? process.env.USERPROFILE ?? '',
12
+ '.ticketlens'
13
+ );
14
+
15
+ const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'clear': 2 };
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Internal helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function validateProfile(profile) {
22
+ if (!profile || /[/\\]/.test(profile) || profile === '..' || profile.includes('..')) {
23
+ throw new Error('Invalid profile name');
24
+ }
25
+ }
26
+
27
+ function toDateString(date) {
28
+ // Format as YYYY-MM-DD in local wall-clock date
29
+ const y = date.getFullYear();
30
+ const m = String(date.getMonth() + 1).padStart(2, '0');
31
+ const d = String(date.getDate()).padStart(2, '0');
32
+ return `${y}-${m}-${d}`;
33
+ }
34
+
35
+ function snapshotPath(configDir, dateStr, profile) {
36
+ return join(configDir, 'triage-history', dateStr, `${profile}.json`);
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Public API
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Save the current scored tickets as today's snapshot.
45
+ *
46
+ * @param {object[]} tickets - Array of scoreAttention() results
47
+ * @param {object} opts
48
+ * @param {string} opts.profile - Profile name (sanitized)
49
+ * @param {string} [opts.configDir]
50
+ * @param {object} [opts.fsModule] - Injectable fs (default: node:fs)
51
+ * @param {Date} [opts.now] - Date for deterministic tests
52
+ */
53
+ export function saveTriageSnapshot(tickets, {
54
+ profile,
55
+ configDir = DEFAULT_CONFIG_DIR,
56
+ fsModule = defaultFs,
57
+ now = new Date(),
58
+ } = {}) {
59
+ validateProfile(profile);
60
+ const dateStr = toDateString(now);
61
+ const dir = join(configDir, 'triage-history', dateStr);
62
+ fsModule.mkdirSync(dir, { recursive: true });
63
+ const filePath = snapshotPath(configDir, dateStr, profile);
64
+ fsModule.writeFileSync(filePath, JSON.stringify(tickets, null, 2), 'utf8');
65
+ }
66
+
67
+ /**
68
+ * Load yesterday's snapshot, or null if none exists.
69
+ *
70
+ * @param {object} opts
71
+ * @param {string} opts.profile
72
+ * @param {string} [opts.configDir]
73
+ * @param {object} [opts.fsModule]
74
+ * @param {Date} [opts.now]
75
+ * @returns {object[]|null}
76
+ */
77
+ export function loadYesterdaySnapshot({
78
+ profile,
79
+ configDir = DEFAULT_CONFIG_DIR,
80
+ fsModule = defaultFs,
81
+ now = new Date(),
82
+ } = {}) {
83
+ validateProfile(profile);
84
+ const yest = new Date(now);
85
+ yest.setDate(yest.getDate() - 1);
86
+ const dateStr = toDateString(yest);
87
+ const filePath = snapshotPath(configDir, dateStr, profile);
88
+ try {
89
+ const raw = fsModule.readFileSync(filePath, 'utf8');
90
+ return JSON.parse(raw);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Compare today's and yesterday's ticket arrays, returning only tickets that
98
+ * worsened (urgency moved toward 'needs-response', new comment, or stale threshold crossed).
99
+ *
100
+ * Only tickets present in BOTH arrays are compared.
101
+ *
102
+ * @param {object[]} today - Today's scored tickets
103
+ * @param {object[]} yesterday - Yesterday's scored tickets
104
+ * @returns {{ ticketKey: string, summary: string, changes: string[] }[]}
105
+ */
106
+ export function diffSnapshots(today, yesterday) {
107
+ const yesterdayMap = new Map(yesterday.map(t => [t.ticketKey, t]));
108
+ const deltas = [];
109
+
110
+ for (const t of today) {
111
+ const y = yesterdayMap.get(t.ticketKey);
112
+ if (!y) continue; // new ticket — skip
113
+
114
+ const changes = [];
115
+
116
+ // Urgency worsened
117
+ const todayOrder = URGENCY_ORDER[t.urgency] ?? 2;
118
+ const yesterdayOrder = URGENCY_ORDER[y.urgency] ?? 2;
119
+ if (todayOrder < yesterdayOrder) {
120
+ changes.push(`${y.urgency} \u2192 ${t.urgency}`);
121
+ }
122
+
123
+ // New comment (today has a comment with a different created timestamp)
124
+ if (
125
+ t.lastComment?.created &&
126
+ t.lastComment.created !== y.lastComment?.created
127
+ ) {
128
+ changes.push('1 new comment');
129
+ }
130
+
131
+ // Stale threshold crossed: was <7 days, now >=7 days
132
+ if (
133
+ typeof t.daysSinceUpdate === 'number' &&
134
+ typeof y.daysSinceUpdate === 'number' &&
135
+ t.daysSinceUpdate >= 7 &&
136
+ y.daysSinceUpdate < 7
137
+ ) {
138
+ changes.push(`stale threshold crossed (${t.daysSinceUpdate} days idle)`);
139
+ }
140
+
141
+ if (changes.length > 0) {
142
+ deltas.push({ ticketKey: t.ticketKey, summary: t.summary, changes });
143
+ }
144
+ }
145
+
146
+ return deltas;
147
+ }
148
+
149
+ /**
150
+ * Render the delta section as a plain-text string for inclusion in the digest payload.
151
+ *
152
+ * @param {{ ticketKey: string, summary: string, changes: string[] }[]} deltas
153
+ * @returns {string} Empty string when deltas is empty
154
+ */
155
+ export function buildDeltaSection(deltas) {
156
+ if (!deltas || deltas.length === 0) return '';
157
+
158
+ const lines = ['\u2500\u2500 What got worse since yesterday \u2500\u2500'];
159
+
160
+ for (const { ticketKey, changes } of deltas) {
161
+ const changeStr = changes.join(' ');
162
+ lines.push(`\u25bc ${ticketKey} ${changeStr}`);
163
+ }
164
+
165
+ return lines.join('\n') + '\n';
166
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * POST scored triage snapshot to the TicketLens API.
3
+ * All errors are caught — push failure must never break triage output.
4
+ */
5
+
6
+ const PUSH_PATH = '/v1/triage/push';
7
+ // Local dev: http://ticketlens.test — production override via TICKETLENS_API_URL env var
8
+ const DEFAULT_API_BASE = 'http://ticketlens.test';
9
+
10
+ function apiBase() {
11
+ return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
12
+ }
13
+
14
+ // Derive console queue URL from API base:
15
+ // http://ticketlens.test → http://ticketlens.test/console/queue
16
+ // https://api.ticketlens.com → https://app.ticketlens.com/console/queue
17
+ function queueUrl(base) {
18
+ return base.replace(/^(https?:\/\/)api\./, '$1app.') + '/console/queue';
19
+ }
20
+
21
+ function buildTicketPayload(scored, rawMap, baseUrl) {
22
+ const raw = rawMap?.get(scored.ticketKey);
23
+ return {
24
+ key: scored.ticketKey,
25
+ summary: scored.summary ?? null,
26
+ status: scored.status ?? null,
27
+ assignee: raw?.assignee ?? null,
28
+ attention_score: null,
29
+ flags: scored.urgency === 'clear' ? [] : [scored.urgency],
30
+ compliance_coverage: null,
31
+ compliance_status: 'unknown',
32
+ url: baseUrl ? `${baseUrl}/browse/${scored.ticketKey}` : null,
33
+ last_updated: raw?.updated ?? null,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * POST the scored triage snapshot to the TicketLens API.
39
+ *
40
+ * @param {object} opts
41
+ * @param {object[]} [opts.sorted] - Scored tickets from attention-scorer
42
+ * @param {Map} [opts.rawTicketMap] - Map<key, normalizedTicket>
43
+ * @param {string} [opts.profile] - Resolved profile name (max 100 chars)
44
+ * @param {string} [opts.baseUrl] - Jira base URL for URL construction
45
+ * @param {string} [opts.licenseKey] - Bearer token for the API
46
+ * @param {string} [opts.capturedAt] - ISO 8601 timestamp (defaults to now)
47
+ * @param {Function} [opts.fetcher] - Injectable fetch (default: globalThis.fetch)
48
+ * @param {Function} [opts.print] - Output fn (default: process.stdout.write)
49
+ * @returns {Promise<{ ok: boolean, status?: number }>}
50
+ */
51
+ export async function pushTriageSnapshot({
52
+ sorted = [],
53
+ rawTicketMap = new Map(),
54
+ profile,
55
+ baseUrl,
56
+ licenseKey,
57
+ capturedAt,
58
+ fetcher = globalThis.fetch,
59
+ print = (s) => process.stdout.write(s),
60
+ } = {}) {
61
+ if (!licenseKey) {
62
+ print('✗ --push requires an active Team license (ticketlens activate <key>)\n');
63
+ return { ok: false };
64
+ }
65
+
66
+ const payload = {
67
+ profile: String(profile ?? 'default').slice(0, 100),
68
+ captured_at: capturedAt ?? new Date().toISOString(),
69
+ tickets: sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl)),
70
+ };
71
+
72
+ try {
73
+ const res = await fetcher(`${apiBase()}${PUSH_PATH}`, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ 'Authorization': `Bearer ${licenseKey}`,
78
+ },
79
+ body: JSON.stringify(payload),
80
+ });
81
+
82
+ if (res.ok) {
83
+ print(`✓ Queue updated — view at ${queueUrl(apiBase())}\n`);
84
+ return { ok: true, status: res.status };
85
+ }
86
+
87
+ if (res.status === 403) {
88
+ print('✗ --push requires a Team license\n');
89
+ return { ok: false, status: res.status };
90
+ }
91
+
92
+ print(`⚠ Push failed (${res.status}) — triage output unaffected\n`);
93
+ return { ok: false, status: res.status };
94
+ } catch {
95
+ print('⚠ Push failed (network error) — triage output unaffected\n');
96
+ return { ok: false };
97
+ }
98
+ }
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const FREE_LIMIT = 3;
5
+
6
+ const USAGE_FILE = 'usage.json';
7
+
8
+ function currentMonth() {
9
+ return new Date().toISOString().slice(0, 7); // "YYYY-MM"
10
+ }
11
+
12
+ function readUsageFile(configDir) {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(path.join(configDir, USAGE_FILE), 'utf8'));
15
+ } catch {
16
+ return { compliance: {} };
17
+ }
18
+ }
19
+
20
+ function writeUsageFile(configDir, data) {
21
+ try {
22
+ fs.mkdirSync(configDir, { recursive: true });
23
+ fs.writeFileSync(path.join(configDir, USAGE_FILE), JSON.stringify(data, null, 2), 'utf8');
24
+ } catch {
25
+ // Non-fatal — usage tracking is best-effort
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Checks free-tier usage for the current month.
31
+ * @param {string} configDir - Path to the TicketLens config directory
32
+ * @returns {{ count: number, month: string, canUse: boolean }}
33
+ */
34
+ export function checkUsage(configDir) {
35
+ const month = currentMonth();
36
+ const data = readUsageFile(configDir);
37
+ const count = data.compliance?.[month] ?? 0;
38
+ return { count, month, canUse: count < FREE_LIMIT };
39
+ }
40
+
41
+ /**
42
+ * Increments the compliance counter for the current month.
43
+ * Caller is responsible for checking {@link checkUsage} before calling this.
44
+ * This function does not enforce the free-tier cap — cap enforcement is
45
+ * handled by the orchestrator and server-side; client-side is UX-only.
46
+ * @param {string} configDir - Path to the TicketLens config directory
47
+ */
48
+ export function incrementUsage(configDir) {
49
+ const month = currentMonth();
50
+ const data = readUsageFile(configDir);
51
+ if (!data.compliance) data.compliance = {};
52
+ data.compliance[month] = (data.compliance[month] ?? 0) + 1;
53
+ writeUsageFile(configDir, data);
54
+ }
@@ -0,0 +1,12 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Detects the version control system in use at a given directory.
6
+ */
7
+ export function detectVcs(dir) {
8
+ if (existsSync(join(dir, '.git'))) return { type: 'git' };
9
+ if (existsSync(join(dir, '.svn'))) return { type: 'svn' };
10
+ if (existsSync(join(dir, '.hg'))) return { type: 'hg' };
11
+ return { type: 'none' };
12
+ }