ticketlens 0.36.0 → 0.38.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/package.json
CHANGED
|
@@ -6,15 +6,31 @@ import { findLinkedCommits } from './commit-linker.mjs';
|
|
|
6
6
|
import { analyzeDiff } from './diff-analyzer.mjs';
|
|
7
7
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
8
8
|
import { appendLedger } from './ledger.mjs';
|
|
9
|
+
import { createStyler } from './ansi.mjs';
|
|
9
10
|
|
|
10
11
|
export const STATUS_ICON = { FOUND: '✔', PARTIAL: '~', NOT_FOUND: '✖' };
|
|
11
12
|
|
|
12
|
-
function
|
|
13
|
+
export function statusColor(status, s) {
|
|
14
|
+
if (status === 'FOUND') return s.green;
|
|
15
|
+
if (status === 'PARTIAL') return s.yellow;
|
|
16
|
+
return s.dim;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Shared with matchColor in ticket-command.mjs (duplicates' match-confidence
|
|
20
|
+
// tiers) — same 70/50 thresholds, same green/yellow/dim vocabulary, applied
|
|
21
|
+
// here to overall requirement coverage instead of a single match score.
|
|
22
|
+
function coverageColor(pct, s) {
|
|
23
|
+
if (pct >= 70) return s.green;
|
|
24
|
+
if (pct >= 50) return s.yellow;
|
|
25
|
+
return s.dim;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatReport({ ticketKey, requirements, analysis, usage, isPro, s }) {
|
|
13
29
|
const { results, coveragePercent } = analysis;
|
|
14
30
|
const lines = [
|
|
15
31
|
'',
|
|
16
|
-
` Compliance Check — ${ticketKey}`,
|
|
17
|
-
` ${'─'.repeat(50)}`,
|
|
32
|
+
` Compliance Check — ${s.brand(s.bold(ticketKey))}`,
|
|
33
|
+
` ${s.dim('─'.repeat(50))}`,
|
|
18
34
|
'',
|
|
19
35
|
];
|
|
20
36
|
|
|
@@ -26,14 +42,15 @@ function formatReport({ ticketKey, requirements, analysis, usage, isPro }) {
|
|
|
26
42
|
}
|
|
27
43
|
|
|
28
44
|
for (const { requirement, status, evidence } of results) {
|
|
29
|
-
const icon = STATUS_ICON[status] ?? '?';
|
|
45
|
+
const icon = statusColor(status, s)(STATUS_ICON[status] ?? '?');
|
|
30
46
|
lines.push(` ${icon} ${requirement}`);
|
|
31
|
-
if (evidence) lines.push(` └─ ${evidence}`);
|
|
47
|
+
if (evidence) lines.push(` └─ ${s.dim(evidence)}`);
|
|
32
48
|
lines.push('');
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
lines.push('');
|
|
36
|
-
|
|
52
|
+
const found = results.filter(r => r.status === 'FOUND').length;
|
|
53
|
+
lines.push(` Coverage: ${coverageColor(coveragePercent, s)(`${coveragePercent}%`)} (${found}/${results.length} requirements found)`);
|
|
37
54
|
lines.push('');
|
|
38
55
|
|
|
39
56
|
if (!isPro) {
|
|
@@ -52,6 +69,12 @@ export async function runComplianceCheck({
|
|
|
52
69
|
ticketKey,
|
|
53
70
|
configDir = DEFAULT_CONFIG_DIR,
|
|
54
71
|
stream = process.stderr,
|
|
72
|
+
// Separate from `stream` (stderr, only used for the Pro-upgrade nudge
|
|
73
|
+
// above) — the report string itself is printed to stdout by the caller
|
|
74
|
+
// (fetch-ticket.mjs), so styling must gate on stdout's TTY-ness, not
|
|
75
|
+
// stderr's, or ANSI codes would leak into `tl compliance X > out.txt`
|
|
76
|
+
// while stderr stays attached to a real terminal.
|
|
77
|
+
outStream = process.stdout,
|
|
55
78
|
isLicensedFn = isLicensed,
|
|
56
79
|
showUpgradeFn = showUpgradePrompt,
|
|
57
80
|
checkUsageFn = checkUsage,
|
|
@@ -78,7 +101,8 @@ export async function runComplianceCheck({
|
|
|
78
101
|
const { diff } = findLinkedCommitsFn(ticketKey, { cwd: process.cwd() });
|
|
79
102
|
const analysis = analyzeDiffFn(requirements, diff);
|
|
80
103
|
|
|
81
|
-
const
|
|
104
|
+
const s = createStyler({ isTTY: outStream.isTTY });
|
|
105
|
+
const report = formatReport({ ticketKey, requirements, analysis, usage, isPro, s });
|
|
82
106
|
const coveragePercent = analysis.coveragePercent;
|
|
83
107
|
const missing = analysis.results
|
|
84
108
|
.filter(r => r.status === 'NOT_FOUND')
|
|
@@ -8,8 +8,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
8
8
|
import { fetchTicket } from './jira-client.mjs';
|
|
9
9
|
import { extractRequirements } from './requirement-extractor.mjs';
|
|
10
10
|
import { findLinkedCommits } from './commit-linker.mjs';
|
|
11
|
-
import { runComplianceCheck, STATUS_ICON } from './compliance-checker.mjs';
|
|
11
|
+
import { runComplianceCheck, STATUS_ICON, statusColor } from './compliance-checker.mjs';
|
|
12
12
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
13
|
+
import { createStyler } from './ansi.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Detect the git remote URL using execFn.
|
|
@@ -34,15 +35,16 @@ function detectRemoteUrl(execFn) {
|
|
|
34
35
|
* Build the "### Linked tickets" section from a ticket's linkedIssues.
|
|
35
36
|
*
|
|
36
37
|
* @param {Array} linkedIssues - array of { key, summary, linkType, direction }
|
|
38
|
+
* @param {object} s - styler from createStyler()
|
|
37
39
|
* @returns {string} section markdown or empty string
|
|
38
40
|
*/
|
|
39
|
-
function buildLinkedTicketsSection(linkedIssues) {
|
|
41
|
+
function buildLinkedTicketsSection(linkedIssues, s) {
|
|
40
42
|
if (!linkedIssues || linkedIssues.length === 0) return '';
|
|
41
43
|
|
|
42
44
|
const lines = ['', '### Linked tickets'];
|
|
43
45
|
for (const issue of linkedIssues) {
|
|
44
46
|
const rel = issue.linkType ?? issue.direction ?? 'Linked';
|
|
45
|
-
lines.push(`- ${issue.key}: ${rel} — ${issue.summary}`);
|
|
47
|
+
lines.push(`- ${s.brand(s.bold(issue.key))}: ${rel} — ${issue.summary}`);
|
|
46
48
|
}
|
|
47
49
|
return lines.join('\n');
|
|
48
50
|
}
|
|
@@ -52,9 +54,10 @@ function buildLinkedTicketsSection(linkedIssues) {
|
|
|
52
54
|
*
|
|
53
55
|
* @param {object|null} complianceResult - result from runComplianceCheckFn or null
|
|
54
56
|
* @param {string[]} requirements - raw requirements list
|
|
57
|
+
* @param {object} s - styler from createStyler()
|
|
55
58
|
* @returns {string} section markdown
|
|
56
59
|
*/
|
|
57
|
-
function buildCoverageSection(complianceResult, requirements) {
|
|
60
|
+
function buildCoverageSection(complianceResult, requirements, s) {
|
|
58
61
|
if (complianceResult === null) {
|
|
59
62
|
const lines = ['', '### Requirements coverage (coverage unavailable — Pro required)'];
|
|
60
63
|
for (const req of requirements) {
|
|
@@ -67,7 +70,7 @@ function buildCoverageSection(complianceResult, requirements) {
|
|
|
67
70
|
const lines = [``, `### Requirements coverage (${coveragePercent}%)`];
|
|
68
71
|
|
|
69
72
|
for (const { requirement, status, evidence } of results) {
|
|
70
|
-
const icon = STATUS_ICON[status] ?? '?';
|
|
73
|
+
const icon = statusColor(status, s)(STATUS_ICON[status] ?? '?');
|
|
71
74
|
const suffix = evidence ? ` (${evidence})` : '';
|
|
72
75
|
lines.push(`- ${icon} ${requirement}${suffix}`);
|
|
73
76
|
}
|
|
@@ -86,7 +89,8 @@ function buildCoverageSection(complianceResult, requirements) {
|
|
|
86
89
|
* @param {Function} [opts.findLinkedCommitsFn] - async fn(ticketKey, opts) → commit[]
|
|
87
90
|
* @param {Function} [opts.runComplianceCheckFn] - async fn({ brief, ticketKey, configDir }) → result|null
|
|
88
91
|
* @param {Function} [opts.execFn] - spawnSync-compatible for git remote detection
|
|
89
|
-
* @param {object} [opts.
|
|
92
|
+
* @param {object} [opts.outStream] - stdout — where this markdown is ultimately printed
|
|
93
|
+
* (`tl pr KEY | pbcopy` pipes stdout), so styling gates on its TTY-ness, not stderr's
|
|
90
94
|
* @returns {Promise<string>} markdown PR description
|
|
91
95
|
*/
|
|
92
96
|
export async function assemblePr(ticketKey, {
|
|
@@ -97,8 +101,9 @@ export async function assemblePr(ticketKey, {
|
|
|
97
101
|
findLinkedCommitsFn = findLinkedCommits,
|
|
98
102
|
runComplianceCheckFn = runComplianceCheck,
|
|
99
103
|
execFn = spawnSync,
|
|
100
|
-
|
|
104
|
+
outStream = process.stdout,
|
|
101
105
|
} = {}) {
|
|
106
|
+
const s = createStyler({ isTTY: outStream.isTTY });
|
|
102
107
|
// Fetch ticket data
|
|
103
108
|
const ticket = await fetchTicketFn(ticketKey, { configDir });
|
|
104
109
|
|
|
@@ -130,7 +135,7 @@ export async function assemblePr(ticketKey, {
|
|
|
130
135
|
);
|
|
131
136
|
|
|
132
137
|
// Build output
|
|
133
|
-
const lines = [`## ${ticketKey}: ${summary}`, ''];
|
|
138
|
+
const lines = [`## ${s.brand(s.bold(ticketKey))}: ${summary}`, ''];
|
|
134
139
|
|
|
135
140
|
// What changed — commits are raw git --oneline strings: "<sha> <message>"
|
|
136
141
|
lines.push('### What changed');
|
|
@@ -143,7 +148,7 @@ export async function assemblePr(ticketKey, {
|
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
// Requirements coverage
|
|
146
|
-
lines.push(buildCoverageSection(complianceResult, requirements));
|
|
151
|
+
lines.push(buildCoverageSection(complianceResult, requirements, s));
|
|
147
152
|
|
|
148
153
|
// Acceptance criteria
|
|
149
154
|
lines.push('');
|
|
@@ -157,7 +162,7 @@ export async function assemblePr(ticketKey, {
|
|
|
157
162
|
}
|
|
158
163
|
|
|
159
164
|
// Linked tickets (optional section)
|
|
160
|
-
const linkedSection = buildLinkedTicketsSection(linkedIssues);
|
|
165
|
+
const linkedSection = buildLinkedTicketsSection(linkedIssues, s);
|
|
161
166
|
if (linkedSection) {
|
|
162
167
|
lines.push(linkedSection);
|
|
163
168
|
}
|
|
@@ -45,10 +45,10 @@ function refuseGithubAttachments(adapter, attachPaths, stream) {
|
|
|
45
45
|
return true;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
function formatAttachSummary(attachResult) {
|
|
48
|
+
function formatAttachSummary(attachResult, s) {
|
|
49
49
|
if (!attachResult) return '';
|
|
50
50
|
const lines = [];
|
|
51
|
-
for (const u of attachResult.uploaded) lines.push(` Attached ${u.filename}${u.url ? ` (${u.url})` : ''}\n`);
|
|
51
|
+
for (const u of attachResult.uploaded) lines.push(` ${s.green('✔')} Attached ${s.bold(u.filename)}${u.url ? ` (${u.url})` : ''}\n`);
|
|
52
52
|
for (const e of attachResult.errors) lines.push(` Failed to attach ${e.path}: ${e.message}\n`);
|
|
53
53
|
if (attachResult.droppedCount > 0) lines.push(` ${attachResult.droppedCount} attachment(s) dropped — exceeds the ${MAX_ATTACHMENTS}-file limit per call.\n`);
|
|
54
54
|
return lines.join('');
|
|
@@ -190,11 +190,14 @@ function describeAppliedFields(applied) {
|
|
|
190
190
|
return parts.join(', ');
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
function formatUpdateResult(ticketKey, { applied, errors }) {
|
|
193
|
+
function formatUpdateResult(ticketKey, { applied, errors }, s) {
|
|
194
194
|
const appliedText = describeAppliedFields(applied);
|
|
195
195
|
const errorText = Object.entries(errors).map(([field, info]) => formatFieldError(field, info)).join('; ');
|
|
196
|
-
|
|
197
|
-
|
|
196
|
+
// Success/partial get the styled brand+bold key; total failure stays plain,
|
|
197
|
+
// consistent with every other refusal/error message in this file (e.g. the
|
|
198
|
+
// GitHub-priority-refusal line just above in runTicketUpdate).
|
|
199
|
+
if (appliedText && !errorText) return ` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} updated: ${s.bold(appliedText)}.\n`;
|
|
200
|
+
if (appliedText && errorText) return ` ${s.yellow('~')} ${s.brand(s.bold(ticketKey))} partially updated: ${s.bold(appliedText)}. Failed: ${errorText}.\n`;
|
|
198
201
|
return ` Nothing updated on ${ticketKey}. Failed: ${errorText}.\n`;
|
|
199
202
|
}
|
|
200
203
|
|
|
@@ -267,6 +270,7 @@ export async function runTicketComment(cmdArgs, {
|
|
|
267
270
|
|
|
268
271
|
const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
269
272
|
if (!adapter) return { ok: false };
|
|
273
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
270
274
|
|
|
271
275
|
// Uploaded BEFORE the comment write so a tracker capable of inline
|
|
272
276
|
// rendering (Jira Server/DC via wiki markup, Jira Cloud via a real ADF
|
|
@@ -287,13 +291,13 @@ export async function runTicketComment(cmdArgs, {
|
|
|
287
291
|
// actually landed) — a partial attach failure is reconstructable from
|
|
288
292
|
// the difference between the two, not just silently absent from audit.
|
|
289
293
|
logActionFn({ ticketKey, action: 'comment', actor, tracker: adapter.type, detail: { id: result.id, attachPaths, attachedFilenames: (attachResult?.uploaded ?? []).map(a => a.filename) } }, { configDir });
|
|
290
|
-
stream.write(` Comment posted to ${ticketKey}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult));
|
|
294
|
+
stream.write(` ${s.green('✔')} Comment posted to ${s.brand(s.bold(ticketKey))}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult, s));
|
|
291
295
|
return { ok: true };
|
|
292
296
|
} catch (err) {
|
|
293
297
|
// Attachments (if any) genuinely landed on the tracker before this
|
|
294
298
|
// write was attempted — formatAttachSummary is still shown here so a
|
|
295
299
|
// caller retrying the whole command doesn't blindly re-upload them.
|
|
296
|
-
stream.write(formatWriteFailure(ticketKey, err) + formatAttachSummary(attachResult));
|
|
300
|
+
stream.write(formatWriteFailure(ticketKey, err) + formatAttachSummary(attachResult, s));
|
|
297
301
|
return { ok: false };
|
|
298
302
|
}
|
|
299
303
|
}
|
|
@@ -323,13 +327,14 @@ export async function runTicketTransitionList(cmdArgs, {
|
|
|
323
327
|
|
|
324
328
|
try {
|
|
325
329
|
const options = await adapter.getTransitions(ticketKey);
|
|
330
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
326
331
|
if (options.length === 0) {
|
|
327
|
-
stream.write(` No valid transitions available for ${ticketKey}.\n`);
|
|
332
|
+
stream.write(` No valid transitions available for ${s.brand(s.bold(ticketKey))}.\n`);
|
|
328
333
|
return { ok: true, options: [] };
|
|
329
334
|
}
|
|
330
|
-
stream.write(` Valid transitions for ${ticketKey}:\n`);
|
|
331
|
-
for (const o of options) stream.write(`
|
|
332
|
-
stream.write(
|
|
335
|
+
stream.write(` Valid transitions for ${s.brand(s.bold(ticketKey))}:\n\n`);
|
|
336
|
+
for (const o of options) stream.write(` ${s.brand('●')} ${o.name}\n`);
|
|
337
|
+
stream.write(`\n Run again with --target="<name>" --confirm to execute.\n`);
|
|
333
338
|
return { ok: true, options };
|
|
334
339
|
} catch (err) {
|
|
335
340
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
@@ -389,7 +394,8 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
389
394
|
}
|
|
390
395
|
recordActionFn(ticketKey, 'transition', { configDir });
|
|
391
396
|
logActionFn({ ticketKey, action: 'transition', actor, tracker: adapter.type, detail: { to: result.to } }, { configDir });
|
|
392
|
-
|
|
397
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
398
|
+
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} transitioned to ${s.bold(`"${result.to}"`)}.\n`);
|
|
393
399
|
return { ok: true };
|
|
394
400
|
} catch (err) {
|
|
395
401
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
@@ -443,7 +449,8 @@ export async function runTicketAssign(cmdArgs, {
|
|
|
443
449
|
const result = await adapter.assignToSelf(ticketKey);
|
|
444
450
|
recordActionFn(ticketKey, 'assign', { configDir });
|
|
445
451
|
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
|
|
446
|
-
|
|
452
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
453
|
+
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
|
|
447
454
|
return { ok: true };
|
|
448
455
|
} catch (err) {
|
|
449
456
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
@@ -563,12 +570,14 @@ export async function runTicketLinkList(cmdArgs, {
|
|
|
563
570
|
|
|
564
571
|
try {
|
|
565
572
|
const types = await adapter.getLinkTypes();
|
|
573
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
566
574
|
if (types.length === 0) {
|
|
567
|
-
stream.write(` No link types available for ${sourceKey} → ${targetKey} on ${adapter.type}.\n`);
|
|
575
|
+
stream.write(` No link types available for ${s.brand(s.bold(sourceKey))} → ${s.brand(s.bold(targetKey))} on ${adapter.type}.\n`);
|
|
568
576
|
return { ok: true, types: [] };
|
|
569
577
|
}
|
|
570
|
-
stream.write(` Available link types for ${sourceKey} → ${targetKey} (${adapter.type}):\n`);
|
|
571
|
-
for (const t of types) stream.write(`
|
|
578
|
+
stream.write(` Available link types for ${s.brand(s.bold(sourceKey))} → ${s.brand(s.bold(targetKey))} (${adapter.type}):\n\n`);
|
|
579
|
+
for (const t of types) stream.write(` ${s.brand('●')} ${t}\n`);
|
|
580
|
+
stream.write('\n');
|
|
572
581
|
if (adapter.type === 'github') {
|
|
573
582
|
stream.write(` Note: GitHub has no generic link relationship — linking will CLOSE ${sourceKey} as a duplicate of ${targetKey}.\n`);
|
|
574
583
|
}
|
|
@@ -647,10 +656,11 @@ export async function runTicketLink(cmdArgs, {
|
|
|
647
656
|
}
|
|
648
657
|
recordActionFn(cooldownKey, 'link', { configDir });
|
|
649
658
|
logActionFn({ ticketKey: sourceKey, action: 'link', actor, tracker: adapter.type, detail: { targetKey, type } }, { configDir });
|
|
659
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
650
660
|
stream.write(
|
|
651
661
|
adapter.type === 'github'
|
|
652
|
-
? ` ${sourceKey} closed as a duplicate of ${targetKey}.\n`
|
|
653
|
-
: ` ${sourceKey} linked to ${targetKey} as "${type}".\n`,
|
|
662
|
+
? ` ${s.green('✔')} ${s.brand(s.bold(sourceKey))} closed as a duplicate of ${s.brand(s.bold(targetKey))}.\n`
|
|
663
|
+
: ` ${s.green('✔')} ${s.brand(s.bold(sourceKey))} linked to ${s.brand(s.bold(targetKey))} as ${s.bold(`"${type}"`)}.\n`,
|
|
654
664
|
);
|
|
655
665
|
return { ok: true };
|
|
656
666
|
} catch (err) {
|
|
@@ -733,7 +743,7 @@ export async function runTicketUpdate(cmdArgs, {
|
|
|
733
743
|
recordActionFn(ticketKey, 'update', { configDir });
|
|
734
744
|
logActionFn({ ticketKey, action: 'update', actor, tracker: adapter.type, detail: { ...result.applied, failed: Object.keys(result.errors) } }, { configDir });
|
|
735
745
|
}
|
|
736
|
-
stream.write(formatUpdateResult(ticketKey, result));
|
|
746
|
+
stream.write(formatUpdateResult(ticketKey, result, createStyler({ isTTY: stream.isTTY })));
|
|
737
747
|
return hasErrors ? { ok: false, applied: result.applied, errors: result.errors } : { ok: true, applied: result.applied };
|
|
738
748
|
} catch (err) {
|
|
739
749
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
@@ -794,6 +804,7 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
794
804
|
|
|
795
805
|
const adapter = resolveTicketAdapter(undefined, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
796
806
|
if (!adapter) return { ok: false };
|
|
807
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
797
808
|
const attachRefused = refuseGithubAttachments(adapter, attachPaths, stream);
|
|
798
809
|
|
|
799
810
|
if (adapter.type !== 'github' && !project) {
|
|
@@ -883,6 +894,6 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
883
894
|
}
|
|
884
895
|
}
|
|
885
896
|
|
|
886
|
-
stream.write(` Created ${result.key}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult));
|
|
897
|
+
stream.write(` ${s.green('✔')} Created ${s.brand(s.bold(result.key))}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult, s));
|
|
887
898
|
return { ok: true, key: result.key };
|
|
888
899
|
}
|