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.
- package/LICENSE +21 -0
- package/README.md +457 -0
- package/bin/ticketlens.mjs +376 -0
- package/package.json +37 -0
- package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
- package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
- package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
- package/skills/jtb/scripts/lib/ansi.mjs +87 -0
- package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
- package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
- package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
- package/skills/jtb/scripts/lib/banner.mjs +201 -0
- package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
- package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
- package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
- package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
- package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
- package/skills/jtb/scripts/lib/cli.mjs +87 -0
- package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
- package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
- package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
- package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
- package/skills/jtb/scripts/lib/config.mjs +63 -0
- package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
- package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
- package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
- package/skills/jtb/scripts/lib/help.mjs +253 -0
- package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
- package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
- package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
- package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
- package/skills/jtb/scripts/lib/ledger.mjs +96 -0
- package/skills/jtb/scripts/lib/license.mjs +195 -0
- package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
- package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
- package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
- package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
- package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
- package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
- package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
- package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
- package/skills/jtb/scripts/lib/spinner.mjs +44 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
- package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
- package/skills/jtb/scripts/lib/sync.mjs +119 -0
- package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
- package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
- package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
- package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
- package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
- 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
|
+
}
|