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,195 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
6
|
+
import { createStyler } from './ansi.mjs';
|
|
7
|
+
|
|
8
|
+
export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
|
|
9
|
+
const LICENSE_FILE = 'license.json';
|
|
10
|
+
const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
|
|
11
|
+
const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
|
|
12
|
+
const UPGRADE_URL = 'https://ticketlens.dev/pricing';
|
|
13
|
+
|
|
14
|
+
const ANSI_RE_LIC = /\x1b\[[0-9;]*m|\x1b\]8;[^\x07]*\x07/g;
|
|
15
|
+
const visLen = (s) => s.replace(ANSI_RE_LIC, '').length;
|
|
16
|
+
function padInner(str, width) {
|
|
17
|
+
return str + ' '.repeat(Math.max(0, width - visLen(str)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readLicense(configDir = DEFAULT_CONFIG_DIR) {
|
|
21
|
+
const filePath = path.join(configDir, LICENSE_FILE);
|
|
22
|
+
try {
|
|
23
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
24
|
+
const { sig, ...payload } = data;
|
|
25
|
+
// Legacy unsigned files are trusted; will be re-signed on next write
|
|
26
|
+
if (!sig) return payload;
|
|
27
|
+
const expected = crypto.createHmac('sha256', payload.key || '')
|
|
28
|
+
.update(JSON.stringify(payload)).digest('hex');
|
|
29
|
+
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ? payload : null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeLicense(data, configDir = DEFAULT_CONFIG_DIR) {
|
|
36
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
37
|
+
const filePath = path.join(configDir, LICENSE_FILE);
|
|
38
|
+
const { sig: _, ...payload } = data; // strip any existing sig before re-signing
|
|
39
|
+
const mac = crypto.createHmac('sha256', payload.key || '')
|
|
40
|
+
.update(JSON.stringify(payload)).digest('hex');
|
|
41
|
+
fs.writeFileSync(filePath, JSON.stringify({ ...payload, sig: mac }), 'utf8');
|
|
42
|
+
fs.chmodSync(filePath, 0o600);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isLicensed(tier, configDir = DEFAULT_CONFIG_DIR) {
|
|
46
|
+
const required = LICENSE_TIERS[tier] ?? 0;
|
|
47
|
+
if (required === 0) return true;
|
|
48
|
+
|
|
49
|
+
const license = readLicense(configDir);
|
|
50
|
+
if (!license) return false;
|
|
51
|
+
|
|
52
|
+
// Hard expiry — subscription cancelled, confirmed by server
|
|
53
|
+
if (license.expiresAt && new Date(license.expiresAt) < new Date()) return false;
|
|
54
|
+
|
|
55
|
+
// Offline grace period — reject if not revalidated within GRACE_DAYS
|
|
56
|
+
if (license.validatedAt) {
|
|
57
|
+
const daysSince = (Date.now() - new Date(license.validatedAt)) / 86400000;
|
|
58
|
+
if (daysSince > GRACE_DAYS) return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const actual = LICENSE_TIERS[license.tier] ?? 0;
|
|
62
|
+
return actual >= required;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fire-and-forget background revalidation. Call at CLI startup; never awaited.
|
|
67
|
+
* Silently updates license.json when the validation window has elapsed.
|
|
68
|
+
*/
|
|
69
|
+
export function revalidateIfStale(opts = {}) {
|
|
70
|
+
const { configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch } = opts;
|
|
71
|
+
const license = readLicense(configDir);
|
|
72
|
+
if (!license?.key || !license?.validatedAt) return;
|
|
73
|
+
const daysSince = (Date.now() - new Date(license.validatedAt)) / 86400000;
|
|
74
|
+
if (daysSince < REVALIDATION_DAYS) return;
|
|
75
|
+
revalidateLicense({ configDir, fetcher }).catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Styled upsell prompt — written to stderr, never pollutes stdout.
|
|
80
|
+
*/
|
|
81
|
+
export function showUpgradePrompt(requiredTier, featureFlag, { stream = process.stderr } = {}) {
|
|
82
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
83
|
+
const tier = requiredTier.charAt(0).toUpperCase() + requiredTier.slice(1);
|
|
84
|
+
const W = 44; // inner width
|
|
85
|
+
const bc = (t) => s.dim(t);
|
|
86
|
+
|
|
87
|
+
const featureLine = padInner(` ${s.yellow('◆')} ${s.bold(featureFlag)} requires ${s.bold(s.cyan(tier))}`, W);
|
|
88
|
+
const upgradeLine = padInner(` ${s.dim('Upgrade:')} ${s.link(UPGRADE_URL, s.cyan(UPGRADE_URL))}`, W);
|
|
89
|
+
const activateLine = padInner(` ${s.dim('Or run:')} ${s.dim('ticketlens activate <KEY>')}`, W);
|
|
90
|
+
const blank = ' '.repeat(W);
|
|
91
|
+
|
|
92
|
+
stream.write('\n');
|
|
93
|
+
stream.write(` ${bc('┌' + '─'.repeat(W) + '┐')}\n`);
|
|
94
|
+
stream.write(` ${bc('│')}${featureLine}${bc('│')}\n`);
|
|
95
|
+
stream.write(` ${bc('│')}${blank}${bc('│')}\n`);
|
|
96
|
+
stream.write(` ${bc('│')}${upgradeLine}${bc('│')}\n`);
|
|
97
|
+
stream.write(` ${bc('│')}${activateLine}${bc('│')}\n`);
|
|
98
|
+
stream.write(` ${bc('└' + '─'.repeat(W) + '┘')}\n`);
|
|
99
|
+
stream.write('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const LEMONSQUEEZY_ACTIVATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/activate';
|
|
103
|
+
const LEMONSQUEEZY_VALIDATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/validate';
|
|
104
|
+
|
|
105
|
+
function extractTier(meta) {
|
|
106
|
+
if (!meta) return 'pro';
|
|
107
|
+
const name = (meta.variant_name || meta.product_name || '').toLowerCase();
|
|
108
|
+
if (name.includes('team')) return 'team';
|
|
109
|
+
return 'pro';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function activateLicense(key, opts = {}) {
|
|
113
|
+
const { configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch, instanceName } = opts;
|
|
114
|
+
const instance = instanceName || os.hostname();
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetcher(LEMONSQUEEZY_ACTIVATE_URL, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
120
|
+
body: JSON.stringify({ license_key: key, instance_name: instance }),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
|
|
125
|
+
if (!data.activated && !data.valid) {
|
|
126
|
+
return { success: false, error: data.error || data.message || 'Invalid license key.' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const meta = data.meta || {};
|
|
130
|
+
const tier = extractTier(meta);
|
|
131
|
+
|
|
132
|
+
const license = {
|
|
133
|
+
key: data.license_key?.key || key,
|
|
134
|
+
tier,
|
|
135
|
+
email: meta.customer_email || null,
|
|
136
|
+
provider: 'lemonsqueezy',
|
|
137
|
+
instanceId: data.instance?.id || null,
|
|
138
|
+
validatedAt: new Date().toISOString(),
|
|
139
|
+
...(meta.ends_at ? { expiresAt: meta.ends_at } : {}),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
writeLicense(license, configDir);
|
|
143
|
+
return { success: true, tier, email: license.email };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { success: false, error: err.message };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function revalidateLicense(opts = {}) {
|
|
150
|
+
const { configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch, instanceName } = opts;
|
|
151
|
+
const license = readLicense(configDir);
|
|
152
|
+
if (!license) return { success: false, error: 'No license found.' };
|
|
153
|
+
|
|
154
|
+
const instance = instanceName || os.hostname();
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetcher(LEMONSQUEEZY_VALIDATE_URL, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
160
|
+
body: JSON.stringify({ license_key: license.key, instance_name: instance }),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const data = await res.json();
|
|
164
|
+
|
|
165
|
+
if (!data.valid) {
|
|
166
|
+
license.expiresAt = new Date().toISOString();
|
|
167
|
+
writeLicense(license, configDir);
|
|
168
|
+
return { success: false, error: 'License is no longer valid.' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
license.validatedAt = new Date().toISOString();
|
|
172
|
+
if (data.meta?.ends_at) license.expiresAt = data.meta.ends_at;
|
|
173
|
+
else delete license.expiresAt;
|
|
174
|
+
writeLicense(license, configDir);
|
|
175
|
+
return { success: true, tier: license.tier };
|
|
176
|
+
} catch {
|
|
177
|
+
return { success: true, tier: license.tier, cached: true };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function checkLicense(configDir = DEFAULT_CONFIG_DIR) {
|
|
182
|
+
const license = readLicense(configDir);
|
|
183
|
+
if (!license) return { tier: 'free', active: false };
|
|
184
|
+
|
|
185
|
+
const expired = license.expiresAt ? new Date(license.expiresAt) < new Date() : false;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
tier: license.tier,
|
|
189
|
+
active: !expired,
|
|
190
|
+
expired,
|
|
191
|
+
email: license.email,
|
|
192
|
+
key: license.key,
|
|
193
|
+
validatedAt: license.validatedAt,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ticket-to-PR Assembler
|
|
3
|
+
* Composes a markdown PR description from ticket data, linked commits,
|
|
4
|
+
* requirements, and compliance coverage.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { fetchTicket } from './jira-client.mjs';
|
|
9
|
+
import { extractRequirements } from './requirement-extractor.mjs';
|
|
10
|
+
import { findLinkedCommits } from './commit-linker.mjs';
|
|
11
|
+
import { runComplianceCheck } from './compliance-checker.mjs';
|
|
12
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detect the git remote URL using execFn.
|
|
16
|
+
* Returns the URL string or null if none detected.
|
|
17
|
+
*
|
|
18
|
+
* @param {Function} execFn - injectable spawnSync-compatible function
|
|
19
|
+
* @returns {string|null}
|
|
20
|
+
*/
|
|
21
|
+
function detectRemoteUrl(execFn) {
|
|
22
|
+
try {
|
|
23
|
+
const result = execFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' });
|
|
24
|
+
if (result.status === 0 && result.stdout) {
|
|
25
|
+
return result.stdout.trim();
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// non-fatal
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the "### Linked tickets" section from a ticket's linkedIssues.
|
|
35
|
+
*
|
|
36
|
+
* @param {Array} linkedIssues - array of { key, summary, linkType, direction }
|
|
37
|
+
* @returns {string} section markdown or empty string
|
|
38
|
+
*/
|
|
39
|
+
function buildLinkedTicketsSection(linkedIssues) {
|
|
40
|
+
if (!linkedIssues || linkedIssues.length === 0) return '';
|
|
41
|
+
|
|
42
|
+
const lines = ['', '### Linked tickets'];
|
|
43
|
+
for (const issue of linkedIssues) {
|
|
44
|
+
const rel = issue.linkType ?? issue.direction ?? 'Linked';
|
|
45
|
+
lines.push(`- ${issue.key}: ${rel} — ${issue.summary}`);
|
|
46
|
+
}
|
|
47
|
+
return lines.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the "### Requirements coverage" section.
|
|
52
|
+
*
|
|
53
|
+
* @param {object|null} complianceResult - result from runComplianceCheckFn or null
|
|
54
|
+
* @param {string[]} requirements - raw requirements list
|
|
55
|
+
* @returns {string} section markdown
|
|
56
|
+
*/
|
|
57
|
+
function buildCoverageSection(complianceResult, requirements) {
|
|
58
|
+
if (complianceResult === null) {
|
|
59
|
+
const lines = ['', '### Requirements coverage (coverage unavailable — Pro required)'];
|
|
60
|
+
for (const req of requirements) {
|
|
61
|
+
lines.push(`- ${req}`);
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { coveragePercent, report } = complianceResult;
|
|
67
|
+
const lines = [``, `### Requirements coverage (${coveragePercent}%)`];
|
|
68
|
+
|
|
69
|
+
for (const entry of report) {
|
|
70
|
+
if (entry.covered) {
|
|
71
|
+
const loc = entry.location ? ` (${entry.location})` : '';
|
|
72
|
+
lines.push(`- ✔ ${entry.req}${loc}`);
|
|
73
|
+
} else {
|
|
74
|
+
lines.push(`- ✖ ${entry.req}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Include any missing reqs not already in report
|
|
79
|
+
if (complianceResult.missing && complianceResult.missing.length > 0) {
|
|
80
|
+
const reportedReqs = new Set(report.map(r => r.req));
|
|
81
|
+
for (const missed of complianceResult.missing) {
|
|
82
|
+
if (!reportedReqs.has(missed)) {
|
|
83
|
+
lines.push(`- ✖ ${missed}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return lines.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Assemble a markdown PR description for the given ticket key.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} ticketKey - e.g. 'PROJ-123'
|
|
95
|
+
* @param {object} opts - injectable dependencies
|
|
96
|
+
* @param {string} [opts.configDir] - config directory path
|
|
97
|
+
* @param {Function} [opts.fetchTicketFn] - async fn(ticketKey, opts) → ticket
|
|
98
|
+
* @param {Function} [opts.extractRequirementsFn] - fn(text) → string[]
|
|
99
|
+
* @param {Function} [opts.findLinkedCommitsFn] - async fn(ticketKey, opts) → commit[]
|
|
100
|
+
* @param {Function} [opts.runComplianceCheckFn] - async fn({ brief, ticketKey, configDir }) → result|null
|
|
101
|
+
* @param {Function} [opts.execFn] - spawnSync-compatible for git remote detection
|
|
102
|
+
* @param {object} [opts.stream] - stderr for progress output (optional)
|
|
103
|
+
* @returns {Promise<string>} markdown PR description
|
|
104
|
+
*/
|
|
105
|
+
export async function assemblePr(ticketKey, {
|
|
106
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
107
|
+
cwd = process.cwd(),
|
|
108
|
+
fetchTicketFn = fetchTicket,
|
|
109
|
+
extractRequirementsFn = extractRequirements,
|
|
110
|
+
findLinkedCommitsFn = findLinkedCommits,
|
|
111
|
+
runComplianceCheckFn = runComplianceCheck,
|
|
112
|
+
execFn = spawnSync,
|
|
113
|
+
stream,
|
|
114
|
+
} = {}) {
|
|
115
|
+
// Fetch ticket data
|
|
116
|
+
const ticket = await fetchTicketFn(ticketKey, { configDir });
|
|
117
|
+
|
|
118
|
+
const summary = ticket.summary ?? ticket.fields?.summary ?? '';
|
|
119
|
+
const description = ticket.description ?? ticket.fields?.description ?? '';
|
|
120
|
+
const linkedIssues = ticket.linkedIssues ?? ticket.fields?.issuelinks ?? [];
|
|
121
|
+
|
|
122
|
+
// Extract requirements from description
|
|
123
|
+
const requirements = extractRequirementsFn(description);
|
|
124
|
+
|
|
125
|
+
// Find linked commits
|
|
126
|
+
const { commits = [], branches = [] } = await findLinkedCommitsFn(ticketKey, { cwd });
|
|
127
|
+
|
|
128
|
+
// Build a minimal plain-text brief for compliance check input
|
|
129
|
+
const brief = `## ${ticketKey}: ${summary}\n\n${description}`;
|
|
130
|
+
|
|
131
|
+
// Run compliance check (may return null for non-Pro)
|
|
132
|
+
let complianceResult = null;
|
|
133
|
+
try {
|
|
134
|
+
complianceResult = await runComplianceCheckFn({ brief, ticketKey, configDir });
|
|
135
|
+
} catch {
|
|
136
|
+
// non-fatal — treat as unavailable
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Detect remote URL for auto-close footer
|
|
140
|
+
const remoteUrl = detectRemoteUrl(execFn);
|
|
141
|
+
const isGitHubOrGitLab = remoteUrl && (
|
|
142
|
+
remoteUrl.includes('github.com') || remoteUrl.includes('gitlab.com')
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Build output
|
|
146
|
+
const lines = [`## ${ticketKey}: ${summary}`, ''];
|
|
147
|
+
|
|
148
|
+
// What changed — commits are raw git --oneline strings: "<sha> <message>"
|
|
149
|
+
lines.push('### What changed');
|
|
150
|
+
if (commits.length === 0) {
|
|
151
|
+
lines.push('_No linked commits found._');
|
|
152
|
+
} else {
|
|
153
|
+
for (const commitLine of commits) {
|
|
154
|
+
lines.push(`- ${commitLine}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Requirements coverage
|
|
159
|
+
lines.push(buildCoverageSection(complianceResult, requirements));
|
|
160
|
+
|
|
161
|
+
// Acceptance criteria
|
|
162
|
+
lines.push('');
|
|
163
|
+
lines.push('### Acceptance criteria');
|
|
164
|
+
if (requirements.length === 0) {
|
|
165
|
+
lines.push('_No acceptance criteria found._');
|
|
166
|
+
} else {
|
|
167
|
+
for (const req of requirements) {
|
|
168
|
+
lines.push(`- ${req}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Linked tickets (optional section)
|
|
173
|
+
const linkedSection = buildLinkedTicketsSection(linkedIssues);
|
|
174
|
+
if (linkedSection) {
|
|
175
|
+
lines.push(linkedSection);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Close footer
|
|
179
|
+
if (isGitHubOrGitLab) {
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push('---');
|
|
182
|
+
lines.push(`Closes ${ticketKey}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive profile pickers.
|
|
3
|
+
* - promptProfileSelect: "profile not found" error recovery.
|
|
4
|
+
* - promptProfileMismatch: ticket prefix not configured in any profile.
|
|
5
|
+
* Both fall back to static output on non-TTY.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createStyler } from './ansi.mjs';
|
|
9
|
+
import { runRawSelect } from './select-prompt.mjs';
|
|
10
|
+
|
|
11
|
+
export function promptProfileSelect({ profileName, suggestion, available }, { stream = process.stderr } = {}) {
|
|
12
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
13
|
+
const isTTY = stream.isTTY;
|
|
14
|
+
|
|
15
|
+
// Static header (written once)
|
|
16
|
+
stream.write('\n');
|
|
17
|
+
stream.write(` ${s.red('✖')} Profile ${s.bold(`"${profileName}"`)} not found.\n`);
|
|
18
|
+
if (suggestion) {
|
|
19
|
+
stream.write(`\n ${s.dim('Did you mean?')} ${s.brand(suggestion)}\n`);
|
|
20
|
+
}
|
|
21
|
+
stream.write(`\n ${s.dim('Select a profile:')}\n\n`);
|
|
22
|
+
|
|
23
|
+
// Non-TTY: just list profiles and exit
|
|
24
|
+
if (!isTTY || !process.stdin.setRawMode) {
|
|
25
|
+
for (const name of available) {
|
|
26
|
+
stream.write(` ${s.brand('›')} ${name}\n`);
|
|
27
|
+
}
|
|
28
|
+
stream.write('\n');
|
|
29
|
+
return Promise.resolve(null);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const initialIndex = suggestion ? Math.max(0, available.indexOf(suggestion)) : 0;
|
|
33
|
+
|
|
34
|
+
function renderFn(selected) {
|
|
35
|
+
const lines = [];
|
|
36
|
+
for (let i = 0; i < available.length; i++) {
|
|
37
|
+
const marker = i === selected ? s.brand('❯') : ' ';
|
|
38
|
+
const label = i === selected ? s.bold(s.brand(available[i])) : available[i];
|
|
39
|
+
lines.push(` ${marker} ${label}`);
|
|
40
|
+
}
|
|
41
|
+
lines.push('');
|
|
42
|
+
lines.push(` ${s.dim('↑/↓ select Enter confirm q/Esc cancel')}`);
|
|
43
|
+
stream.write(lines.join('\n') + '\n');
|
|
44
|
+
return lines.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return runRawSelect({ count: available.length, initialIndex, renderFn, stream })
|
|
48
|
+
.then(index => {
|
|
49
|
+
if (index === null) return null;
|
|
50
|
+
stream.write(` ${s.green('✔')} Using profile ${s.bold(s.brand(available[index]))}\n\n`);
|
|
51
|
+
return available[index];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Prompts the user to choose between multiple profiles that all share the
|
|
57
|
+
* same ticket prefix. Shown before the fetch attempt when ambiguity exists.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} ticketKey e.g. "PROJ-123"
|
|
60
|
+
* @param {Array<{name:string, baseUrl:string|null}>} profiles matching profiles
|
|
61
|
+
* @param {{ stream?: NodeJS.WriteStream }} [opts]
|
|
62
|
+
* @returns {Promise<string|null>} chosen profile name, or null if cancelled
|
|
63
|
+
*/
|
|
64
|
+
export function promptMultipleMatches(ticketKey, profiles, { stream = process.stderr } = {}) {
|
|
65
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
66
|
+
const prefix = ticketKey.split('-')[0];
|
|
67
|
+
|
|
68
|
+
stream.write('\n');
|
|
69
|
+
stream.write(` ${s.cyan('●')} ${s.bold(plural(profiles.length, 'profile'))} match prefix ${s.bold(s.cyan(prefix))} — which should handle ${s.dim(ticketKey)}?\n\n`);
|
|
70
|
+
|
|
71
|
+
if (!stream.isTTY || !process.stdin.setRawMode) {
|
|
72
|
+
for (const p of profiles) {
|
|
73
|
+
const sub = p.baseUrl ? ` ${s.dim(p.baseUrl)}` : '';
|
|
74
|
+
stream.write(` ${s.cyan('›')} ${p.name}${sub}\n`);
|
|
75
|
+
}
|
|
76
|
+
stream.write(`\n ${s.dim(`→ Re-run with --profile=NAME to skip this prompt`)}\n\n`);
|
|
77
|
+
return Promise.resolve(null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderFn(selected) {
|
|
81
|
+
const lines = [];
|
|
82
|
+
for (let i = 0; i < profiles.length; i++) {
|
|
83
|
+
const p = profiles[i];
|
|
84
|
+
const isSelected = i === selected;
|
|
85
|
+
const marker = isSelected ? s.blue('❯') : ' ';
|
|
86
|
+
const label = isSelected ? s.bold(s.blue(p.name)) : p.name;
|
|
87
|
+
lines.push(` ${marker} ${label}`);
|
|
88
|
+
if (p.baseUrl) lines.push(` ${s.dim(p.baseUrl)}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push(` ${s.dim('↑/↓ select Enter confirm Esc cancel')}`);
|
|
92
|
+
stream.write(lines.join('\n') + '\n');
|
|
93
|
+
return lines.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return runRawSelect({ count: profiles.length, initialIndex: 0, renderFn, stream })
|
|
97
|
+
.then(index => {
|
|
98
|
+
if (index === null) return null;
|
|
99
|
+
const picked = profiles[index].name;
|
|
100
|
+
stream.write(` ${s.green('✔')} Using ${s.bold(s.cyan(picked))}\n\n`);
|
|
101
|
+
return picked;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function plural(n, word) {
|
|
106
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Prompts the user to pick a profile when the ticket's prefix isn't
|
|
111
|
+
* configured in any profile. The resolved profile is pre-selected.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} ticketKey e.g. "ECNT-3888"
|
|
114
|
+
* @param {string} currentProfile name of the auto-resolved profile
|
|
115
|
+
* @param {Array<{name:string, baseUrl:string|null}>} profiles all known profiles
|
|
116
|
+
* @param {{ stream?: NodeJS.WriteStream }} [opts]
|
|
117
|
+
* @returns {Promise<string|null>} picked profile name, or null to keep current
|
|
118
|
+
*/
|
|
119
|
+
export function promptProfileMismatch(ticketKey, currentProfile, profiles, { stream = process.stderr } = {}) {
|
|
120
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
121
|
+
const prefix = ticketKey.split('-')[0];
|
|
122
|
+
|
|
123
|
+
stream.write('\n');
|
|
124
|
+
stream.write(` ${s.yellow('⚠')} No profile is configured for ${s.bold(s.cyan(prefix))} tickets.\n`);
|
|
125
|
+
stream.write(` ${s.dim('Currently using:')} ${s.cyan(currentProfile)}\n`);
|
|
126
|
+
stream.write(`\n ${s.dim(`Which profile should handle ${ticketKey}?`)}\n\n`);
|
|
127
|
+
|
|
128
|
+
if (!stream.isTTY || !process.stdin.setRawMode) {
|
|
129
|
+
for (const p of profiles) {
|
|
130
|
+
const sub = p.baseUrl ? ` ${s.dim(p.baseUrl)}` : '';
|
|
131
|
+
stream.write(` ${s.cyan('›')} ${p.name}${sub}\n`);
|
|
132
|
+
}
|
|
133
|
+
stream.write(`\n ${s.dim(`→ Re-run with --profile=NAME to skip this prompt`)}\n\n`);
|
|
134
|
+
return Promise.resolve(null);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const initialIndex = Math.max(0, profiles.findIndex(p => p.name === currentProfile));
|
|
138
|
+
|
|
139
|
+
function renderFn(selected) {
|
|
140
|
+
const lines = [];
|
|
141
|
+
for (let i = 0; i < profiles.length; i++) {
|
|
142
|
+
const p = profiles[i];
|
|
143
|
+
const isSelected = i === selected;
|
|
144
|
+
const marker = isSelected ? s.blue('❯') : ' ';
|
|
145
|
+
const label = isSelected ? s.bold(s.blue(p.name)) : p.name;
|
|
146
|
+
lines.push(` ${marker} ${label}`);
|
|
147
|
+
if (p.baseUrl) lines.push(` ${s.dim(p.baseUrl)}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push('');
|
|
150
|
+
lines.push(` ${s.dim(`↑/↓ select Enter confirm Esc continue with ${currentProfile}`)}`);
|
|
151
|
+
stream.write(lines.join('\n') + '\n');
|
|
152
|
+
return lines.length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return runRawSelect({ count: profiles.length, initialIndex, renderFn, stream })
|
|
156
|
+
.then(index => {
|
|
157
|
+
if (index === null) return null;
|
|
158
|
+
const picked = profiles[index].name;
|
|
159
|
+
if (picked === currentProfile) {
|
|
160
|
+
stream.write(` ${s.dim(`Continuing with ${s.cyan(picked)}`)}\n\n`);
|
|
161
|
+
} else {
|
|
162
|
+
stream.write(` ${s.green('✔')} Using ${s.bold(s.cyan(picked))} for this fetch\n\n`);
|
|
163
|
+
}
|
|
164
|
+
return picked;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Simple profile selector for the connection-retry flow.
|
|
170
|
+
* No prefix-warning header — just "switch to which profile?".
|
|
171
|
+
*
|
|
172
|
+
* @param {string} currentProfile
|
|
173
|
+
* @param {Array<{name:string, baseUrl:string|null}>} profiles
|
|
174
|
+
* @param {{ stream?: NodeJS.WriteStream }} [opts]
|
|
175
|
+
* @returns {Promise<string|null>}
|
|
176
|
+
*/
|
|
177
|
+
export function promptSwitchProfile(currentProfile, profiles, { stream = process.stderr } = {}) {
|
|
178
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
179
|
+
|
|
180
|
+
stream.write(`\n ${s.dim('Switch to which profile?')}\n\n`);
|
|
181
|
+
|
|
182
|
+
if (!stream.isTTY || !process.stdin.setRawMode) {
|
|
183
|
+
for (const p of profiles) {
|
|
184
|
+
const sub = p.baseUrl ? ` ${s.dim(p.baseUrl)}` : '';
|
|
185
|
+
stream.write(` ${s.cyan('›')} ${p.name}${sub}\n`);
|
|
186
|
+
}
|
|
187
|
+
stream.write(`\n ${s.dim(`→ Re-run with --profile=NAME to specify directly`)}\n\n`);
|
|
188
|
+
return Promise.resolve(null);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const initialIndex = Math.max(0, profiles.findIndex(p => p.name === currentProfile));
|
|
192
|
+
|
|
193
|
+
function renderFn(selected) {
|
|
194
|
+
const lines = [];
|
|
195
|
+
for (let i = 0; i < profiles.length; i++) {
|
|
196
|
+
const p = profiles[i];
|
|
197
|
+
const isSelected = i === selected;
|
|
198
|
+
const marker = isSelected ? s.blue('❯') : ' ';
|
|
199
|
+
const label = isSelected ? s.bold(s.blue(p.name)) : p.name;
|
|
200
|
+
lines.push(` ${marker} ${label}`);
|
|
201
|
+
if (p.baseUrl) lines.push(` ${s.dim(p.baseUrl)}`);
|
|
202
|
+
}
|
|
203
|
+
lines.push('');
|
|
204
|
+
lines.push(` ${s.dim('↑/↓ select Enter confirm Esc cancel')}`);
|
|
205
|
+
stream.write(lines.join('\n') + '\n');
|
|
206
|
+
return lines.length;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return runRawSelect({ count: profiles.length, initialIndex, renderFn, stream })
|
|
210
|
+
.then(index => {
|
|
211
|
+
if (index === null) return null;
|
|
212
|
+
const picked = profiles[index].name;
|
|
213
|
+
stream.write(` ${s.green('✔')} Switching to ${s.bold(s.cyan(picked))}\n\n`);
|
|
214
|
+
return picked;
|
|
215
|
+
});
|
|
216
|
+
}
|