@falldownthesystem/devtool 0.2.0 → 0.4.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 +2 -2
- package/src/clients/serviceDesk.js +119 -0
- package/src/commands/sd.js +135 -0
- package/src/config/store.js +53 -0
- package/src/formatters/serviceDesk.js +267 -0
- package/src/index.js +4 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@falldownthesystem/devtool",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for Bitbucket PRs, Jira tickets, and
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "CLI for Bitbucket PRs, Jira tickets, Confluence pages, and Service Desk tickets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"dev": "src/index.js"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { get } from '../config/store.js';
|
|
2
|
+
|
|
3
|
+
function baseUrl() {
|
|
4
|
+
return get('serviceDesk.baseUrl');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function authHeader() {
|
|
8
|
+
const email = get('serviceDesk.email');
|
|
9
|
+
const token = get('serviceDesk.token');
|
|
10
|
+
return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function request(path, params = {}, extraHeaders = {}) {
|
|
14
|
+
const url = new URL(path.startsWith('http') ? path : `${baseUrl()}${path}`);
|
|
15
|
+
for (const [k, v] of Object.entries(params)) {
|
|
16
|
+
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
headers: {
|
|
21
|
+
'Authorization': authHeader(),
|
|
22
|
+
'Accept': 'application/json',
|
|
23
|
+
...extraHeaders,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const err = new Error(`Service Desk API error: ${res.status} ${res.statusText}`);
|
|
29
|
+
err.status = res.status;
|
|
30
|
+
err.url = url.toString();
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return res.json();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getIssue(issueKey) {
|
|
38
|
+
return request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
|
|
39
|
+
expand: 'renderedFields',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function getIssueComments(issueKey) {
|
|
44
|
+
const data = await request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
|
|
45
|
+
orderBy: 'created',
|
|
46
|
+
maxResults: 100,
|
|
47
|
+
});
|
|
48
|
+
return data.comments || [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function searchIssues(jql, maxResults = 50, nextPageToken = '') {
|
|
52
|
+
const fields = 'summary,status,assignee,priority,updated,issuetype,reporter,customfield_10010';
|
|
53
|
+
const data = await request('/rest/api/3/search/jql', {
|
|
54
|
+
jql,
|
|
55
|
+
fields,
|
|
56
|
+
maxResults,
|
|
57
|
+
nextPageToken,
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
issues: data.issues || [],
|
|
61
|
+
nextPageToken: data.nextPageToken || null,
|
|
62
|
+
isLast: data.isLast !== false,
|
|
63
|
+
maxResults,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function getMyIssues() {
|
|
68
|
+
const project = get('serviceDesk.defaultProject');
|
|
69
|
+
const jql = project
|
|
70
|
+
? `project = ${project} AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`
|
|
71
|
+
: `assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`;
|
|
72
|
+
return searchIssues(jql);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let _serviceDeskIdCache = null;
|
|
76
|
+
|
|
77
|
+
async function getServiceDeskId(projectKey) {
|
|
78
|
+
if (_serviceDeskIdCache && _serviceDeskIdCache.projectKey === projectKey) {
|
|
79
|
+
return _serviceDeskIdCache.id;
|
|
80
|
+
}
|
|
81
|
+
const data = await request('/rest/servicedeskapi/servicedesk', { start: 0, limit: 100 }, { 'X-ExperimentalApi': 'opt-in' });
|
|
82
|
+
const match = (data.values || []).find((sd) => sd.projectKey === projectKey);
|
|
83
|
+
if (!match) {
|
|
84
|
+
throw new Error(`No Service Desk found for project ${projectKey}`);
|
|
85
|
+
}
|
|
86
|
+
_serviceDeskIdCache = { projectKey, id: match.id };
|
|
87
|
+
return match.id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function listQueues() {
|
|
91
|
+
const projectKey = get('serviceDesk.defaultProject');
|
|
92
|
+
if (!projectKey) throw new Error('serviceDesk.defaultProject is not configured');
|
|
93
|
+
const sdId = await getServiceDeskId(projectKey);
|
|
94
|
+
const data = await request(`/rest/servicedeskapi/servicedesk/${sdId}/queue`, { start: 0, limit: 100 }, { 'X-ExperimentalApi': 'opt-in' });
|
|
95
|
+
return (data.values || []).map((q) => ({ id: q.id, name: q.name, jql: q.jql }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function findQueue(nameOrId) {
|
|
99
|
+
const queues = await listQueues();
|
|
100
|
+
const idMatch = queues.find((q) => String(q.id) === String(nameOrId));
|
|
101
|
+
if (idMatch) return idMatch;
|
|
102
|
+
const lower = nameOrId.toLowerCase();
|
|
103
|
+
const exact = queues.find((q) => q.name.toLowerCase() === lower);
|
|
104
|
+
if (exact) return exact;
|
|
105
|
+
const partial = queues.filter((q) => q.name.toLowerCase().includes(lower));
|
|
106
|
+
if (partial.length === 1) return partial[0];
|
|
107
|
+
if (partial.length > 1) {
|
|
108
|
+
const err = new Error(`Ambiguous queue "${nameOrId}" — matches: ${partial.map((q) => q.name).join(', ')}`);
|
|
109
|
+
err.candidates = partial;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`No queue found matching "${nameOrId}"`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function searchByQueue(nameOrId, maxResults = 50, nextPageToken = '') {
|
|
116
|
+
const queue = await findQueue(nameOrId);
|
|
117
|
+
const result = await searchIssues(queue.jql, maxResults, nextPageToken);
|
|
118
|
+
return { ...result, queue };
|
|
119
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import {
|
|
3
|
+
getIssue,
|
|
4
|
+
getIssueComments,
|
|
5
|
+
searchIssues,
|
|
6
|
+
getMyIssues,
|
|
7
|
+
listQueues,
|
|
8
|
+
searchByQueue,
|
|
9
|
+
} from '../clients/serviceDesk.js';
|
|
10
|
+
import {
|
|
11
|
+
formatIssueView, formatIssueComments, formatIssueSearch,
|
|
12
|
+
plainIssueView, plainIssueComments, plainIssueSearch,
|
|
13
|
+
curateIssueJson, curateCommentsJson, curateSearchJson,
|
|
14
|
+
formatQueueList, plainQueueList, curateQueuesJson,
|
|
15
|
+
} from '../formatters/serviceDesk.js';
|
|
16
|
+
import { spinner } from '../utils/output.js';
|
|
17
|
+
import { handleError } from '../utils/errors.js';
|
|
18
|
+
import { getOutputMode, outputJson } from '../utils/mode.js';
|
|
19
|
+
|
|
20
|
+
const sd = new Command('sd')
|
|
21
|
+
.alias('servicedesk')
|
|
22
|
+
.description('Jira Service Desk Cloud tickets');
|
|
23
|
+
|
|
24
|
+
sd.command('view <key>')
|
|
25
|
+
.alias('read')
|
|
26
|
+
.description('View ticket details')
|
|
27
|
+
.action(async function (key) {
|
|
28
|
+
const mode = getOutputMode(this);
|
|
29
|
+
const s = mode === 'rich' ? spinner(`Fetching ${key}...`).start() : null;
|
|
30
|
+
try {
|
|
31
|
+
const issue = await getIssue(key);
|
|
32
|
+
if (s) s.success({ text: key });
|
|
33
|
+
if (mode === 'json') outputJson(curateIssueJson(issue));
|
|
34
|
+
else if (mode === 'plain') plainIssueView(issue);
|
|
35
|
+
else formatIssueView(issue);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (s) s.error({ text: `Failed to fetch ${key}` });
|
|
38
|
+
handleError(err, `sd view ${key}`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
sd.command('comments <key>')
|
|
43
|
+
.description('List ticket comments')
|
|
44
|
+
.action(async function (key) {
|
|
45
|
+
const mode = getOutputMode(this);
|
|
46
|
+
const s = mode === 'rich' ? spinner(`Fetching comments for ${key}...`).start() : null;
|
|
47
|
+
try {
|
|
48
|
+
const comments = await getIssueComments(key);
|
|
49
|
+
if (s) s.success({ text: `${comments.length} comment(s)` });
|
|
50
|
+
if (mode === 'json') outputJson(curateCommentsJson(comments));
|
|
51
|
+
else if (mode === 'plain') plainIssueComments(comments);
|
|
52
|
+
else formatIssueComments(comments);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (s) s.error({ text: `Failed to fetch comments for ${key}` });
|
|
55
|
+
handleError(err, `sd comments ${key}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
sd.command('search <jql>')
|
|
60
|
+
.alias('find')
|
|
61
|
+
.description('Search Service Desk tickets with JQL')
|
|
62
|
+
.option('-n, --per-page <number>', 'Results per page', '20')
|
|
63
|
+
.option('--next <token>', 'Continuation token from a previous page')
|
|
64
|
+
.action(async function (jql, opts) {
|
|
65
|
+
const mode = getOutputMode(this);
|
|
66
|
+
const perPage = parseInt(opts.perPage, 10);
|
|
67
|
+
const s = mode === 'rich' ? spinner('Searching...').start() : null;
|
|
68
|
+
try {
|
|
69
|
+
const result = await searchIssues(jql, perPage, opts.next || '');
|
|
70
|
+
if (s) s.success({ text: `${result.issues.length} issue(s) returned` });
|
|
71
|
+
if (mode === 'json') outputJson(curateSearchJson(result));
|
|
72
|
+
else if (mode === 'plain') plainIssueSearch(result);
|
|
73
|
+
else formatIssueSearch(result);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (s) s.error({ text: 'Search failed' });
|
|
76
|
+
handleError(err, `sd search "${jql}"`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
sd.command('queues')
|
|
81
|
+
.description('List Service Desk queues for the default project')
|
|
82
|
+
.action(async function () {
|
|
83
|
+
const mode = getOutputMode(this);
|
|
84
|
+
const s = mode === 'rich' ? spinner('Fetching queues...').start() : null;
|
|
85
|
+
try {
|
|
86
|
+
const queues = await listQueues();
|
|
87
|
+
if (s) s.success({ text: `${queues.length} queue(s)` });
|
|
88
|
+
if (mode === 'json') outputJson(curateQueuesJson(queues));
|
|
89
|
+
else if (mode === 'plain') plainQueueList(queues);
|
|
90
|
+
else formatQueueList(queues);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (s) s.error({ text: 'Failed to fetch queues' });
|
|
93
|
+
handleError(err, 'sd queues');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
sd.command('queue <nameOrId>')
|
|
98
|
+
.description('Show issues in a Service Desk queue (by name or id)')
|
|
99
|
+
.option('-n, --per-page <number>', 'Results per page', '20')
|
|
100
|
+
.option('--next <token>', 'Continuation token from a previous page')
|
|
101
|
+
.action(async function (nameOrId, opts) {
|
|
102
|
+
const mode = getOutputMode(this);
|
|
103
|
+
const perPage = parseInt(opts.perPage, 10);
|
|
104
|
+
const s = mode === 'rich' ? spinner(`Fetching queue "${nameOrId}"...`).start() : null;
|
|
105
|
+
try {
|
|
106
|
+
const result = await searchByQueue(nameOrId, perPage, opts.next || '');
|
|
107
|
+
if (s) s.success({ text: `${result.issues.length} issue(s) in "${result.queue.name}"` });
|
|
108
|
+
if (mode === 'json') outputJson(curateSearchJson(result));
|
|
109
|
+
else if (mode === 'plain') plainIssueSearch(result);
|
|
110
|
+
else formatIssueSearch(result);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (s) s.error({ text: `Failed to fetch queue "${nameOrId}"` });
|
|
113
|
+
handleError(err, `sd queue ${nameOrId}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
sd.command('mine')
|
|
118
|
+
.alias('my')
|
|
119
|
+
.description('Show my open Service Desk tickets')
|
|
120
|
+
.action(async function () {
|
|
121
|
+
const mode = getOutputMode(this);
|
|
122
|
+
const s = mode === 'rich' ? spinner('Fetching my tickets...').start() : null;
|
|
123
|
+
try {
|
|
124
|
+
const result = await getMyIssues();
|
|
125
|
+
if (s) s.success({ text: `${result.issues.length} ticket(s)` });
|
|
126
|
+
if (mode === 'json') outputJson(curateSearchJson(result));
|
|
127
|
+
else if (mode === 'plain') plainIssueSearch(result);
|
|
128
|
+
else formatIssueSearch(result);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (s) s.error({ text: 'Failed to fetch tickets' });
|
|
131
|
+
handleError(err, 'sd my tickets');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export default sd;
|
package/src/config/store.js
CHANGED
|
@@ -29,6 +29,15 @@ const schema = {
|
|
|
29
29
|
defaultSpace: { type: 'string' },
|
|
30
30
|
},
|
|
31
31
|
},
|
|
32
|
+
serviceDesk: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
baseUrl: { type: 'string' },
|
|
36
|
+
email: { type: 'string' },
|
|
37
|
+
token: { type: 'string' },
|
|
38
|
+
defaultProject: { type: 'string' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
32
41
|
};
|
|
33
42
|
|
|
34
43
|
const config = new Conf({
|
|
@@ -38,6 +47,7 @@ const config = new Conf({
|
|
|
38
47
|
bitbucket: { workspace: '', defaultRepo: '', email: '', token: '' },
|
|
39
48
|
jira: { baseUrl: '', token: '', defaultProject: '' },
|
|
40
49
|
confluence: { baseUrl: '', email: '', token: '', defaultSpace: '' },
|
|
50
|
+
serviceDesk: { baseUrl: '', email: '', token: '', defaultProject: '' },
|
|
41
51
|
},
|
|
42
52
|
});
|
|
43
53
|
|
|
@@ -146,9 +156,52 @@ export async function runSetup() {
|
|
|
146
156
|
},
|
|
147
157
|
});
|
|
148
158
|
|
|
159
|
+
const reuseConfluenceCreds = confluence.email && confluence.token
|
|
160
|
+
? await p.confirm({
|
|
161
|
+
message: 'Reuse Confluence email/token for Jira Service Desk Cloud?',
|
|
162
|
+
initialValue: true,
|
|
163
|
+
})
|
|
164
|
+
: false;
|
|
165
|
+
|
|
166
|
+
if (p.isCancel(reuseConfluenceCreds)) {
|
|
167
|
+
p.cancel('Setup cancelled.');
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const serviceDesk = await p.group({
|
|
172
|
+
baseUrl: () => p.text({
|
|
173
|
+
message: 'Jira Service Desk Cloud base URL (use canonical *.atlassian.net, not a vanity domain)',
|
|
174
|
+
placeholder: 'https://company.atlassian.net',
|
|
175
|
+
initialValue: config.get('serviceDesk.baseUrl') || '',
|
|
176
|
+
}),
|
|
177
|
+
email: () => reuseConfluenceCreds
|
|
178
|
+
? Promise.resolve(confluence.email)
|
|
179
|
+
: p.text({
|
|
180
|
+
message: 'Service Desk account email',
|
|
181
|
+
placeholder: 'you@company.com',
|
|
182
|
+
initialValue: config.get('serviceDesk.email') || '',
|
|
183
|
+
}),
|
|
184
|
+
token: () => reuseConfluenceCreds
|
|
185
|
+
? Promise.resolve(confluence.token)
|
|
186
|
+
: p.password({
|
|
187
|
+
message: 'Service Desk API token',
|
|
188
|
+
}),
|
|
189
|
+
defaultProject: () => p.text({
|
|
190
|
+
message: 'Service Desk default project key',
|
|
191
|
+
placeholder: 'TUKI',
|
|
192
|
+
initialValue: config.get('serviceDesk.defaultProject') || '',
|
|
193
|
+
}),
|
|
194
|
+
}, {
|
|
195
|
+
onCancel: () => {
|
|
196
|
+
p.cancel('Setup cancelled.');
|
|
197
|
+
process.exit(0);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
149
201
|
config.set('bitbucket', bb);
|
|
150
202
|
config.set('jira', jira);
|
|
151
203
|
config.set('confluence', confluence);
|
|
204
|
+
config.set('serviceDesk', serviceDesk);
|
|
152
205
|
|
|
153
206
|
p.outro(pc.green('Configuration saved!'));
|
|
154
207
|
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { table, heading, field, divider, pc } from '../utils/output.js';
|
|
2
|
+
|
|
3
|
+
function statusColor(name) {
|
|
4
|
+
if (!name) return pc.dim('Unknown');
|
|
5
|
+
const lower = name.toLowerCase();
|
|
6
|
+
if (lower === 'done' || lower === 'closed' || lower === 'resolved') return pc.green(name);
|
|
7
|
+
if (lower === 'in progress' || lower === 'in review' || lower === 'waiting for support') return pc.blue(name);
|
|
8
|
+
if (lower === 'to do' || lower === 'open' || lower === 'backlog' || lower === 'waiting for customer') return pc.yellow(name);
|
|
9
|
+
return name;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function priorityColor(name) {
|
|
13
|
+
if (!name) return pc.dim('None');
|
|
14
|
+
const lower = name.toLowerCase();
|
|
15
|
+
if (lower === 'highest' || lower === 'blocker') return pc.red(pc.bold(name));
|
|
16
|
+
if (lower === 'high' || lower === 'critical') return pc.red(name);
|
|
17
|
+
if (lower === 'low' || lower === 'minor' || lower === 'lowest' || lower === 'trivial') return pc.dim(name);
|
|
18
|
+
return name;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function relativeTime(dateStr) {
|
|
22
|
+
if (!dateStr) return '';
|
|
23
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
24
|
+
const mins = Math.floor(diff / 60000);
|
|
25
|
+
if (mins < 60) return `${mins}m ago`;
|
|
26
|
+
const hours = Math.floor(mins / 60);
|
|
27
|
+
if (hours < 24) return `${hours}h ago`;
|
|
28
|
+
const days = Math.floor(hours / 24);
|
|
29
|
+
if (days < 30) return `${days}d ago`;
|
|
30
|
+
return new Date(dateStr).toLocaleDateString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function adfToText(node) {
|
|
34
|
+
if (!node) return '';
|
|
35
|
+
if (typeof node === 'string') return node;
|
|
36
|
+
if (Array.isArray(node)) return node.map(adfToText).join('');
|
|
37
|
+
if (node.type === 'text') return node.text || '';
|
|
38
|
+
if (node.type === 'hardBreak') return '\n';
|
|
39
|
+
const inner = adfToText(node.content);
|
|
40
|
+
const blockTypes = new Set(['paragraph', 'heading', 'bulletList', 'orderedList', 'listItem', 'codeBlock', 'blockquote']);
|
|
41
|
+
if (blockTypes.has(node.type)) return inner + '\n';
|
|
42
|
+
return inner;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function descriptionText(field) {
|
|
46
|
+
if (!field) return '';
|
|
47
|
+
if (typeof field === 'string') return field;
|
|
48
|
+
return adfToText(field).trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requestType(fields) {
|
|
52
|
+
for (const key of Object.keys(fields)) {
|
|
53
|
+
if (!key.startsWith('customfield_')) continue;
|
|
54
|
+
const val = fields[key];
|
|
55
|
+
if (val && typeof val === 'object' && val.requestType?.name) {
|
|
56
|
+
return val.requestType.name;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function migrationServerKey(fields) {
|
|
63
|
+
return fields.customfield_10165 || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- JSON curators ---
|
|
67
|
+
|
|
68
|
+
export function curateIssueJson(issue) {
|
|
69
|
+
const f = issue.fields;
|
|
70
|
+
return {
|
|
71
|
+
key: issue.key,
|
|
72
|
+
summary: f.summary,
|
|
73
|
+
status: f.status?.name || null,
|
|
74
|
+
priority: f.priority?.name || null,
|
|
75
|
+
type: f.issuetype?.name || null,
|
|
76
|
+
requestType: requestType(f),
|
|
77
|
+
migrationServerKey: migrationServerKey(f),
|
|
78
|
+
assignee: f.assignee?.displayName || null,
|
|
79
|
+
reporter: f.reporter?.displayName || null,
|
|
80
|
+
labels: f.labels || [],
|
|
81
|
+
components: (f.components || []).map((c) => c.name),
|
|
82
|
+
created: f.created,
|
|
83
|
+
updated: f.updated,
|
|
84
|
+
description: descriptionText(f.description),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function curateCommentsJson(comments) {
|
|
89
|
+
return comments.map((c) => ({
|
|
90
|
+
author: c.author?.displayName || null,
|
|
91
|
+
date: c.created,
|
|
92
|
+
body: descriptionText(c.body),
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function curateSearchJson(result) {
|
|
97
|
+
return {
|
|
98
|
+
nextPageToken: result.nextPageToken,
|
|
99
|
+
isLast: result.isLast,
|
|
100
|
+
queue: result.queue ? { id: result.queue.id, name: result.queue.name, jql: result.queue.jql } : undefined,
|
|
101
|
+
issues: result.issues.map((issue) => ({
|
|
102
|
+
key: issue.key,
|
|
103
|
+
summary: issue.fields.summary,
|
|
104
|
+
status: issue.fields.status?.name || null,
|
|
105
|
+
assignee: issue.fields.assignee?.displayName || null,
|
|
106
|
+
priority: issue.fields.priority?.name || null,
|
|
107
|
+
updated: issue.fields.updated,
|
|
108
|
+
})),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function curateQueuesJson(queues) {
|
|
113
|
+
return queues.map((q) => ({ id: q.id, name: q.name, jql: q.jql }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function plainQueueList(queues) {
|
|
117
|
+
if (queues.length === 0) { console.log('No queues.'); return; }
|
|
118
|
+
for (const q of queues) {
|
|
119
|
+
console.log(`${q.id}\t${q.name}\t${q.jql}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function formatQueueList(queues) {
|
|
124
|
+
if (queues.length === 0) {
|
|
125
|
+
console.log(pc.dim(' No queues.'));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const rows = queues.map((q) => [
|
|
129
|
+
pc.dim(String(q.id)),
|
|
130
|
+
pc.bold(q.name),
|
|
131
|
+
(q.jql || '').length > 80 ? q.jql.slice(0, 77) + '...' : q.jql || '',
|
|
132
|
+
]);
|
|
133
|
+
console.log(table(['ID', 'Name', 'JQL'], rows));
|
|
134
|
+
console.log('');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Plain formatters ---
|
|
138
|
+
|
|
139
|
+
export function plainIssueView(issue) {
|
|
140
|
+
const f = issue.fields;
|
|
141
|
+
console.log(`${issue.key} ${f.summary}`);
|
|
142
|
+
console.log(`Status: ${f.status?.name || 'Unknown'}`);
|
|
143
|
+
console.log(`Priority: ${f.priority?.name || 'None'}`);
|
|
144
|
+
console.log(`Type: ${f.issuetype?.name || 'Unknown'}`);
|
|
145
|
+
const rt = requestType(f);
|
|
146
|
+
if (rt) console.log(`Request Type: ${rt}`);
|
|
147
|
+
const mk = migrationServerKey(f);
|
|
148
|
+
if (mk) console.log(`Migration Server Key: ${mk}`);
|
|
149
|
+
console.log(`Assignee: ${f.assignee?.displayName || 'Unassigned'}`);
|
|
150
|
+
console.log(`Reporter: ${f.reporter?.displayName || 'Unknown'}`);
|
|
151
|
+
if (f.labels?.length > 0) console.log(`Labels: ${f.labels.join(', ')}`);
|
|
152
|
+
if (f.components?.length > 0) console.log(`Components: ${f.components.map((c) => c.name).join(', ')}`);
|
|
153
|
+
console.log(`Created: ${f.created}`);
|
|
154
|
+
console.log(`Updated: ${f.updated}`);
|
|
155
|
+
const desc = descriptionText(f.description);
|
|
156
|
+
if (desc) console.log(`Description:\n${desc}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function plainIssueComments(comments) {
|
|
160
|
+
if (comments.length === 0) { console.log('No comments.'); return; }
|
|
161
|
+
for (const c of comments) {
|
|
162
|
+
console.log(`${c.author?.displayName || 'Unknown'} (${relativeTime(c.created)}): ${descriptionText(c.body)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function plainIssueSearch(result) {
|
|
167
|
+
if (result.queue) {
|
|
168
|
+
console.log(`Queue: ${result.queue.name} (${result.queue.id})`);
|
|
169
|
+
console.log(`JQL: ${result.queue.jql}`);
|
|
170
|
+
}
|
|
171
|
+
if (result.issues.length === 0) { console.log('No issues found.'); return; }
|
|
172
|
+
for (const issue of result.issues) {
|
|
173
|
+
const f = issue.fields;
|
|
174
|
+
console.log(`${issue.key} [${f.status?.name}] "${f.summary}" - ${f.assignee?.displayName || 'Unassigned'} (${f.priority?.name}, ${relativeTime(f.updated)})`);
|
|
175
|
+
}
|
|
176
|
+
if (!result.isLast && result.nextPageToken) {
|
|
177
|
+
console.log(`More results available (nextPageToken: ${result.nextPageToken})`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Rich formatters ---
|
|
182
|
+
|
|
183
|
+
export function formatIssueView(issue) {
|
|
184
|
+
const f = issue.fields;
|
|
185
|
+
|
|
186
|
+
heading(`${issue.key} ${f.summary}`);
|
|
187
|
+
divider();
|
|
188
|
+
field('Status', statusColor(f.status?.name));
|
|
189
|
+
field('Priority', priorityColor(f.priority?.name));
|
|
190
|
+
field('Type', f.issuetype?.name);
|
|
191
|
+
const rt = requestType(f);
|
|
192
|
+
if (rt) field('Request Type', rt);
|
|
193
|
+
const mk = migrationServerKey(f);
|
|
194
|
+
if (mk) field('Migration Server Key', mk);
|
|
195
|
+
field('Assignee', f.assignee?.displayName || pc.dim('Unassigned'));
|
|
196
|
+
field('Reporter', f.reporter?.displayName);
|
|
197
|
+
field('Labels', f.labels?.length > 0 ? f.labels.join(', ') : null);
|
|
198
|
+
field('Components', f.components?.map((c) => c.name).join(', ') || null);
|
|
199
|
+
field('Created', new Date(f.created).toLocaleString());
|
|
200
|
+
field('Updated', new Date(f.updated).toLocaleString());
|
|
201
|
+
|
|
202
|
+
const desc = descriptionText(f.description);
|
|
203
|
+
if (desc) {
|
|
204
|
+
heading('Description');
|
|
205
|
+
const lines = desc.split('\n');
|
|
206
|
+
const truncated = lines.slice(0, 30);
|
|
207
|
+
for (const line of truncated) {
|
|
208
|
+
console.log(` ${line}`);
|
|
209
|
+
}
|
|
210
|
+
if (lines.length > 30) {
|
|
211
|
+
console.log(pc.dim(` ... (${lines.length - 30} more lines)`));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.log('');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function formatIssueComments(comments) {
|
|
219
|
+
if (comments.length === 0) {
|
|
220
|
+
console.log(pc.dim(' No comments.'));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const c of comments) {
|
|
225
|
+
console.log(`\n ${pc.bold(c.author?.displayName || 'Unknown')} ${pc.dim(relativeTime(c.created))}`);
|
|
226
|
+
const body = descriptionText(c.body);
|
|
227
|
+
if (body) {
|
|
228
|
+
for (const line of body.split('\n')) {
|
|
229
|
+
console.log(` ${line}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
console.log('');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function formatIssueSearch(result) {
|
|
237
|
+
const { issues } = result;
|
|
238
|
+
|
|
239
|
+
if (result.queue) {
|
|
240
|
+
heading(`Queue: ${result.queue.name}`);
|
|
241
|
+
console.log(pc.dim(` ${result.queue.jql}`));
|
|
242
|
+
divider();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (issues.length === 0) {
|
|
246
|
+
console.log(pc.dim(' No issues found.'));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const rows = issues.map((issue) => [
|
|
251
|
+
pc.bold(issue.key),
|
|
252
|
+
(issue.fields.summary || '').length > 55
|
|
253
|
+
? issue.fields.summary.slice(0, 52) + '...'
|
|
254
|
+
: issue.fields.summary || '',
|
|
255
|
+
statusColor(issue.fields.status?.name),
|
|
256
|
+
issue.fields.assignee?.displayName || pc.dim('Unassigned'),
|
|
257
|
+
priorityColor(issue.fields.priority?.name),
|
|
258
|
+
relativeTime(issue.fields.updated),
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
console.log(table(['Key', 'Summary', 'Status', 'Assignee', 'Priority', 'Updated'], rows));
|
|
262
|
+
|
|
263
|
+
if (!result.isLast && result.nextPageToken) {
|
|
264
|
+
console.log(pc.dim(` More results available — pass --next ${result.nextPageToken} to continue`));
|
|
265
|
+
}
|
|
266
|
+
console.log('');
|
|
267
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,13 +6,14 @@ import configCmd from './commands/config.js';
|
|
|
6
6
|
import prCmd from './commands/pr.js';
|
|
7
7
|
import jiraCmd from './commands/jira.js';
|
|
8
8
|
import confluenceCmd from './commands/confluence.js';
|
|
9
|
+
import sdCmd from './commands/sd.js';
|
|
9
10
|
|
|
10
11
|
const program = new Command();
|
|
11
12
|
|
|
12
13
|
program
|
|
13
14
|
.name('dev')
|
|
14
|
-
.description('CLI for Bitbucket PRs, Jira tickets, and
|
|
15
|
-
.version('0.
|
|
15
|
+
.description('CLI for Bitbucket PRs, Jira tickets, Confluence pages, and Service Desk tickets')
|
|
16
|
+
.version('0.4.0')
|
|
16
17
|
.option('--json', 'Output raw JSON')
|
|
17
18
|
.option('--plain', 'Output compact plain text (no tables, no colors)');
|
|
18
19
|
|
|
@@ -20,6 +21,7 @@ program.addCommand(configCmd);
|
|
|
20
21
|
program.addCommand(prCmd);
|
|
21
22
|
program.addCommand(jiraCmd);
|
|
22
23
|
program.addCommand(confluenceCmd);
|
|
24
|
+
program.addCommand(sdCmd);
|
|
23
25
|
|
|
24
26
|
program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
25
27
|
const cmdChain = [];
|