@falldownthesystem/devtool 0.3.0 → 0.4.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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025 FallDownTheSystem
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -5,8 +5,7 @@ A terminal-first CLI for working with Bitbucket PRs, Jira tickets, and Confluenc
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install
9
- npm link
8
+ npm install -g @falldownthesystem/devtool
10
9
  ```
11
10
 
12
11
  This gives you the `dev` command globally. On first run, it'll walk you through connecting your Bitbucket, Jira, and Confluence accounts.
package/package.json CHANGED
@@ -1,17 +1,35 @@
1
1
  {
2
2
  "name": "@falldownthesystem/devtool",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
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"
8
8
  },
9
+ "files": [
10
+ "src/"
11
+ ],
9
12
  "scripts": {
10
- "start": "node src/index.js"
13
+ "start": "node src/index.js",
14
+ "test": "node --test test/config.test.js test/confluence.test.js test/service-desk.test.js"
11
15
  },
12
- "keywords": [],
13
- "author": "",
16
+ "keywords": [
17
+ "cli",
18
+ "bitbucket",
19
+ "jira",
20
+ "confluence",
21
+ "pull-request",
22
+ "devtools"
23
+ ],
24
+ "author": "FallDownTheSystem",
14
25
  "license": "ISC",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/FallDownTheSystem/devtool.git"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
15
33
  "dependencies": {
16
34
  "@clack/prompts": "^1.0.0",
17
35
  "cli-table3": "^0.6.5",
@@ -1,7 +1,7 @@
1
1
  import { get } from '../config/store.js';
2
2
 
3
- function baseUrl() {
4
- return `${get('confluence.baseUrl')}/wiki`;
3
+ function apiUrl(path) {
4
+ return new URL(`/wiki${path}`, get('confluence.baseUrl'));
5
5
  }
6
6
 
7
7
  function authHeader() {
@@ -10,8 +10,10 @@ function authHeader() {
10
10
  return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
11
11
  }
12
12
 
13
- async function request(path, params = {}) {
14
- const url = new URL(path.startsWith('http') ? path : `${baseUrl()}${path}`);
13
+ async function request(url, params = {}) {
14
+ if (url.origin !== new URL(get('confluence.baseUrl')).origin) {
15
+ throw new Error('Confluence pagination points to a different site. Check confluence.baseUrl.');
16
+ }
15
17
  for (const [k, v] of Object.entries(params)) {
16
18
  if (v !== undefined && v !== null) url.searchParams.set(k, v);
17
19
  }
@@ -35,11 +37,13 @@ async function request(path, params = {}) {
35
37
 
36
38
  async function paginateV2(path, params = {}) {
37
39
  const results = [];
38
- let data = await request(path, params);
40
+ let url = apiUrl(path);
41
+ let data = await request(url, params);
39
42
  results.push(...(data.results || []));
40
43
 
41
44
  while (data._links?.next) {
42
- data = await request(data._links.next);
45
+ url = new URL(data._links.next, url);
46
+ data = await request(url);
43
47
  results.push(...(data.results || []));
44
48
  }
45
49
 
@@ -51,7 +55,7 @@ export async function listSpaces() {
51
55
  }
52
56
 
53
57
  export async function getPage(id) {
54
- return request(`/api/v2/pages/${id}`, { 'body-format': 'storage' });
58
+ return request(apiUrl(`/api/v2/pages/${id}`), { 'body-format': 'storage' });
55
59
  }
56
60
 
57
61
  export async function listSpacePages(spaceId) {
@@ -59,5 +63,5 @@ export async function listSpacePages(spaceId) {
59
63
  }
60
64
 
61
65
  export async function searchPages(cql) {
62
- return request('/rest/api/search', { cql });
66
+ return request(apiUrl('/rest/api/search'), { cql });
63
67
  }
@@ -1,72 +1,181 @@
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 = {}) {
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
- },
24
- });
25
-
26
- if (!res.ok) {
27
- const err = new Error(`Service Desk API error: ${res.status} ${res.statusText}`);
28
- err.status = res.status;
29
- err.url = url.toString();
30
- throw err;
31
- }
32
-
33
- return res.json();
34
- }
35
-
36
- export async function getIssue(issueKey) {
37
- return request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
38
- expand: 'renderedFields',
39
- });
40
- }
41
-
42
- export async function getIssueComments(issueKey) {
43
- const data = await request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
44
- orderBy: 'created',
45
- maxResults: 100,
46
- });
47
- return data.comments || [];
48
- }
49
-
50
- export async function searchIssues(jql, maxResults = 50, nextPageToken = '') {
51
- const fields = 'summary,status,assignee,priority,updated,issuetype,reporter,customfield_10010';
52
- const data = await request('/rest/api/3/search/jql', {
53
- jql,
54
- fields,
55
- maxResults,
56
- nextPageToken,
57
- });
58
- return {
59
- issues: data.issues || [],
60
- nextPageToken: data.nextPageToken || null,
61
- isLast: data.isLast !== false,
62
- maxResults,
63
- };
64
- }
65
-
66
- export async function getMyIssues() {
67
- const project = get('serviceDesk.defaultProject');
68
- const jql = project
69
- ? `project = ${project} AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`
70
- : `assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`;
71
- return searchIssues(jql);
72
- }
1
+ import { get } from '../config/store.js';
2
+
3
+ function configurationError(reason) {
4
+ return new Error(`Service Desk configuration error: ${reason}. Check serviceDesk.baseUrl; use the canonical https://<site>.atlassian.net URL.`);
5
+ }
6
+
7
+ function baseUrl() {
8
+ let url;
9
+ try {
10
+ url = new URL(get('serviceDesk.baseUrl'));
11
+ } catch {
12
+ throw configurationError('invalid base URL');
13
+ }
14
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || /[^/]/.test(url.pathname)) {
15
+ throw configurationError('the base URL must be an HTTPS site URL without a path, query, or credentials');
16
+ }
17
+ return new URL(url.origin);
18
+ }
19
+
20
+ function authHeader() {
21
+ const email = get('serviceDesk.email');
22
+ const token = get('serviceDesk.token');
23
+ return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
24
+ }
25
+
26
+ async function fetchJson(url, headers = {}) {
27
+ const res = await fetch(url, {
28
+ redirect: 'manual',
29
+ headers: { 'Accept': 'application/json', ...headers },
30
+ });
31
+
32
+ if (res.redirected || (res.status >= 300 && res.status < 400)) {
33
+ throw configurationError('the API returned a redirect; authentication may not reach the intended site');
34
+ }
35
+ if (res.url && new URL(res.url).origin !== url.origin) {
36
+ throw configurationError('the API response came from an unexpected host');
37
+ }
38
+ if (!res.ok) {
39
+ const err = new Error(`Service Desk API error: ${res.status} ${res.statusText}`);
40
+ err.status = res.status;
41
+ err.url = url.toString();
42
+ throw err;
43
+ }
44
+
45
+ const contentType = res.headers.get('content-type')?.split(';')[0].trim().toLowerCase();
46
+ if (!/^application\/(?:[\w.+-]+\+)?json$/.test(contentType || '')) {
47
+ throw configurationError('expected a JSON API response, but received a different content type');
48
+ }
49
+ let data;
50
+ try {
51
+ data = await res.json();
52
+ } catch {
53
+ throw configurationError('the API response contains invalid JSON');
54
+ }
55
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
56
+ throw configurationError('unexpected API response structure');
57
+ }
58
+ return data;
59
+ }
60
+
61
+ async function request(path, params = {}, { extraHeaders = {}, validate = () => true } = {}) {
62
+ const site = baseUrl();
63
+ if (!site.hostname.endsWith('.atlassian.net')) {
64
+ const info = await fetchJson(new URL('/rest/api/2/serverInfo', site));
65
+ let canonical;
66
+ try {
67
+ canonical = new URL(info.baseUrl);
68
+ } catch {
69
+ throw configurationError('serverInfo did not return a valid canonical base URL');
70
+ }
71
+ if (canonical.origin !== site.origin || canonical.pathname.replace(/\/+$/, '') !== '') {
72
+ throw configurationError(`serverInfo identifies a different canonical base URL: ${canonical.origin}${canonical.pathname.replace(/\/+$/, '')}`);
73
+ }
74
+ }
75
+
76
+ const url = new URL(path, site);
77
+ for (const [k, v] of Object.entries(params)) {
78
+ if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
79
+ }
80
+ const data = await fetchJson(url, { 'Authorization': authHeader(), ...extraHeaders });
81
+ if (!validate(data)) throw configurationError('unexpected API response structure');
82
+ return data;
83
+ }
84
+
85
+ export async function getIssue(issueKey) {
86
+ return request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
87
+ expand: 'renderedFields',
88
+ }, {
89
+ validate: (data) => typeof data.key === 'string' && data.fields && typeof data.fields === 'object' && !Array.isArray(data.fields),
90
+ });
91
+ }
92
+
93
+ export async function getIssueComments(issueKey) {
94
+ const data = await request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
95
+ orderBy: 'created',
96
+ maxResults: 100,
97
+ }, {
98
+ validate: (data) => Array.isArray(data.comments),
99
+ });
100
+ return data.comments;
101
+ }
102
+
103
+ export async function searchIssues(jql, maxResults = 50, nextPageToken = '') {
104
+ const fields = 'summary,status,assignee,priority,updated,issuetype,reporter,customfield_10010';
105
+ const data = await request('/rest/api/3/search/jql', {
106
+ jql,
107
+ fields,
108
+ maxResults,
109
+ nextPageToken,
110
+ }, {
111
+ validate: (data) => Array.isArray(data.issues)
112
+ && (data.isLast === undefined || typeof data.isLast === 'boolean')
113
+ && (data.nextPageToken == null || typeof data.nextPageToken === 'string'),
114
+ });
115
+ return {
116
+ issues: data.issues,
117
+ nextPageToken: data.nextPageToken || null,
118
+ isLast: data.isLast !== false,
119
+ maxResults,
120
+ };
121
+ }
122
+
123
+ export async function getMyIssues() {
124
+ const project = get('serviceDesk.defaultProject');
125
+ const jql = project
126
+ ? `project = ${project} AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`
127
+ : `assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`;
128
+ return searchIssues(jql);
129
+ }
130
+
131
+ let _serviceDeskIdCache = null;
132
+
133
+ async function getServiceDeskId(projectKey) {
134
+ if (_serviceDeskIdCache && _serviceDeskIdCache.projectKey === projectKey) {
135
+ return _serviceDeskIdCache.id;
136
+ }
137
+ const data = await request('/rest/servicedeskapi/servicedesk', { start: 0, limit: 100 }, {
138
+ extraHeaders: { 'X-ExperimentalApi': 'opt-in' },
139
+ validate: (data) => Array.isArray(data.values),
140
+ });
141
+ const match = data.values.find((sd) => sd.projectKey === projectKey);
142
+ if (!match) {
143
+ throw new Error(`No Service Desk found for project ${projectKey}`);
144
+ }
145
+ _serviceDeskIdCache = { projectKey, id: match.id };
146
+ return match.id;
147
+ }
148
+
149
+ export async function listQueues() {
150
+ const projectKey = get('serviceDesk.defaultProject');
151
+ if (!projectKey) throw new Error('serviceDesk.defaultProject is not configured');
152
+ const sdId = await getServiceDeskId(projectKey);
153
+ const data = await request(`/rest/servicedeskapi/servicedesk/${sdId}/queue`, { start: 0, limit: 100 }, {
154
+ extraHeaders: { 'X-ExperimentalApi': 'opt-in' },
155
+ validate: (data) => Array.isArray(data.values),
156
+ });
157
+ return data.values.map((q) => ({ id: q.id, name: q.name, jql: q.jql }));
158
+ }
159
+
160
+ export async function findQueue(nameOrId) {
161
+ const queues = await listQueues();
162
+ const idMatch = queues.find((q) => String(q.id) === String(nameOrId));
163
+ if (idMatch) return idMatch;
164
+ const lower = nameOrId.toLowerCase();
165
+ const exact = queues.find((q) => q.name.toLowerCase() === lower);
166
+ if (exact) return exact;
167
+ const partial = queues.filter((q) => q.name.toLowerCase().includes(lower));
168
+ if (partial.length === 1) return partial[0];
169
+ if (partial.length > 1) {
170
+ const err = new Error(`Ambiguous queue "${nameOrId}" — matches: ${partial.map((q) => q.name).join(', ')}`);
171
+ err.candidates = partial;
172
+ throw err;
173
+ }
174
+ throw new Error(`No queue found matching "${nameOrId}"`);
175
+ }
176
+
177
+ export async function searchByQueue(nameOrId, maxResults = 50, nextPageToken = '') {
178
+ const queue = await findQueue(nameOrId);
179
+ const result = await searchIssues(queue.jql, maxResults, nextPageToken);
180
+ return { ...result, queue };
181
+ }
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { getAll, set, runSetup, configPath, isConfigured } from '../config/store.js';
2
+ import { getAll, set, runSetup, configPath, schema } from '../config/store.js';
3
3
  import { heading, field, mask, success, error, divider } from '../utils/output.js';
4
4
 
5
5
  const config = new Command('config')
@@ -17,29 +17,15 @@ config
17
17
  .alias('get')
18
18
  .description('Show current configuration')
19
19
  .action(() => {
20
- if (!isConfigured()) {
21
- error('Not configured yet. Run: dev config setup');
22
- return;
23
- }
24
-
25
20
  const all = getAll();
26
21
 
27
- heading('Bitbucket');
28
- field('Workspace', all.bitbucket.workspace);
29
- field('Default repo', all.bitbucket.defaultRepo);
30
- field('Email', all.bitbucket.email);
31
- field('Token', mask(all.bitbucket.token));
32
-
33
- heading('Jira');
34
- field('Base URL', all.jira.baseUrl);
35
- field('Token', mask(all.jira.token));
36
- field('Default project', all.jira.defaultProject);
37
-
38
- heading('Confluence');
39
- field('Base URL', all.confluence.baseUrl);
40
- field('Email', all.confluence.email);
41
- field('Token', mask(all.confluence.token));
42
- field('Default space', all.confluence.defaultSpace);
22
+ for (const [section, definition] of Object.entries(schema)) {
23
+ heading(definition.title);
24
+ for (const [key, property] of Object.entries(definition.properties)) {
25
+ const value = all[section]?.[key] ?? '';
26
+ field(property.title, property.writeOnly ? mask(value) : value);
27
+ }
28
+ }
43
29
 
44
30
  console.log('');
45
31
  divider();
@@ -51,11 +37,8 @@ config
51
37
  .command('set <key> <value>')
52
38
  .description('Set a config value (e.g. bitbucket.workspace my-ws)')
53
39
  .action((key, value) => {
54
- const validKeys = [
55
- 'bitbucket.workspace', 'bitbucket.defaultRepo', 'bitbucket.email', 'bitbucket.token',
56
- 'jira.baseUrl', 'jira.token', 'jira.defaultProject',
57
- 'confluence.baseUrl', 'confluence.email', 'confluence.token', 'confluence.defaultSpace',
58
- ];
40
+ const validKeys = Object.entries(schema).flatMap(([section, definition]) =>
41
+ Object.keys(definition.properties).map((key) => `${section}.${key}`));
59
42
  if (!validKeys.includes(key)) {
60
43
  error(`Invalid key: ${key}`);
61
44
  console.log(`Valid keys: ${validKeys.join(', ')}`);
@@ -1,95 +1,135 @@
1
- import { Command } from 'commander';
2
- import {
3
- getIssue,
4
- getIssueComments,
5
- searchIssues,
6
- getMyIssues,
7
- } from '../clients/serviceDesk.js';
8
- import {
9
- formatIssueView, formatIssueComments, formatIssueSearch,
10
- plainIssueView, plainIssueComments, plainIssueSearch,
11
- curateIssueJson, curateCommentsJson, curateSearchJson,
12
- } from '../formatters/serviceDesk.js';
13
- import { spinner } from '../utils/output.js';
14
- import { handleError } from '../utils/errors.js';
15
- import { getOutputMode, outputJson } from '../utils/mode.js';
16
-
17
- const sd = new Command('sd')
18
- .alias('servicedesk')
19
- .description('Jira Service Desk Cloud tickets');
20
-
21
- sd.command('view <key>')
22
- .alias('read')
23
- .description('View ticket details')
24
- .action(async function (key) {
25
- const mode = getOutputMode(this);
26
- const s = mode === 'rich' ? spinner(`Fetching ${key}...`).start() : null;
27
- try {
28
- const issue = await getIssue(key);
29
- if (s) s.success({ text: key });
30
- if (mode === 'json') outputJson(curateIssueJson(issue));
31
- else if (mode === 'plain') plainIssueView(issue);
32
- else formatIssueView(issue);
33
- } catch (err) {
34
- if (s) s.error({ text: `Failed to fetch ${key}` });
35
- handleError(err, `sd view ${key}`);
36
- }
37
- });
38
-
39
- sd.command('comments <key>')
40
- .description('List ticket comments')
41
- .action(async function (key) {
42
- const mode = getOutputMode(this);
43
- const s = mode === 'rich' ? spinner(`Fetching comments for ${key}...`).start() : null;
44
- try {
45
- const comments = await getIssueComments(key);
46
- if (s) s.success({ text: `${comments.length} comment(s)` });
47
- if (mode === 'json') outputJson(curateCommentsJson(comments));
48
- else if (mode === 'plain') plainIssueComments(comments);
49
- else formatIssueComments(comments);
50
- } catch (err) {
51
- if (s) s.error({ text: `Failed to fetch comments for ${key}` });
52
- handleError(err, `sd comments ${key}`);
53
- }
54
- });
55
-
56
- sd.command('search <jql>')
57
- .alias('find')
58
- .description('Search Service Desk tickets with JQL')
59
- .option('-n, --per-page <number>', 'Results per page', '20')
60
- .option('--next <token>', 'Continuation token from a previous page')
61
- .action(async function (jql, opts) {
62
- const mode = getOutputMode(this);
63
- const perPage = parseInt(opts.perPage, 10);
64
- const s = mode === 'rich' ? spinner('Searching...').start() : null;
65
- try {
66
- const result = await searchIssues(jql, perPage, opts.next || '');
67
- if (s) s.success({ text: `${result.issues.length} issue(s) returned` });
68
- if (mode === 'json') outputJson(curateSearchJson(result));
69
- else if (mode === 'plain') plainIssueSearch(result);
70
- else formatIssueSearch(result);
71
- } catch (err) {
72
- if (s) s.error({ text: 'Search failed' });
73
- handleError(err, `sd search "${jql}"`);
74
- }
75
- });
76
-
77
- sd.command('mine')
78
- .alias('my')
79
- .description('Show my open Service Desk tickets')
80
- .action(async function () {
81
- const mode = getOutputMode(this);
82
- const s = mode === 'rich' ? spinner('Fetching my tickets...').start() : null;
83
- try {
84
- const result = await getMyIssues();
85
- if (s) s.success({ text: `${result.issues.length} ticket(s)` });
86
- if (mode === 'json') outputJson(curateSearchJson(result));
87
- else if (mode === 'plain') plainIssueSearch(result);
88
- else formatIssueSearch(result);
89
- } catch (err) {
90
- if (s) s.error({ text: 'Failed to fetch tickets' });
91
- handleError(err, 'sd my tickets');
92
- }
93
- });
94
-
95
- export default sd;
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;
@@ -2,40 +2,44 @@ import Conf from 'conf';
2
2
  import * as p from '@clack/prompts';
3
3
  import pc from 'picocolors';
4
4
 
5
- const schema = {
5
+ export const schema = {
6
6
  bitbucket: {
7
7
  type: 'object',
8
+ title: 'Bitbucket',
8
9
  properties: {
9
- workspace: { type: 'string' },
10
- defaultRepo: { type: 'string' },
11
- email: { type: 'string' },
12
- token: { type: 'string' },
10
+ workspace: { type: 'string', title: 'Workspace' },
11
+ defaultRepo: { type: 'string', title: 'Default repo' },
12
+ email: { type: 'string', title: 'Email' },
13
+ token: { type: 'string', title: 'Token', writeOnly: true },
13
14
  },
14
15
  },
15
16
  jira: {
16
17
  type: 'object',
18
+ title: 'Jira',
17
19
  properties: {
18
- baseUrl: { type: 'string' },
19
- token: { type: 'string' },
20
- defaultProject: { type: 'string' },
20
+ baseUrl: { type: 'string', title: 'Base URL' },
21
+ token: { type: 'string', title: 'Token', writeOnly: true },
22
+ defaultProject: { type: 'string', title: 'Default project' },
21
23
  },
22
24
  },
23
25
  confluence: {
24
26
  type: 'object',
27
+ title: 'Confluence',
25
28
  properties: {
26
- baseUrl: { type: 'string' },
27
- email: { type: 'string' },
28
- token: { type: 'string' },
29
- defaultSpace: { type: 'string' },
29
+ baseUrl: { type: 'string', title: 'Base URL' },
30
+ email: { type: 'string', title: 'Email' },
31
+ token: { type: 'string', title: 'Token', writeOnly: true },
32
+ defaultSpace: { type: 'string', title: 'Default space' },
30
33
  },
31
34
  },
32
35
  serviceDesk: {
33
36
  type: 'object',
37
+ title: 'Service Desk',
34
38
  properties: {
35
- baseUrl: { type: 'string' },
36
- email: { type: 'string' },
37
- token: { type: 'string' },
38
- defaultProject: { type: 'string' },
39
+ baseUrl: { type: 'string', title: 'Base URL' },
40
+ email: { type: 'string', title: 'Email' },
41
+ token: { type: 'string', title: 'Token', writeOnly: true },
42
+ defaultProject: { type: 'string', title: 'Default project' },
39
43
  },
40
44
  },
41
45
  };
@@ -1,222 +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
- // --- JSON curators ---
63
-
64
- export function curateIssueJson(issue) {
65
- const f = issue.fields;
66
- return {
67
- key: issue.key,
68
- summary: f.summary,
69
- status: f.status?.name || null,
70
- priority: f.priority?.name || null,
71
- type: f.issuetype?.name || null,
72
- requestType: requestType(f),
73
- assignee: f.assignee?.displayName || null,
74
- reporter: f.reporter?.displayName || null,
75
- labels: f.labels || [],
76
- components: (f.components || []).map((c) => c.name),
77
- created: f.created,
78
- updated: f.updated,
79
- description: descriptionText(f.description),
80
- };
81
- }
82
-
83
- export function curateCommentsJson(comments) {
84
- return comments.map((c) => ({
85
- author: c.author?.displayName || null,
86
- date: c.created,
87
- body: descriptionText(c.body),
88
- }));
89
- }
90
-
91
- export function curateSearchJson(result) {
92
- return {
93
- nextPageToken: result.nextPageToken,
94
- isLast: result.isLast,
95
- issues: result.issues.map((issue) => ({
96
- key: issue.key,
97
- summary: issue.fields.summary,
98
- status: issue.fields.status?.name || null,
99
- assignee: issue.fields.assignee?.displayName || null,
100
- priority: issue.fields.priority?.name || null,
101
- updated: issue.fields.updated,
102
- })),
103
- };
104
- }
105
-
106
- // --- Plain formatters ---
107
-
108
- export function plainIssueView(issue) {
109
- const f = issue.fields;
110
- console.log(`${issue.key} ${f.summary}`);
111
- console.log(`Status: ${f.status?.name || 'Unknown'}`);
112
- console.log(`Priority: ${f.priority?.name || 'None'}`);
113
- console.log(`Type: ${f.issuetype?.name || 'Unknown'}`);
114
- const rt = requestType(f);
115
- if (rt) console.log(`Request Type: ${rt}`);
116
- console.log(`Assignee: ${f.assignee?.displayName || 'Unassigned'}`);
117
- console.log(`Reporter: ${f.reporter?.displayName || 'Unknown'}`);
118
- if (f.labels?.length > 0) console.log(`Labels: ${f.labels.join(', ')}`);
119
- if (f.components?.length > 0) console.log(`Components: ${f.components.map((c) => c.name).join(', ')}`);
120
- console.log(`Created: ${f.created}`);
121
- console.log(`Updated: ${f.updated}`);
122
- const desc = descriptionText(f.description);
123
- if (desc) console.log(`Description:\n${desc}`);
124
- }
125
-
126
- export function plainIssueComments(comments) {
127
- if (comments.length === 0) { console.log('No comments.'); return; }
128
- for (const c of comments) {
129
- console.log(`${c.author?.displayName || 'Unknown'} (${relativeTime(c.created)}): ${descriptionText(c.body)}`);
130
- }
131
- }
132
-
133
- export function plainIssueSearch(result) {
134
- if (result.issues.length === 0) { console.log('No issues found.'); return; }
135
- for (const issue of result.issues) {
136
- const f = issue.fields;
137
- console.log(`${issue.key} [${f.status?.name}] "${f.summary}" - ${f.assignee?.displayName || 'Unassigned'} (${f.priority?.name}, ${relativeTime(f.updated)})`);
138
- }
139
- if (!result.isLast && result.nextPageToken) {
140
- console.log(`More results available (nextPageToken: ${result.nextPageToken})`);
141
- }
142
- }
143
-
144
- // --- Rich formatters ---
145
-
146
- export function formatIssueView(issue) {
147
- const f = issue.fields;
148
-
149
- heading(`${issue.key} ${f.summary}`);
150
- divider();
151
- field('Status', statusColor(f.status?.name));
152
- field('Priority', priorityColor(f.priority?.name));
153
- field('Type', f.issuetype?.name);
154
- const rt = requestType(f);
155
- if (rt) field('Request Type', rt);
156
- field('Assignee', f.assignee?.displayName || pc.dim('Unassigned'));
157
- field('Reporter', f.reporter?.displayName);
158
- field('Labels', f.labels?.length > 0 ? f.labels.join(', ') : null);
159
- field('Components', f.components?.map((c) => c.name).join(', ') || null);
160
- field('Created', new Date(f.created).toLocaleString());
161
- field('Updated', new Date(f.updated).toLocaleString());
162
-
163
- const desc = descriptionText(f.description);
164
- if (desc) {
165
- heading('Description');
166
- const lines = desc.split('\n');
167
- const truncated = lines.slice(0, 30);
168
- for (const line of truncated) {
169
- console.log(` ${line}`);
170
- }
171
- if (lines.length > 30) {
172
- console.log(pc.dim(` ... (${lines.length - 30} more lines)`));
173
- }
174
- }
175
-
176
- console.log('');
177
- }
178
-
179
- export function formatIssueComments(comments) {
180
- if (comments.length === 0) {
181
- console.log(pc.dim(' No comments.'));
182
- return;
183
- }
184
-
185
- for (const c of comments) {
186
- console.log(`\n ${pc.bold(c.author?.displayName || 'Unknown')} ${pc.dim(relativeTime(c.created))}`);
187
- const body = descriptionText(c.body);
188
- if (body) {
189
- for (const line of body.split('\n')) {
190
- console.log(` ${line}`);
191
- }
192
- }
193
- }
194
- console.log('');
195
- }
196
-
197
- export function formatIssueSearch(result) {
198
- const { issues } = result;
199
-
200
- if (issues.length === 0) {
201
- console.log(pc.dim(' No issues found.'));
202
- return;
203
- }
204
-
205
- const rows = issues.map((issue) => [
206
- pc.bold(issue.key),
207
- (issue.fields.summary || '').length > 55
208
- ? issue.fields.summary.slice(0, 52) + '...'
209
- : issue.fields.summary || '',
210
- statusColor(issue.fields.status?.name),
211
- issue.fields.assignee?.displayName || pc.dim('Unassigned'),
212
- priorityColor(issue.fields.priority?.name),
213
- relativeTime(issue.fields.updated),
214
- ]);
215
-
216
- console.log(table(['Key', 'Summary', 'Status', 'Assignee', 'Priority', 'Updated'], rows));
217
-
218
- if (!result.isLast && result.nextPageToken) {
219
- console.log(pc.dim(` More results available — pass --next ${result.nextPageToken} to continue`));
220
- }
221
- console.log('');
222
- }
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
@@ -13,7 +13,7 @@ const program = new Command();
13
13
  program
14
14
  .name('dev')
15
15
  .description('CLI for Bitbucket PRs, Jira tickets, Confluence pages, and Service Desk tickets')
16
- .version('0.3.0')
16
+ .version('0.4.1')
17
17
  .option('--json', 'Output raw JSON')
18
18
  .option('--plain', 'Output compact plain text (no tables, no colors)');
19
19
 
package/CLAUDE.md DELETED
@@ -1,54 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- `devtool` — a Node.js CLI (`dev`) for interacting with Bitbucket pull requests and Jira tickets from the terminal. Uses ES modules, commander for CLI parsing, and the `conf` package for persistent config storage.
8
-
9
- ## Commands
10
-
11
- ```bash
12
- # Run the CLI
13
- node src/index.js <command>
14
-
15
- # Install globally for `dev` command
16
- npm link
17
- ```
18
-
19
- No build step, no tests, no linter configured yet.
20
-
21
- ## Architecture
22
-
23
- ```
24
- src/
25
- index.js # Entry point — commander program, registers subcommands, auto-setup hook
26
- config/store.js # Conf-based config (persisted to OS config dir via `conf` package)
27
- clients/ # API clients (bitbucket.js uses fetch + Basic auth, jira.js uses jira.js SDK + Bearer token)
28
- commands/ # Commander subcommands (config, pr, jira)
29
- formatters/ # Output formatting — each has three variants: rich (colored tables), plain (compact text), JSON (curated objects)
30
- utils/
31
- output.js # Shared UI primitives: spinner, table (cli-table3), heading, field, divider, mask
32
- errors.js # handleError — maps HTTP status codes and connection errors to user-friendly messages
33
- mode.js # getOutputMode — reads --json/--plain global flags, returns 'json'|'plain'|'rich'
34
- ```
35
-
36
- ### Output Mode Pattern
37
-
38
- Every command supports three output modes via global flags `--json` and `--plain`:
39
- - **rich** (default): colored output with tables (cli-table3) and spinners
40
- - **plain**: no colors, no tables, compact text — suitable for piping
41
- - **json**: curated JSON (not raw API responses) via `curate*Json` functions in formatters
42
-
43
- Commands follow this pattern: check mode → conditionally show spinner → fetch data → format via mode-specific function. The `withMode` helper in `commands/pr.js` abstracts this for simple cases.
44
-
45
- ### Config
46
-
47
- Stored via `conf` package (projectName: `devtool`). Interactive setup runs automatically on first use (preAction hook in index.js). Config keys:
48
- - `bitbucket.{workspace, defaultRepo, email, token}`
49
- - `jira.{baseUrl, token, defaultProject}`
50
-
51
- ### API Clients
52
-
53
- - **Bitbucket** (`clients/bitbucket.js`): Raw fetch against `api.bitbucket.org/2.0`, Basic auth, manual pagination via `next` links
54
- - **Jira** (`clients/jira.js`): Uses `jira.js` SDK (Version2Client) with Bearer token auth (overrides SDK's built-in Basic auth via request interceptor)