@falldownthesystem/devtool 0.5.0 → 0.6.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/README.md +167 -9
- package/package.json +7 -3
- package/src/clients/bitbucket.js +22 -26
- package/src/clients/confluence.js +170 -171
- package/src/clients/graph-auth.js +110 -0
- package/src/clients/graph-cache.js +148 -0
- package/src/clients/graph.js +263 -0
- package/src/clients/jira.js +20 -20
- package/src/clients/serviceDesk.js +33 -37
- package/src/commands/config.js +71 -27
- package/src/commands/confluence.js +8 -7
- package/src/commands/graph.js +147 -0
- package/src/commands/jira.js +6 -5
- package/src/commands/pr.js +6 -22
- package/src/commands/sd.js +8 -7
- package/src/config/setup.js +113 -0
- package/src/config/store.js +207 -171
- package/src/formatters/graph.js +220 -0
- package/src/index.js +9 -20
- package/src/utils/connection.js +5 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { open, rm, lstat, mkdtemp, link } from 'node:fs/promises';
|
|
2
|
+
import { resolve, dirname, join } from 'node:path';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { validateGraphSite } from '../config/store.js';
|
|
6
|
+
|
|
7
|
+
const BASE = 'https://graph.microsoft.com/v1.0';
|
|
8
|
+
const MAIL_FIELDS = 'id,subject,from,toRecipients,receivedDateTime,sentDateTime,isRead,hasAttachments,bodyPreview,webLink,parentFolderId';
|
|
9
|
+
|
|
10
|
+
export function graphUrl(path, params = {}) {
|
|
11
|
+
const url = new URL(path.startsWith('/') ? `${BASE}${path}` : path);
|
|
12
|
+
if (url.origin !== 'https://graph.microsoft.com' || !url.pathname.startsWith('/v1.0/') || url.username || url.password || url.hash) {
|
|
13
|
+
throw new Error('Refused an invalid Microsoft Graph URL.');
|
|
14
|
+
}
|
|
15
|
+
for (const [key, value] of Object.entries(params)) {
|
|
16
|
+
if (value !== undefined && value !== '') url.searchParams.set(key, String(value));
|
|
17
|
+
}
|
|
18
|
+
return url;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function graphId(value) {
|
|
22
|
+
if (typeof value !== 'string' || !value.trim() || value === '.' || value === '..') throw new Error('A resource ID is required.');
|
|
23
|
+
return encodeURIComponent(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function graphLimit(value = 25) {
|
|
27
|
+
if (!/^\d+$/.test(String(value)) || Number(value) < 1 || Number(value) > 1000) {
|
|
28
|
+
throw new Error('Limit must be an integer from 1 to 1000.');
|
|
29
|
+
}
|
|
30
|
+
return Number(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function siteAddress(site) {
|
|
34
|
+
if (!site) throw new Error('Set defaultSite on the Microsoft Graph connection or pass --site <site-url-or-id>.');
|
|
35
|
+
if (!/^https?:/i.test(site)) return `/sites/${graphId(site)}`;
|
|
36
|
+
const invalid = validateGraphSite(site);
|
|
37
|
+
if (invalid) throw new Error(invalid);
|
|
38
|
+
const url = new URL(site);
|
|
39
|
+
const path = url.pathname.split('/').filter(Boolean).map((part) => graphId(decodeURIComponent(part))).join('/');
|
|
40
|
+
return path ? `/sites/${url.hostname}:/${path}` : `/sites/${url.hostname}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mailboxPath(mailbox) {
|
|
44
|
+
return mailbox ? `/users/${graphId(mailbox)}` : '/me';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function calendarRange({ from, to } = {}, now = new Date()) {
|
|
48
|
+
function parse(value) {
|
|
49
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/i.test(value) || !Number.isFinite(Date.parse(value))) {
|
|
50
|
+
throw new Error('Calendar dates must use ISO 8601 with a timezone, for example 2026-09-14T09:00:00+03:00.');
|
|
51
|
+
}
|
|
52
|
+
return new Date(value);
|
|
53
|
+
}
|
|
54
|
+
const start = from ? parse(from) : now;
|
|
55
|
+
const end = to ? parse(to) : new Date(start.getTime() + 30 * 86400000);
|
|
56
|
+
if (end <= start || end - start > 366 * 86400000) throw new Error('Calendar end must follow start, within 366 days.');
|
|
57
|
+
return { startDateTime: start.toISOString(), endDateTime: end.toISOString() };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function calendarPath({ mailbox, calendar } = {}) {
|
|
61
|
+
return `${mailboxPath(mailbox)}/${calendar ? `calendars/${graphId(calendar)}` : 'calendar'}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function eventStart(event) {
|
|
65
|
+
const value = event.start?.dateTime || '';
|
|
66
|
+
const start = Date.parse(/(?:[zZ]|[+-]\d{2}:\d{2})$/.test(value) ? value : `${value}Z`);
|
|
67
|
+
if (!Number.isFinite(start)) throw new Error('Microsoft Graph returned an event without a valid start time.');
|
|
68
|
+
return start;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createGraphClient({ auth, defaultSite, fetchImpl = fetch }) {
|
|
72
|
+
async function response(path, params = {}, { sharedMail = false, calendars = false, availability = false, headers = {}, scheduleQuery, timeout = 60000 } = {}) {
|
|
73
|
+
const url = graphUrl(path, params);
|
|
74
|
+
if (scheduleQuery && (url.pathname !== '/v1.0/me/calendar/getSchedule' || url.search)) throw new Error('Only the read-only getSchedule query can use POST.');
|
|
75
|
+
const token = await auth.token({ sharedMail, calendars, availability });
|
|
76
|
+
const result = await fetchImpl(url, {
|
|
77
|
+
method: scheduleQuery ? 'POST' : 'GET',
|
|
78
|
+
headers: { ...headers, Accept: 'application/json', Authorization: `Bearer ${token}`, ...(scheduleQuery ? { 'Content-Type': 'application/json' } : {}) },
|
|
79
|
+
...(scheduleQuery ? { body: JSON.stringify(scheduleQuery) } : {}),
|
|
80
|
+
redirect: 'manual',
|
|
81
|
+
signal: AbortSignal.timeout(timeout),
|
|
82
|
+
});
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function check(result) {
|
|
87
|
+
if (result.ok) return;
|
|
88
|
+
let message = `Microsoft Graph request failed (${result.status}).`;
|
|
89
|
+
if (result.status === 401) message = 'Microsoft Graph rejected the sign-in. Run: dev graph login';
|
|
90
|
+
else if (result.status === 403) message = 'Microsoft Graph access denied. Check delegated permissions, administrator consent, and your access to this resource. Shared mail also needs Mail.Read.Shared and Exchange access.';
|
|
91
|
+
else if (result.status === 404) message = 'Microsoft Graph resource not found. Check its ID and your access.';
|
|
92
|
+
else if (result.status === 429) message = `Microsoft Graph rate limit reached. Retry after ${result.headers.get('retry-after') || 'a few'} seconds.`;
|
|
93
|
+
else if (result.status >= 300 && result.status < 400) message = 'Refused an unexpected Microsoft Graph redirect.';
|
|
94
|
+
if (result.headers.get('content-type')?.includes('application/json')) {
|
|
95
|
+
try {
|
|
96
|
+
const body = await result.json();
|
|
97
|
+
const code = body.error?.code;
|
|
98
|
+
if (typeof code === 'string' && /^[a-z0-9_.-]{1,80}$/i.test(code)) message += ` Code: ${code}.`;
|
|
99
|
+
} catch {}
|
|
100
|
+
} else {
|
|
101
|
+
await result.body?.cancel();
|
|
102
|
+
}
|
|
103
|
+
const err = new Error(message);
|
|
104
|
+
err.status = result.status;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function request(path, params, options) {
|
|
109
|
+
const result = await response(path, params, options);
|
|
110
|
+
await check(result);
|
|
111
|
+
return result.json();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function collection(path, params = {}, { limit = 25, pageSize = 100, requireComplete = false, ...options } = {}) {
|
|
115
|
+
limit = graphLimit(limit);
|
|
116
|
+
let next = graphUrl(path, { ...params, $top: Math.min(limit, pageSize) }).href;
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const items = [];
|
|
119
|
+
while (next && items.length < limit) {
|
|
120
|
+
if (seen.has(next) || seen.size >= 1000) throw new Error('Microsoft Graph returned invalid pagination.');
|
|
121
|
+
seen.add(next);
|
|
122
|
+
const page = await request(next, {}, options);
|
|
123
|
+
if (!Array.isArray(page.value)) throw new Error('Microsoft Graph returned an invalid collection.');
|
|
124
|
+
const overflow = page.value.length > limit - items.length;
|
|
125
|
+
items.push(...page.value.slice(0, limit - items.length));
|
|
126
|
+
next = page['@odata.nextLink'];
|
|
127
|
+
if (requireComplete && (overflow || (next && items.length >= limit))) throw new Error('Calendar range exceeds 1000 events. Narrow --from and --to.');
|
|
128
|
+
}
|
|
129
|
+
return items;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function siteId(site) {
|
|
133
|
+
const target = site || defaultSite;
|
|
134
|
+
if (target && !/^https?:/i.test(target)) return graphId(target);
|
|
135
|
+
const data = await request(siteAddress(target), { $select: 'id' });
|
|
136
|
+
return graphId(data.id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function calendarEvents(options = {}) {
|
|
140
|
+
const range = calendarRange(options);
|
|
141
|
+
// Read the entire bounded window before sorting, so page order cannot hide an earlier occurrence.
|
|
142
|
+
const events = await collection(`${calendarPath(options)}/calendarView`, range, {
|
|
143
|
+
limit: 1000, pageSize: 1000, requireComplete: true, calendars: true, headers: { Prefer: 'outlook.timezone="UTC"' },
|
|
144
|
+
});
|
|
145
|
+
return { range, events: events.sort((a, b) => eventStart(a) - eventStart(b)) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
async availability(schedules, options = {}) {
|
|
150
|
+
if (!Array.isArray(schedules) || !schedules.length || schedules.length > 20 || schedules.some((address) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address))) {
|
|
151
|
+
throw new Error('Supply 1–20 email addresses for the availability query.');
|
|
152
|
+
}
|
|
153
|
+
const range = calendarRange(options);
|
|
154
|
+
if (!options.to) range.endDateTime = new Date(Date.parse(range.startDateTime) + 86400000).toISOString();
|
|
155
|
+
if (Date.parse(range.endDateTime) - Date.parse(range.startDateTime) >= 62 * 86400000) throw new Error('Availability range must be shorter than 62 days.');
|
|
156
|
+
const interval = Number(options.interval ?? 30);
|
|
157
|
+
if (!Number.isInteger(interval) || interval < 5 || interval > 1440) throw new Error('Availability interval must be 5–1440 minutes.');
|
|
158
|
+
const result = await request('/me/calendar/getSchedule', {}, {
|
|
159
|
+
availability: true,
|
|
160
|
+
headers: { Prefer: 'outlook.timezone="UTC"' },
|
|
161
|
+
scheduleQuery: {
|
|
162
|
+
schedules,
|
|
163
|
+
startTime: { dateTime: range.startDateTime.replace(/Z$/, ''), timeZone: 'UTC' },
|
|
164
|
+
endTime: { dateTime: range.endDateTime.replace(/Z$/, ''), timeZone: 'UTC' },
|
|
165
|
+
availabilityViewInterval: interval,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
if (!Array.isArray(result.value)) throw new Error('Microsoft Graph returned an invalid availability result.');
|
|
169
|
+
return { from: range.startDateTime, to: range.endDateTime, intervalMinutes: interval, schedules: result.value };
|
|
170
|
+
},
|
|
171
|
+
calendars: ({ mailbox, limit } = {}) => collection(`${mailboxPath(mailbox)}/calendars`, {}, { limit, calendars: true }),
|
|
172
|
+
calendarEvents: async (options = {}) => {
|
|
173
|
+
const limit = graphLimit(options.limit);
|
|
174
|
+
return (await calendarEvents(options)).events.slice(0, limit);
|
|
175
|
+
},
|
|
176
|
+
nextEvent: async (options = {}) => {
|
|
177
|
+
const { range, events } = await calendarEvents(options);
|
|
178
|
+
const start = Date.parse(range.startDateTime);
|
|
179
|
+
const event = events.find((item) => !item.isCancelled && item.responseStatus?.response !== 'declined' && eventStart(item) >= start) || null;
|
|
180
|
+
return { from: range.startDateTime, to: range.endDateTime, event };
|
|
181
|
+
},
|
|
182
|
+
calendarEvent: (id, options = {}) => request(`${calendarPath(options)}/events/${graphId(id)}`, {}, { calendars: true, headers: { Prefer: 'outlook.timezone="UTC"' } }),
|
|
183
|
+
|
|
184
|
+
chats: ({ limit } = {}) => collection('/me/chats', { $expand: 'members', $orderby: 'lastMessagePreview/createdDateTime desc' }, { limit, pageSize: 50 }),
|
|
185
|
+
chat: (id) => request(`/chats/${graphId(id)}`, { $expand: 'members' }),
|
|
186
|
+
chatMessages: (id, { limit } = {}) => collection(`/chats/${graphId(id)}/messages`, { $orderby: 'createdDateTime desc' }, { limit, pageSize: 50 }),
|
|
187
|
+
chatMessage: (chatId, messageId) => request(`/chats/${graphId(chatId)}/messages/${graphId(messageId)}`),
|
|
188
|
+
|
|
189
|
+
mailFolders: ({ mailbox, parent, limit } = {}) => collection(`${mailboxPath(mailbox)}/mailFolders${parent ? `/${graphId(parent)}/childFolders` : ''}`, {}, { limit, sharedMail: !!mailbox }),
|
|
190
|
+
mailMessages: ({ mailbox, folder, search, limit } = {}) => {
|
|
191
|
+
const path = `${mailboxPath(mailbox)}${folder ? `/mailFolders/${graphId(folder)}` : ''}/messages`;
|
|
192
|
+
return collection(path, {
|
|
193
|
+
$select: MAIL_FIELDS,
|
|
194
|
+
$search: search ? JSON.stringify(search) : undefined,
|
|
195
|
+
$orderby: search ? undefined : 'receivedDateTime desc',
|
|
196
|
+
}, { limit, sharedMail: !!mailbox });
|
|
197
|
+
},
|
|
198
|
+
mailMessage: (id, { mailbox, folder } = {}) => request(`${mailboxPath(mailbox)}${folder ? `/mailFolders/${graphId(folder)}` : ''}/messages/${graphId(id)}`, {
|
|
199
|
+
$select: `${MAIL_FIELDS},body,ccRecipients`,
|
|
200
|
+
}, { sharedMail: !!mailbox, headers: { Prefer: 'outlook.body-content-type="text"' } }),
|
|
201
|
+
|
|
202
|
+
sites: (query, { limit } = {}) => collection('/sites', { search: query, $select: 'id,name,displayName,description,webUrl' }, { limit }),
|
|
203
|
+
site: (site) => request(siteAddress(site || defaultSite), { $select: 'id,name,displayName,description,webUrl' }),
|
|
204
|
+
drives: async ({ site, limit } = {}) => collection(`/sites/${await siteId(site)}/drives`, {}, { limit }),
|
|
205
|
+
files: (drive, { folder, path, limit } = {}) => {
|
|
206
|
+
if (folder && path) throw new Error('Use either --folder or --path, not both.');
|
|
207
|
+
let location = folder ? `items/${graphId(folder)}` : 'root';
|
|
208
|
+
if (path) location = `root:/${path.replace(/\\/g, '/').split('/').filter(Boolean).map(graphId).join('/')}:`;
|
|
209
|
+
return collection(`/drives/${graphId(drive)}/${location}/children`, {}, { limit });
|
|
210
|
+
},
|
|
211
|
+
file: (drive, id) => request(`/drives/${graphId(drive)}/items/${graphId(id)}`),
|
|
212
|
+
lists: async ({ site, limit } = {}) => collection(`/sites/${await siteId(site)}/lists`, {}, { limit }),
|
|
213
|
+
listItems: async (list, { site, limit } = {}) => collection(`/sites/${await siteId(site)}/lists/${graphId(list)}/items`, { $expand: 'fields' }, { limit }),
|
|
214
|
+
listItem: async (list, item, { site } = {}) => request(`/sites/${await siteId(site)}/lists/${graphId(list)}/items/${graphId(item)}`, { $expand: 'fields' }),
|
|
215
|
+
|
|
216
|
+
async download(drive, id, destination, { timeout = 120 } = {}) {
|
|
217
|
+
if (!/^\d+$/.test(String(timeout)) || Number(timeout) < 1 || Number(timeout) > 86400) throw new Error('Download timeout must be 1–86400 seconds.');
|
|
218
|
+
const milliseconds = Number(timeout) * 1000;
|
|
219
|
+
try {
|
|
220
|
+
await lstat(destination);
|
|
221
|
+
throw Object.assign(new Error('Destination already exists. Choose a new local path.'), { code: 'EEXIST' });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err.code !== 'ENOENT') throw err;
|
|
224
|
+
}
|
|
225
|
+
const directory = await mkdtemp(join(dirname(resolve(destination)), '.devtool-download-'));
|
|
226
|
+
const temporary = join(directory, 'content');
|
|
227
|
+
let failure;
|
|
228
|
+
try {
|
|
229
|
+
let result = await response(`/drives/${graphId(drive)}/items/${graphId(id)}/content`, {}, { timeout: milliseconds });
|
|
230
|
+
if (result.status === 302) {
|
|
231
|
+
const target = new URL(result.headers.get('location'));
|
|
232
|
+
await result.body?.cancel();
|
|
233
|
+
if (target.protocol !== 'https:' || !target.hostname.endsWith('.sharepoint.com') || target.username || target.password || target.port) {
|
|
234
|
+
throw new Error('Refused a download redirect outside SharePoint Online.');
|
|
235
|
+
}
|
|
236
|
+
// The download URL already authorizes this request; never forward the Graph bearer token.
|
|
237
|
+
result = await fetchImpl(target, { method: 'GET', redirect: 'error', signal: AbortSignal.timeout(milliseconds) });
|
|
238
|
+
}
|
|
239
|
+
await check(result);
|
|
240
|
+
if (!result.body) throw new Error('SharePoint returned no file content.');
|
|
241
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
242
|
+
try {
|
|
243
|
+
await pipeline(Readable.fromWeb(result.body), file.createWriteStream());
|
|
244
|
+
} finally {
|
|
245
|
+
await file.close();
|
|
246
|
+
}
|
|
247
|
+
// Publish only a complete file. An atomic hard link refuses a concurrently created destination.
|
|
248
|
+
await link(temporary, destination);
|
|
249
|
+
return { path: resolve(destination), downloaded: true };
|
|
250
|
+
} catch (err) {
|
|
251
|
+
failure = err;
|
|
252
|
+
throw err;
|
|
253
|
+
} finally {
|
|
254
|
+
try {
|
|
255
|
+
await rm(directory, { recursive: true, force: true });
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (failure) failure.message += ' Temporary download cleanup failed.';
|
|
258
|
+
else throw new Error('The download is complete, but temporary file cleanup failed.', { cause: err });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
package/src/clients/jira.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { Version2Client } from 'jira.js';
|
|
2
|
-
import { get } from '../config/store.js';
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
const clients = new Map();
|
|
5
4
|
|
|
6
|
-
function getClient() {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
client = new Version2Client({
|
|
10
|
-
host:
|
|
5
|
+
function getClient({ baseUrl, token }) {
|
|
6
|
+
const key = `${baseUrl}\n${token}`;
|
|
7
|
+
if (!clients.has(key)) {
|
|
8
|
+
const client = new Version2Client({
|
|
9
|
+
host: baseUrl,
|
|
11
10
|
authentication: {
|
|
12
11
|
basic: { email: '', apiToken: token },
|
|
13
12
|
},
|
|
@@ -16,20 +15,21 @@ function getClient() {
|
|
|
16
15
|
config.headers['Authorization'] = `Bearer ${token}`;
|
|
17
16
|
return config;
|
|
18
17
|
});
|
|
18
|
+
clients.set(key, client);
|
|
19
19
|
}
|
|
20
|
-
return
|
|
20
|
+
return clients.get(key);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export async function getIssue(issueKey) {
|
|
24
|
-
return getClient().issues.getIssue({
|
|
23
|
+
export async function getIssue(connection, issueKey) {
|
|
24
|
+
return getClient(connection).issues.getIssue({
|
|
25
25
|
issueIdOrKey: issueKey,
|
|
26
26
|
fields: ['*all'],
|
|
27
27
|
expand: 'renderedFields',
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export async function getIssueComments(issueKey) {
|
|
32
|
-
const result = await getClient().issueComments.getComments({
|
|
31
|
+
export async function getIssueComments(connection, issueKey) {
|
|
32
|
+
const result = await getClient(connection).issueComments.getComments({
|
|
33
33
|
issueIdOrKey: issueKey,
|
|
34
34
|
orderBy: 'created',
|
|
35
35
|
maxResults: 100,
|
|
@@ -37,8 +37,8 @@ export async function getIssueComments(issueKey) {
|
|
|
37
37
|
return result.comments || [];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export async function searchIssues(jql, startAt = 0, maxResults = 50) {
|
|
41
|
-
const result = await getClient().issueSearch.searchForIssuesUsingJql({
|
|
40
|
+
export async function searchIssues(connection, jql, startAt = 0, maxResults = 50) {
|
|
41
|
+
const result = await getClient(connection).issueSearch.searchForIssuesUsingJql({
|
|
42
42
|
jql,
|
|
43
43
|
startAt,
|
|
44
44
|
maxResults,
|
|
@@ -52,21 +52,21 @@ export async function searchIssues(jql, startAt = 0, maxResults = 50) {
|
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export async function getMyIssues() {
|
|
56
|
-
const project =
|
|
55
|
+
export async function getMyIssues(connection) {
|
|
56
|
+
const project = connection.defaultProject;
|
|
57
57
|
const jql = project
|
|
58
58
|
? `project = ${project} AND assignee = currentUser() AND status != Done ORDER BY updated DESC`
|
|
59
59
|
: `assignee = currentUser() AND status != Done ORDER BY updated DESC`;
|
|
60
|
-
return searchIssues(jql);
|
|
60
|
+
return searchIssues(connection, jql);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export async function getIssueChildren(issueKey) {
|
|
64
|
-
const issue = await getIssue(issueKey);
|
|
63
|
+
export async function getIssueChildren(connection, issueKey) {
|
|
64
|
+
const issue = await getIssue(connection, issueKey);
|
|
65
65
|
const type = issue.fields.issuetype?.name;
|
|
66
66
|
const parent = { key: issue.key, type, summary: issue.fields.summary };
|
|
67
67
|
|
|
68
68
|
if (type === 'Epic') {
|
|
69
|
-
const response = await getClient().instance.get(
|
|
69
|
+
const response = await getClient(connection).instance.get(
|
|
70
70
|
`/rest/agile/1.0/epic/${encodeURIComponent(issueKey)}/issue`,
|
|
71
71
|
{
|
|
72
72
|
params: {
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { get } from '../config/store.js';
|
|
2
|
-
|
|
3
1
|
function configurationError(reason) {
|
|
4
|
-
return new Error(`Service Desk configuration error: ${reason}. Check
|
|
2
|
+
return new Error(`Service Desk configuration error: ${reason}. Check the base URL of the Service Desk connection; use the canonical https://<site>.atlassian.net URL.`);
|
|
5
3
|
}
|
|
6
4
|
|
|
7
|
-
function baseUrl() {
|
|
5
|
+
function baseUrl(connection) {
|
|
8
6
|
let url;
|
|
9
7
|
try {
|
|
10
|
-
url = new URL(
|
|
8
|
+
url = new URL(connection.baseUrl);
|
|
11
9
|
} catch {
|
|
12
10
|
throw configurationError('invalid base URL');
|
|
13
11
|
}
|
|
@@ -17,9 +15,7 @@ function baseUrl() {
|
|
|
17
15
|
return new URL(url.origin);
|
|
18
16
|
}
|
|
19
17
|
|
|
20
|
-
function authHeader() {
|
|
21
|
-
const email = get('serviceDesk.email');
|
|
22
|
-
const token = get('serviceDesk.token');
|
|
18
|
+
function authHeader({ email = '', token = '' }) {
|
|
23
19
|
return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
|
|
24
20
|
}
|
|
25
21
|
|
|
@@ -58,8 +54,8 @@ async function fetchJson(url, headers = {}) {
|
|
|
58
54
|
return data;
|
|
59
55
|
}
|
|
60
56
|
|
|
61
|
-
async function request(path, params = {}, { extraHeaders = {}, validate = () => true } = {}) {
|
|
62
|
-
const site = baseUrl();
|
|
57
|
+
async function request(connection, path, params = {}, { extraHeaders = {}, validate = () => true } = {}) {
|
|
58
|
+
const site = baseUrl(connection);
|
|
63
59
|
if (!site.hostname.endsWith('.atlassian.net')) {
|
|
64
60
|
const info = await fetchJson(new URL('/rest/api/2/serverInfo', site));
|
|
65
61
|
let canonical;
|
|
@@ -77,21 +73,21 @@ async function request(path, params = {}, { extraHeaders = {}, validate = () =>
|
|
|
77
73
|
for (const [k, v] of Object.entries(params)) {
|
|
78
74
|
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
|
|
79
75
|
}
|
|
80
|
-
const data = await fetchJson(url, { 'Authorization': authHeader(), ...extraHeaders });
|
|
76
|
+
const data = await fetchJson(url, { 'Authorization': authHeader(connection), ...extraHeaders });
|
|
81
77
|
if (!validate(data)) throw configurationError('unexpected API response structure');
|
|
82
78
|
return data;
|
|
83
79
|
}
|
|
84
80
|
|
|
85
|
-
export async function getIssue(issueKey) {
|
|
86
|
-
return request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
|
|
81
|
+
export async function getIssue(connection, issueKey) {
|
|
82
|
+
return request(connection, `/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
|
|
87
83
|
expand: 'renderedFields',
|
|
88
84
|
}, {
|
|
89
85
|
validate: (data) => typeof data.key === 'string' && data.fields && typeof data.fields === 'object' && !Array.isArray(data.fields),
|
|
90
86
|
});
|
|
91
87
|
}
|
|
92
88
|
|
|
93
|
-
export async function getIssueComments(issueKey) {
|
|
94
|
-
const data = await request(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
|
|
89
|
+
export async function getIssueComments(connection, issueKey) {
|
|
90
|
+
const data = await request(connection, `/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
|
|
95
91
|
orderBy: 'created',
|
|
96
92
|
maxResults: 100,
|
|
97
93
|
}, {
|
|
@@ -100,9 +96,9 @@ export async function getIssueComments(issueKey) {
|
|
|
100
96
|
return data.comments;
|
|
101
97
|
}
|
|
102
98
|
|
|
103
|
-
export async function searchIssues(jql, maxResults = 50, nextPageToken = '') {
|
|
99
|
+
export async function searchIssues(connection, jql, maxResults = 50, nextPageToken = '') {
|
|
104
100
|
const fields = 'summary,status,assignee,priority,updated,issuetype,reporter,customfield_10010';
|
|
105
|
-
const data = await request('/rest/api/3/search/jql', {
|
|
101
|
+
const data = await request(connection, '/rest/api/3/search/jql', {
|
|
106
102
|
jql,
|
|
107
103
|
fields,
|
|
108
104
|
maxResults,
|
|
@@ -120,21 +116,21 @@ export async function searchIssues(jql, maxResults = 50, nextPageToken = '') {
|
|
|
120
116
|
};
|
|
121
117
|
}
|
|
122
118
|
|
|
123
|
-
export async function getMyIssues() {
|
|
124
|
-
const project =
|
|
119
|
+
export async function getMyIssues(connection) {
|
|
120
|
+
const project = connection.defaultProject;
|
|
125
121
|
const jql = project
|
|
126
122
|
? `project = ${project} AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`
|
|
127
123
|
: `assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC`;
|
|
128
|
-
return searchIssues(jql);
|
|
124
|
+
return searchIssues(connection, jql);
|
|
129
125
|
}
|
|
130
126
|
|
|
131
|
-
|
|
127
|
+
// Keyed by site as well as project, because two sites can both have a project with the same key.
|
|
128
|
+
const serviceDeskIds = new Map();
|
|
132
129
|
|
|
133
|
-
async function getServiceDeskId(projectKey) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
const data = await request('/rest/servicedeskapi/servicedesk', { start: 0, limit: 100 }, {
|
|
130
|
+
async function getServiceDeskId(connection, projectKey) {
|
|
131
|
+
const key = `${baseUrl(connection).origin} ${projectKey}`;
|
|
132
|
+
if (serviceDeskIds.has(key)) return serviceDeskIds.get(key);
|
|
133
|
+
const data = await request(connection, '/rest/servicedeskapi/servicedesk', { start: 0, limit: 100 }, {
|
|
138
134
|
extraHeaders: { 'X-ExperimentalApi': 'opt-in' },
|
|
139
135
|
validate: (data) => Array.isArray(data.values),
|
|
140
136
|
});
|
|
@@ -142,23 +138,23 @@ async function getServiceDeskId(projectKey) {
|
|
|
142
138
|
if (!match) {
|
|
143
139
|
throw new Error(`No Service Desk found for project ${projectKey}`);
|
|
144
140
|
}
|
|
145
|
-
|
|
141
|
+
serviceDeskIds.set(key, match.id);
|
|
146
142
|
return match.id;
|
|
147
143
|
}
|
|
148
144
|
|
|
149
|
-
export async function listQueues() {
|
|
150
|
-
const projectKey =
|
|
151
|
-
if (!projectKey) throw new Error('
|
|
152
|
-
const sdId = await getServiceDeskId(projectKey);
|
|
153
|
-
const data = await request(`/rest/servicedeskapi/servicedesk/${sdId}/queue`, { start: 0, limit: 100 }, {
|
|
145
|
+
export async function listQueues(connection) {
|
|
146
|
+
const projectKey = connection.defaultProject;
|
|
147
|
+
if (!projectKey) throw new Error('No default project is set on the Service Desk connection');
|
|
148
|
+
const sdId = await getServiceDeskId(connection, projectKey);
|
|
149
|
+
const data = await request(connection, `/rest/servicedeskapi/servicedesk/${sdId}/queue`, { start: 0, limit: 100 }, {
|
|
154
150
|
extraHeaders: { 'X-ExperimentalApi': 'opt-in' },
|
|
155
151
|
validate: (data) => Array.isArray(data.values),
|
|
156
152
|
});
|
|
157
153
|
return data.values.map((q) => ({ id: q.id, name: q.name, jql: q.jql }));
|
|
158
154
|
}
|
|
159
155
|
|
|
160
|
-
export async function findQueue(nameOrId) {
|
|
161
|
-
const queues = await listQueues();
|
|
156
|
+
export async function findQueue(connection, nameOrId) {
|
|
157
|
+
const queues = await listQueues(connection);
|
|
162
158
|
const idMatch = queues.find((q) => String(q.id) === String(nameOrId));
|
|
163
159
|
if (idMatch) return idMatch;
|
|
164
160
|
const lower = nameOrId.toLowerCase();
|
|
@@ -174,8 +170,8 @@ export async function findQueue(nameOrId) {
|
|
|
174
170
|
throw new Error(`No queue found matching "${nameOrId}"`);
|
|
175
171
|
}
|
|
176
172
|
|
|
177
|
-
export async function searchByQueue(nameOrId, maxResults = 50, nextPageToken = '') {
|
|
178
|
-
const queue = await findQueue(nameOrId);
|
|
179
|
-
const result = await searchIssues(queue.jql, maxResults, nextPageToken);
|
|
173
|
+
export async function searchByQueue(connection, nameOrId, maxResults = 50, nextPageToken = '') {
|
|
174
|
+
const queue = await findQueue(connection, nameOrId);
|
|
175
|
+
const result = await searchIssues(connection, queue.jql, maxResults, nextPageToken);
|
|
180
176
|
return { ...result, queue };
|
|
181
177
|
}
|
package/src/commands/config.js
CHANGED
|
@@ -1,31 +1,80 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
types, label, listConnections, removeConnection, setDefault, setField, configPath,
|
|
4
|
+
} from '../config/store.js';
|
|
5
|
+
import { runSetup, addConnection, editConnection, describe } from '../config/setup.js';
|
|
3
6
|
import { heading, field, mask, success, error, divider } from '../utils/output.js';
|
|
4
7
|
|
|
8
|
+
function attempt(action) {
|
|
9
|
+
return async (...args) => {
|
|
10
|
+
try {
|
|
11
|
+
await action(...args);
|
|
12
|
+
} catch (err) {
|
|
13
|
+
error(err.message);
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseType(input) {
|
|
20
|
+
if (input === undefined) return undefined;
|
|
21
|
+
const type = Object.keys(types).find((t) => t.toLowerCase() === input.toLowerCase());
|
|
22
|
+
if (!type) throw new Error(`Unknown connection type "${input}". Valid types: ${Object.keys(types).join(', ')}`);
|
|
23
|
+
return type;
|
|
24
|
+
}
|
|
25
|
+
|
|
5
26
|
const config = new Command('config')
|
|
6
|
-
.description('Manage
|
|
27
|
+
.description('Manage connections');
|
|
7
28
|
|
|
8
29
|
config
|
|
9
30
|
.command('setup')
|
|
10
|
-
.description('
|
|
11
|
-
.action(
|
|
12
|
-
|
|
13
|
-
|
|
31
|
+
.description('Add or edit connections interactively')
|
|
32
|
+
.action(attempt(runSetup));
|
|
33
|
+
|
|
34
|
+
config
|
|
35
|
+
.command('add [type]')
|
|
36
|
+
.description(`Add a connection (${Object.keys(types).join(', ')})`)
|
|
37
|
+
.option('-n, --name <name>', 'Connection name')
|
|
38
|
+
.action(attempt((type, opts) => addConnection(parseType(type), opts.name)));
|
|
39
|
+
|
|
40
|
+
config
|
|
41
|
+
.command('edit <connection>')
|
|
42
|
+
.description('Edit a connection interactively')
|
|
43
|
+
.action(attempt(editConnection));
|
|
44
|
+
|
|
45
|
+
config
|
|
46
|
+
.command('remove <connection>')
|
|
47
|
+
.alias('rm')
|
|
48
|
+
.description('Remove a connection')
|
|
49
|
+
.action(attempt((ref) => {
|
|
50
|
+
success(`Removed connection "${label(removeConnection(ref))}"`);
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
config
|
|
54
|
+
.command('default <connection>')
|
|
55
|
+
.description('Use a connection when no --connection is given for its type')
|
|
56
|
+
.action(attempt((ref) => {
|
|
57
|
+
const connection = setDefault(ref);
|
|
58
|
+
success(`"${label(connection)}" is now the default ${types[connection.type].title} connection`);
|
|
59
|
+
}));
|
|
14
60
|
|
|
15
61
|
config
|
|
16
62
|
.command('show')
|
|
17
|
-
.
|
|
18
|
-
.description('Show
|
|
63
|
+
.aliases(['get', 'list'])
|
|
64
|
+
.description('Show all connections')
|
|
19
65
|
.action(() => {
|
|
20
|
-
const
|
|
66
|
+
const connections = listConnections();
|
|
67
|
+
if (!connections.length) {
|
|
68
|
+
console.log('\nNo connections configured. Run `dev config add` to add one.');
|
|
69
|
+
}
|
|
21
70
|
|
|
22
|
-
|
|
23
|
-
heading(
|
|
24
|
-
for (const [key, property] of Object.entries(
|
|
25
|
-
const value =
|
|
26
|
-
field(property.title, property.
|
|
71
|
+
connections.forEach((connection, index) => {
|
|
72
|
+
heading(describe(connections, index));
|
|
73
|
+
for (const [key, property] of Object.entries(types[connection.type].fields)) {
|
|
74
|
+
const value = connection[key] ?? '';
|
|
75
|
+
field(property.title, property.secret && value ? mask(value) : value);
|
|
27
76
|
}
|
|
28
|
-
}
|
|
77
|
+
});
|
|
29
78
|
|
|
30
79
|
console.log('');
|
|
31
80
|
divider();
|
|
@@ -35,17 +84,12 @@ config
|
|
|
35
84
|
|
|
36
85
|
config
|
|
37
86
|
.command('set <key> <value>')
|
|
38
|
-
.description('Set a
|
|
39
|
-
.action((key, value) => {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
set(key, value);
|
|
48
|
-
success(`Set ${key}`);
|
|
49
|
-
});
|
|
87
|
+
.description('Set a connection field, as <connection>.<field> (e.g. bitbucket.workspace my-ws)')
|
|
88
|
+
.action(attempt((key, value) => {
|
|
89
|
+
const dot = key.lastIndexOf('.');
|
|
90
|
+
if (dot <= 0) throw new Error(`Invalid key: ${key}. Use <connection>.<field>, e.g. jira.baseUrl`);
|
|
91
|
+
const connection = setField(key.slice(0, dot), key.slice(dot + 1), value);
|
|
92
|
+
success(`Set ${key.slice(dot + 1)} on connection "${label(connection)}"`);
|
|
93
|
+
}));
|
|
50
94
|
|
|
51
95
|
export default config;
|