@falldownthesystem/devtool 0.1.0 → 0.3.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/CLAUDE.md +54 -0
- package/README.md +3 -1
- package/package.json +4 -21
- package/src/clients/jira.js +37 -0
- package/src/clients/serviceDesk.js +72 -0
- package/src/commands/jira.js +20 -0
- package/src/commands/sd.js +95 -0
- package/src/config/store.js +53 -0
- package/src/formatters/jira.js +71 -0
- package/src/formatters/serviceDesk.js +222 -0
- package/src/index.js +4 -2
- package/LICENSE +0 -15
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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)
|
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@ A terminal-first CLI for working with Bitbucket PRs, Jira tickets, and Confluenc
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install
|
|
8
|
+
npm install
|
|
9
|
+
npm link
|
|
9
10
|
```
|
|
10
11
|
|
|
11
12
|
This gives you the `dev` command globally. On first run, it'll walk you through connecting your Bitbucket, Jira, and Confluence accounts.
|
|
@@ -22,6 +23,7 @@ dev pr activity 42 # approvals, updates, comments
|
|
|
22
23
|
dev jira view PROJ-123 # ticket details
|
|
23
24
|
dev jira comments PROJ-123 # ticket comments
|
|
24
25
|
dev jira search "sprint = 5" # JQL search
|
|
26
|
+
dev jira children PROJ-123 # epic children or subtasks (auto-detect)
|
|
25
27
|
dev jira mine # your open tickets
|
|
26
28
|
|
|
27
29
|
dev confluence spaces # list spaces
|
package/package.json
CHANGED
|
@@ -1,34 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@falldownthesystem/devtool",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for Bitbucket PRs, Jira tickets, and
|
|
3
|
+
"version": "0.3.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"
|
|
8
8
|
},
|
|
9
|
-
"files": [
|
|
10
|
-
"src/"
|
|
11
|
-
],
|
|
12
9
|
"scripts": {
|
|
13
10
|
"start": "node src/index.js"
|
|
14
11
|
},
|
|
15
|
-
"keywords": [
|
|
16
|
-
|
|
17
|
-
"bitbucket",
|
|
18
|
-
"jira",
|
|
19
|
-
"confluence",
|
|
20
|
-
"pull-request",
|
|
21
|
-
"devtools"
|
|
22
|
-
],
|
|
23
|
-
"author": "FallDownTheSystem",
|
|
12
|
+
"keywords": [],
|
|
13
|
+
"author": "",
|
|
24
14
|
"license": "ISC",
|
|
25
|
-
"repository": {
|
|
26
|
-
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/FallDownTheSystem/devtool.git"
|
|
28
|
-
},
|
|
29
|
-
"engines": {
|
|
30
|
-
"node": ">=18"
|
|
31
|
-
},
|
|
32
15
|
"dependencies": {
|
|
33
16
|
"@clack/prompts": "^1.0.0",
|
|
34
17
|
"cli-table3": "^0.6.5",
|
package/src/clients/jira.js
CHANGED
|
@@ -59,3 +59,40 @@ export async function getMyIssues() {
|
|
|
59
59
|
: `assignee = currentUser() AND status != Done ORDER BY updated DESC`;
|
|
60
60
|
return searchIssues(jql);
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
export async function getIssueChildren(issueKey) {
|
|
64
|
+
const issue = await getIssue(issueKey);
|
|
65
|
+
const type = issue.fields.issuetype?.name;
|
|
66
|
+
const parent = { key: issue.key, type, summary: issue.fields.summary };
|
|
67
|
+
|
|
68
|
+
if (type === 'Epic') {
|
|
69
|
+
const response = await getClient().instance.get(
|
|
70
|
+
`/rest/agile/1.0/epic/${encodeURIComponent(issueKey)}/issue`,
|
|
71
|
+
{
|
|
72
|
+
params: {
|
|
73
|
+
fields: 'summary,status,assignee,priority,updated,issuetype',
|
|
74
|
+
maxResults: 100,
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
const data = response.data;
|
|
79
|
+
return {
|
|
80
|
+
issues: data.issues || [],
|
|
81
|
+
total: data.total ?? (data.issues || []).length,
|
|
82
|
+
startAt: data.startAt || 0,
|
|
83
|
+
maxResults: data.maxResults || 100,
|
|
84
|
+
parent,
|
|
85
|
+
source: 'epic',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const subtasks = issue.fields.subtasks || [];
|
|
90
|
+
return {
|
|
91
|
+
issues: subtasks,
|
|
92
|
+
total: subtasks.length,
|
|
93
|
+
startAt: 0,
|
|
94
|
+
maxResults: subtasks.length,
|
|
95
|
+
parent,
|
|
96
|
+
source: 'subtasks',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
}
|
package/src/commands/jira.js
CHANGED
|
@@ -4,11 +4,13 @@ import {
|
|
|
4
4
|
getIssueComments,
|
|
5
5
|
searchIssues,
|
|
6
6
|
getMyIssues,
|
|
7
|
+
getIssueChildren,
|
|
7
8
|
} from '../clients/jira.js';
|
|
8
9
|
import {
|
|
9
10
|
formatIssueView, formatIssueComments, formatIssueSearch,
|
|
10
11
|
plainIssueView, plainIssueComments, plainIssueSearch,
|
|
11
12
|
curateIssueJson, curateCommentsJson, curateSearchJson,
|
|
13
|
+
formatIssueChildren, plainIssueChildren, curateChildrenJson,
|
|
12
14
|
} from '../formatters/jira.js';
|
|
13
15
|
import { spinner } from '../utils/output.js';
|
|
14
16
|
import { handleError } from '../utils/errors.js';
|
|
@@ -74,6 +76,24 @@ jira.command('search <jql>')
|
|
|
74
76
|
}
|
|
75
77
|
});
|
|
76
78
|
|
|
79
|
+
jira.command('children <key>')
|
|
80
|
+
.alias('subs')
|
|
81
|
+
.description('List epic children or issue subtasks')
|
|
82
|
+
.action(async function (key) {
|
|
83
|
+
const mode = getOutputMode(this);
|
|
84
|
+
const s = mode === 'rich' ? spinner(`Fetching children of ${key}...`).start() : null;
|
|
85
|
+
try {
|
|
86
|
+
const result = await getIssueChildren(key);
|
|
87
|
+
if (s) s.success({ text: `${result.total} ${result.source === 'epic' ? 'issue(s) in epic' : 'subtask(s)'}` });
|
|
88
|
+
if (mode === 'json') outputJson(curateChildrenJson(result));
|
|
89
|
+
else if (mode === 'plain') plainIssueChildren(result);
|
|
90
|
+
else formatIssueChildren(result);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (s) s.error({ text: `Failed to fetch children of ${key}` });
|
|
93
|
+
handleError(err, `jira children ${key}`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
77
97
|
jira.command('mine')
|
|
78
98
|
.alias('my')
|
|
79
99
|
.description('Show my open issues')
|
|
@@ -0,0 +1,95 @@
|
|
|
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;
|
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
|
}
|
package/src/formatters/jira.js
CHANGED
|
@@ -109,6 +109,23 @@ export function curateSearchJson(result) {
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export function curateChildrenJson(result) {
|
|
113
|
+
return {
|
|
114
|
+
parent: result.parent,
|
|
115
|
+
source: result.source,
|
|
116
|
+
total: result.total,
|
|
117
|
+
issues: result.issues.map((issue) => ({
|
|
118
|
+
key: issue.key,
|
|
119
|
+
summary: issue.fields?.summary || null,
|
|
120
|
+
status: issue.fields?.status?.name || null,
|
|
121
|
+
type: issue.fields?.issuetype?.name || null,
|
|
122
|
+
assignee: issue.fields?.assignee?.displayName || null,
|
|
123
|
+
priority: issue.fields?.priority?.name || null,
|
|
124
|
+
updated: issue.fields?.updated || null,
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
112
129
|
// --- Plain formatters ---
|
|
113
130
|
|
|
114
131
|
export function plainIssueView(issue) {
|
|
@@ -149,6 +166,27 @@ export function plainIssueSearch(result) {
|
|
|
149
166
|
if (result.total > showing) console.log(`Showing ${result.startAt + 1}-${showing} of ${result.total}`);
|
|
150
167
|
}
|
|
151
168
|
|
|
169
|
+
export function plainIssueChildren(result) {
|
|
170
|
+
const label = result.source === 'epic' ? 'epic children' : 'subtasks';
|
|
171
|
+
console.log(`${result.parent.key} (${result.parent.type}) "${result.parent.summary}" - ${result.total} ${label}`);
|
|
172
|
+
if (result.issues.length === 0) { console.log('None.'); return; }
|
|
173
|
+
for (const issue of result.issues) {
|
|
174
|
+
const f = issue.fields || {};
|
|
175
|
+
const parts = [
|
|
176
|
+
`${issue.key}`,
|
|
177
|
+
f.status?.name ? `[${f.status.name}]` : null,
|
|
178
|
+
f.issuetype?.name ? `(${f.issuetype.name})` : null,
|
|
179
|
+
f.summary ? `"${f.summary}"` : null,
|
|
180
|
+
].filter(Boolean);
|
|
181
|
+
const meta = [
|
|
182
|
+
f.assignee?.displayName || null,
|
|
183
|
+
f.priority?.name || null,
|
|
184
|
+
f.updated ? relativeTime(f.updated) : null,
|
|
185
|
+
].filter(Boolean).join(', ');
|
|
186
|
+
console.log(`${parts.join(' ')}${meta ? ` - ${meta}` : ''}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
152
190
|
// --- Rich formatters (original) ---
|
|
153
191
|
|
|
154
192
|
export function formatIssueView(issue) {
|
|
@@ -234,3 +272,36 @@ export function formatIssueSearch(result) {
|
|
|
234
272
|
}
|
|
235
273
|
console.log('');
|
|
236
274
|
}
|
|
275
|
+
|
|
276
|
+
export function formatIssueChildren(result) {
|
|
277
|
+
const { issues, parent, source } = result;
|
|
278
|
+
const label = source === 'epic' ? 'children' : 'subtasks';
|
|
279
|
+
|
|
280
|
+
heading(`${parent.key} ${parent.summary}`);
|
|
281
|
+
console.log(pc.dim(` ${parent.type} · ${result.total} ${label}`));
|
|
282
|
+
divider();
|
|
283
|
+
|
|
284
|
+
if (issues.length === 0) {
|
|
285
|
+
console.log(pc.dim(' None.'));
|
|
286
|
+
console.log('');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const rows = issues.map((issue) => {
|
|
291
|
+
const f = issue.fields || {};
|
|
292
|
+
return [
|
|
293
|
+
pc.bold(issue.key),
|
|
294
|
+
(f.summary || '').length > 50
|
|
295
|
+
? f.summary.slice(0, 47) + '...'
|
|
296
|
+
: f.summary || '',
|
|
297
|
+
f.issuetype?.name || pc.dim('—'),
|
|
298
|
+
statusColor(f.status?.name),
|
|
299
|
+
f.assignee?.displayName || pc.dim('Unassigned'),
|
|
300
|
+
priorityColor(f.priority?.name),
|
|
301
|
+
f.updated ? relativeTime(f.updated) : pc.dim('—'),
|
|
302
|
+
];
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
console.log(table(['Key', 'Summary', 'Type', 'Status', 'Assignee', 'Priority', 'Updated'], rows));
|
|
306
|
+
console.log('');
|
|
307
|
+
}
|
|
@@ -0,0 +1,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
|
+
// --- 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
|
+
}
|
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.3.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 = [];
|
package/LICENSE
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
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.
|