ticketlens 0.9.18 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ticketlens.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { promptSecret, promptText } from '../skills/jtb/scripts/lib/prompt-helpe
|
|
|
36
36
|
import { checkForUpdate, getUpdateHint } from '../skills/jtb/scripts/lib/update-check.mjs';
|
|
37
37
|
import { incrementInvocation, incrementCommand } from '../skills/jtb/scripts/lib/activity-counter.mjs';
|
|
38
38
|
import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
|
|
39
|
+
import { applyTeamConfigOnLogin, checkTeamJiraConfigUpdate } from '../skills/jtb/scripts/lib/team-jira-sync.mjs';
|
|
39
40
|
|
|
40
41
|
const TRACKED_COMMANDS = new Set([
|
|
41
42
|
'triage', 'fetch', 'get', 'compliance', 'review', 'standup',
|
|
@@ -72,12 +73,24 @@ process.on('exit', () => {
|
|
|
72
73
|
});
|
|
73
74
|
|
|
74
75
|
switch (command) {
|
|
75
|
-
case 'fetch':
|
|
76
|
-
|
|
76
|
+
case 'fetch': {
|
|
77
|
+
// Flow 2: fire team config check concurrently; banner shown after brief output
|
|
78
|
+
const _teamCheck = checkTeamJiraConfigUpdate().catch(() => null);
|
|
79
|
+
runFetch(cmdArgs).then(async () => {
|
|
80
|
+
const tcResult = await _teamCheck;
|
|
81
|
+
if (tcResult?.banner) {
|
|
82
|
+
const s = createStyler({ isTTY: process.stderr.isTTY });
|
|
83
|
+
process.stderr.write(`\n ${s.yellow('!')} ${tcResult.banner}\n`);
|
|
84
|
+
} else if (tcResult?.deleted) {
|
|
85
|
+
const s = createStyler({ isTTY: process.stderr.isTTY });
|
|
86
|
+
process.stderr.write(`\n ${s.yellow('!')} Team Jira config removed by manager — using local credentials.\n`);
|
|
87
|
+
}
|
|
88
|
+
}).catch(err => {
|
|
77
89
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
78
90
|
process.exitCode = 1;
|
|
79
91
|
});
|
|
80
92
|
break;
|
|
93
|
+
}
|
|
81
94
|
|
|
82
95
|
case 'triage':
|
|
83
96
|
runTriage(cmdArgs).catch(err => {
|
|
@@ -481,6 +494,13 @@ switch (command) {
|
|
|
481
494
|
|
|
482
495
|
saveCliToken(token);
|
|
483
496
|
process.stderr.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Logged in.\n`);
|
|
497
|
+
|
|
498
|
+
// Flow 1: pull team Jira config for Pro/Team members (silently skipped for Free)
|
|
499
|
+
const tcLogin = await applyTeamConfigOnLogin().catch(() => null);
|
|
500
|
+
if (tcLogin?.ok) {
|
|
501
|
+
process.stderr.write(` ${s.dim(`○ Team Jira config applied for ${s.cyan(tcLogin.groupName)}.`)}\n`);
|
|
502
|
+
}
|
|
503
|
+
|
|
484
504
|
process.stderr.write(`\n Run ${s.cyan('ticketlens sync')} to pull your connections.\n\n`);
|
|
485
505
|
})().catch(err => {
|
|
486
506
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
@@ -541,6 +561,14 @@ switch (command) {
|
|
|
541
561
|
}
|
|
542
562
|
}
|
|
543
563
|
|
|
564
|
+
// Flow 3: also pull team Jira config update (Pro/Team); silently skipped for Free
|
|
565
|
+
const tcSync = await checkTeamJiraConfigUpdate().catch(() => null);
|
|
566
|
+
if (tcSync?.banner) {
|
|
567
|
+
process.stderr.write(`\n ${s.yellow('!')} ${tcSync.banner}\n`);
|
|
568
|
+
} else if (tcSync?.deleted) {
|
|
569
|
+
process.stderr.write(`\n ${s.yellow('!')} Team Jira config removed by manager — using local credentials.\n`);
|
|
570
|
+
}
|
|
571
|
+
|
|
544
572
|
process.stderr.write('\n');
|
|
545
573
|
})().catch(err => {
|
|
546
574
|
process.stderr.write(`Error: ${err.message}\n`);
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.10.1 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -297,3 +297,14 @@ Forces a fresh fetch from Jira, bypassing the local brief cache (4-hour TTL by d
|
|
|
297
297
|
### --no-attachments
|
|
298
298
|
|
|
299
299
|
Skip downloading and reading ticket attachments. Speeds up the fetch for tickets with large or irrelevant file attachments.
|
|
300
|
+
|
|
301
|
+
### Team Jira config auto-sync (Pro/Team)
|
|
302
|
+
|
|
303
|
+
Pro and Team members whose manager has configured a shared Jira profile in the Console (`/console/admin/jira`) receive the config automatically — no manual setup needed.
|
|
304
|
+
|
|
305
|
+
- **On `ticketlens login`:** team config is fetched and written to the local profile immediately after authentication.
|
|
306
|
+
- **On every fetch (including `/jtb TICKET-KEY`):** the CLI silently checks whether the team config has been updated since last sync. If it has, the new config is applied and a banner is printed to stderr after the brief — e.g. `! Team Jira config updated by your manager.`
|
|
307
|
+
- **On `ticketlens sync`:** explicitly force-pulls the latest team config.
|
|
308
|
+
- **If the team config is removed by the manager:** the CLI retains local credentials and shows a `! Team Jira config removed by manager — using local credentials.` banner.
|
|
309
|
+
|
|
310
|
+
This is background behaviour — no action needed from you as an AI assistant. The banner may appear in CLI output; it is informational only.
|
|
@@ -122,7 +122,8 @@ export async function browserLogin({
|
|
|
122
122
|
let url = `${consoleBase}/console/auth/cli`
|
|
123
123
|
+ `?port=${port}`
|
|
124
124
|
+ `&state=${encodeURIComponent(state)}`
|
|
125
|
-
+ `&hostname=${encodeURIComponent(hostname)}
|
|
125
|
+
+ `&hostname=${encodeURIComponent(hostname)}`
|
|
126
|
+
+ `&fresh=1`;
|
|
126
127
|
|
|
127
128
|
const license = readLicenseFn();
|
|
128
129
|
if (license?.email) {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches and applies the team Jira config shared by the manager.
|
|
3
|
+
* Non-secret fields only (URL, auth type, prefixes, project paths, triage statuses).
|
|
4
|
+
* Each member stores their own Jira credentials locally.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { readCliToken } from './cli-auth.mjs';
|
|
10
|
+
import { saveProfile, loadProfiles } from './profile-resolver.mjs';
|
|
11
|
+
import { apiBase } from './api-utils.mjs';
|
|
12
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
13
|
+
|
|
14
|
+
const META_FILE = 'team-jira-meta.json';
|
|
15
|
+
|
|
16
|
+
function metaPath(configDir) {
|
|
17
|
+
return join(configDir, META_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readMeta(configDir) {
|
|
21
|
+
const p = metaPath(configDir);
|
|
22
|
+
if (!existsSync(p)) return null;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeMeta(configDir, data) {
|
|
31
|
+
writeFileSync(metaPath(configDir), JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetches GET /v1/team/config from the API.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {string} [opts.configDir]
|
|
39
|
+
* @param {Function} [opts.fetcher] - injectable fetch for testing
|
|
40
|
+
* @returns {Promise<{error: string, [key: string]: any} | object>}
|
|
41
|
+
*/
|
|
42
|
+
export async function fetchTeamJiraConfig({ configDir = DEFAULT_CONFIG_DIR, fetcher = fetch } = {}) {
|
|
43
|
+
const token = readCliToken(configDir);
|
|
44
|
+
if (!token) return { error: 'no-token' };
|
|
45
|
+
|
|
46
|
+
const url = `${apiBase()}/v1/team/config`;
|
|
47
|
+
let res;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetcher(url, {
|
|
50
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
51
|
+
signal: AbortSignal.timeout(10000),
|
|
52
|
+
});
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return { error: 'network', message: e.message };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (res.status === 403) return { error: 'http-403' };
|
|
58
|
+
if (res.status === 404) return { error: 'http-404' };
|
|
59
|
+
if (!res.ok) return { error: `http-${res.status}` };
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
return await res.json();
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return { error: 'parse', message: e.message };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Converts API team config response to a CLI profile data object.
|
|
70
|
+
*/
|
|
71
|
+
function teamConfigToCliProfile(apiData) {
|
|
72
|
+
const profile = { baseUrl: apiData.jira_base_url, auth: apiData.auth_type };
|
|
73
|
+
if (apiData.prefixes?.length) profile.ticketPrefixes = apiData.prefixes;
|
|
74
|
+
if (apiData.project_paths?.length) profile.projectPaths = apiData.project_paths;
|
|
75
|
+
if (apiData.triage_statuses?.length) profile.triageStatuses = apiData.triage_statuses;
|
|
76
|
+
return profile;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Writes team config to the named profile without touching credentials.
|
|
81
|
+
* Returns { error, message } on failure — never throws.
|
|
82
|
+
*/
|
|
83
|
+
async function applyTeamJiraConfig(groupName, profileData, configDir) {
|
|
84
|
+
try {
|
|
85
|
+
await saveProfile(groupName, profileData, null, configDir);
|
|
86
|
+
return { ok: true };
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { error: 'save-failed', message: e.message };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Silently checks whether the team Jira config has changed since last fetch.
|
|
94
|
+
* Applies the new config and returns a banner string if changed.
|
|
95
|
+
* Never throws — always returns an object.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} opts
|
|
98
|
+
* @param {string} [opts.configDir]
|
|
99
|
+
* @param {Function} [opts.fetcher]
|
|
100
|
+
* @returns {Promise<{updated: boolean, deleted?: boolean, banner?: string, error?: string}>}
|
|
101
|
+
*/
|
|
102
|
+
export async function checkTeamJiraConfigUpdate({ configDir = DEFAULT_CONFIG_DIR, fetcher = fetch } = {}) {
|
|
103
|
+
const apiData = await fetchTeamJiraConfig({ configDir, fetcher });
|
|
104
|
+
|
|
105
|
+
if (apiData.error === 'http-403') return { updated: false };
|
|
106
|
+
if (apiData.error === 'http-404') return { updated: false, deleted: true };
|
|
107
|
+
if (apiData.error) return { updated: false, error: apiData.error };
|
|
108
|
+
|
|
109
|
+
const meta = readMeta(configDir);
|
|
110
|
+
|
|
111
|
+
// No change since last fetch — skip silently
|
|
112
|
+
if (meta?.updated_at && meta.updated_at === apiData.updated_at) {
|
|
113
|
+
return { updated: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const groupName = String(apiData.group_name ?? '').trim().slice(0, 100);
|
|
117
|
+
if (!groupName || /^(__proto__|constructor|prototype)$/.test(groupName)) {
|
|
118
|
+
return { updated: false, error: 'invalid-group-name' };
|
|
119
|
+
}
|
|
120
|
+
const profileData = teamConfigToCliProfile(apiData);
|
|
121
|
+
const applied = await applyTeamJiraConfig(groupName, profileData, configDir);
|
|
122
|
+
|
|
123
|
+
if (applied.error) return { updated: false, error: applied.error };
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
writeMeta(configDir, {
|
|
127
|
+
group_name: groupName,
|
|
128
|
+
updated_at: apiData.updated_at,
|
|
129
|
+
jira_base_url: apiData.jira_base_url,
|
|
130
|
+
auth_type: apiData.auth_type,
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
return { updated: false, error: 'meta-write-failed' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const changedFields = [];
|
|
137
|
+
if (meta?.jira_base_url && meta.jira_base_url !== apiData.jira_base_url) changedFields.push('Jira URL');
|
|
138
|
+
if (meta?.auth_type && meta.auth_type !== apiData.auth_type) changedFields.push('auth type');
|
|
139
|
+
|
|
140
|
+
const detail = changedFields.length > 0
|
|
141
|
+
? ` (${changedFields.join(', ')} changed)`
|
|
142
|
+
: '';
|
|
143
|
+
|
|
144
|
+
const banner = `⚠ Team Jira config updated by manager (${apiData.updated_at})${detail}.`;
|
|
145
|
+
|
|
146
|
+
return { updated: true, banner, groupName };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fetches team Jira config on first login and applies it.
|
|
151
|
+
* Returns the group name so the caller can prompt for personal credentials.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} opts
|
|
154
|
+
* @param {string} [opts.configDir]
|
|
155
|
+
* @param {Function} [opts.fetcher]
|
|
156
|
+
* @returns {Promise<{ok: boolean, groupName?: string, error?: string}>}
|
|
157
|
+
*/
|
|
158
|
+
export async function applyTeamConfigOnLogin({ configDir = DEFAULT_CONFIG_DIR, fetcher = fetch } = {}) {
|
|
159
|
+
const apiData = await fetchTeamJiraConfig({ configDir, fetcher });
|
|
160
|
+
|
|
161
|
+
// Free tier / no token / network error — silently skip (not a blocker for login)
|
|
162
|
+
if (apiData.error) return { ok: false, error: apiData.error };
|
|
163
|
+
|
|
164
|
+
const groupName = String(apiData.group_name ?? '').trim().slice(0, 100);
|
|
165
|
+
if (!groupName || /^(__proto__|constructor|prototype)$/.test(groupName)) {
|
|
166
|
+
return { ok: false, error: 'invalid-group-name' };
|
|
167
|
+
}
|
|
168
|
+
const profileData = teamConfigToCliProfile(apiData);
|
|
169
|
+
const applied = await applyTeamJiraConfig(groupName, profileData, configDir);
|
|
170
|
+
|
|
171
|
+
if (applied.error) return { ok: false, error: applied.error };
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
writeMeta(configDir, {
|
|
175
|
+
group_name: groupName,
|
|
176
|
+
updated_at: apiData.updated_at,
|
|
177
|
+
jira_base_url: apiData.jira_base_url,
|
|
178
|
+
auth_type: apiData.auth_type,
|
|
179
|
+
});
|
|
180
|
+
} catch {
|
|
181
|
+
return { ok: false, error: 'meta-write-failed' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { ok: true, groupName, authType: apiData.auth_type };
|
|
185
|
+
}
|