@falldownthesystem/devtool 0.4.0 → 0.5.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.
@@ -1,267 +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
- }
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.4.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)