atlass 1.12.0 → 1.13.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/dist/bitbucket.d.mts +1 -1
- package/dist/bitbucket.mjs +1 -1
- package/dist/{build-2A5pjMrK.mjs → build-BblaPBOA.mjs} +733 -595
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/confluence.d.mts +1 -1
- package/dist/confluence.mjs +1 -1
- package/dist/jira.d.mts +1 -1
- package/dist/jira.mjs +1 -1
- package/package.json +4 -4
|
@@ -1,33 +1,60 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { checkbox, confirm, editor, input, password, search, select } from "@inquirer/prompts";
|
|
3
2
|
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
3
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
4
|
import { Entry } from "@napi-rs/keyring";
|
|
7
|
-
import
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
8
7
|
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import { checkbox, confirm, editor, input, password, search, select } from "@inquirer/prompts";
|
|
9
|
+
import kleur from "kleur";
|
|
9
10
|
import { marked } from "marked";
|
|
10
11
|
import { randomUUID } from "node:crypto";
|
|
11
12
|
import { common, createLowlight } from "lowlight";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
//#region src/api/http-error.ts
|
|
14
|
+
var HttpError = class extends Error {
|
|
15
|
+
status;
|
|
16
|
+
jira;
|
|
17
|
+
constructor(status, message, jira = {
|
|
18
|
+
errorMessages: [],
|
|
19
|
+
errors: {}
|
|
20
|
+
}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.jira = jira;
|
|
24
|
+
this.name = "HttpError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
function httpError(status, path, body, loginHint) {
|
|
28
|
+
if (status === 401 || status === 403) return new HttpError(status, `Authentication failed (401/403). Run \`${loginHint}\` to update your token.`);
|
|
29
|
+
if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
|
|
30
|
+
if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
|
|
31
|
+
if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
|
|
32
|
+
if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`, jiraErrors(body));
|
|
33
|
+
return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`, jiraErrors(body));
|
|
23
34
|
}
|
|
24
|
-
function
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
function extractErrorMessage(body) {
|
|
36
|
+
if (!body) return "";
|
|
37
|
+
const json = tryParseJson(body);
|
|
38
|
+
const { errorMessages, errors } = jiraErrors(body);
|
|
39
|
+
const fromJira = [...errorMessages, ...Object.entries(errors).map(([f, m]) => `${f}: ${m}`)];
|
|
40
|
+
if (fromJira.length) return fromJira.join("; ");
|
|
41
|
+
if (json?.message) return json.message;
|
|
42
|
+
if (json?.error?.message) return json.error.message;
|
|
43
|
+
return body.slice(0, 300);
|
|
28
44
|
}
|
|
29
|
-
function
|
|
30
|
-
|
|
45
|
+
function jiraErrors(body) {
|
|
46
|
+
const json = tryParseJson(body);
|
|
47
|
+
return {
|
|
48
|
+
errorMessages: json?.errorMessages ?? [],
|
|
49
|
+
errors: json?.errors ?? {}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function tryParseJson(text) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(text);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
31
58
|
}
|
|
32
59
|
//#endregion
|
|
33
60
|
//#region src/api/client.ts
|
|
@@ -35,10 +62,12 @@ const DISABLE_XSRF_CHECK = { "X-Atlassian-Token": "nocheck" };
|
|
|
35
62
|
var AtlassianClient = class {
|
|
36
63
|
site;
|
|
37
64
|
authHeader;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
loginHint;
|
|
66
|
+
constructor(credentials, loginHint) {
|
|
67
|
+
this.site = credentials.site;
|
|
68
|
+
const basic = Buffer.from(`${credentials.email}:${credentials.token}`).toString("base64");
|
|
41
69
|
this.authHeader = `Basic ${basic}`;
|
|
70
|
+
this.loginHint = loginHint;
|
|
42
71
|
}
|
|
43
72
|
async request(path, init) {
|
|
44
73
|
const res = await fetch(`${this.site}${path}`, {
|
|
@@ -51,7 +80,7 @@ var AtlassianClient = class {
|
|
|
51
80
|
});
|
|
52
81
|
if (!res.ok) {
|
|
53
82
|
const body = await res.text().catch(() => "");
|
|
54
|
-
throw httpError(res.status, path, body);
|
|
83
|
+
throw httpError(res.status, path, body, this.loginHint);
|
|
55
84
|
}
|
|
56
85
|
return res;
|
|
57
86
|
}
|
|
@@ -111,204 +140,512 @@ function pathAndQuery(absoluteUrl) {
|
|
|
111
140
|
const u = new URL(absoluteUrl);
|
|
112
141
|
return u.pathname + u.search;
|
|
113
142
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/api/session.ts
|
|
145
|
+
const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
|
|
146
|
+
const ATLASSIAN_LOGIN = "atlass auth login";
|
|
147
|
+
const BITBUCKET_LOGIN = "atlass bitbucket login";
|
|
148
|
+
var BitbucketClient = class extends AtlassianClient {
|
|
149
|
+
workspace;
|
|
150
|
+
defaultRepo;
|
|
151
|
+
uuid;
|
|
152
|
+
constructor(email, token, bitbucket) {
|
|
153
|
+
super({
|
|
154
|
+
site: BITBUCKET_ORIGIN,
|
|
155
|
+
email,
|
|
156
|
+
token
|
|
157
|
+
}, BITBUCKET_LOGIN);
|
|
158
|
+
this.workspace = bitbucket.workspace;
|
|
159
|
+
this.defaultRepo = bitbucket.defaultRepo;
|
|
160
|
+
this.uuid = bitbucket.uuid;
|
|
125
161
|
}
|
|
126
162
|
};
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
|
|
130
|
-
if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
|
|
131
|
-
if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
|
|
132
|
-
if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`, jiraErrors(body));
|
|
133
|
-
return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`, jiraErrors(body));
|
|
163
|
+
function sessionFor(credentials) {
|
|
164
|
+
return new AtlassianClient(credentials, ATLASSIAN_LOGIN);
|
|
134
165
|
}
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
const json = tryParseJson(body);
|
|
138
|
-
const { errorMessages, errors } = jiraErrors(body);
|
|
139
|
-
const fromJira = [...errorMessages, ...Object.entries(errors).map(([f, m]) => `${f}: ${m}`)];
|
|
140
|
-
if (fromJira.length) return fromJira.join("; ");
|
|
141
|
-
if (json?.message) return json.message;
|
|
142
|
-
if (json?.error?.message) return json.error.message;
|
|
143
|
-
return body.slice(0, 300);
|
|
166
|
+
function bitbucketSessionFor(email, token, bitbucket) {
|
|
167
|
+
return new BitbucketClient(email, token, bitbucket);
|
|
144
168
|
}
|
|
145
|
-
function
|
|
146
|
-
const
|
|
169
|
+
async function openSession(profile) {
|
|
170
|
+
const config = await profile.read();
|
|
171
|
+
if (!config?.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
|
|
172
|
+
const token = await profile.token(config.email, "atlassian");
|
|
173
|
+
if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
|
|
174
|
+
return sessionFor({
|
|
175
|
+
site: config.site,
|
|
176
|
+
email: config.email,
|
|
177
|
+
token
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async function openBitbucketSession(profile) {
|
|
181
|
+
const config = await profile.read();
|
|
182
|
+
if (!config?.email || !config.bitbucket?.workspace) throw new Error("Not logged in to Bitbucket. Run `atlass bitbucket login` first.");
|
|
183
|
+
const token = await profile.token(config.email, "bitbucket");
|
|
184
|
+
if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
|
|
185
|
+
return bitbucketSessionFor(config.email, token, config.bitbucket);
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/files/disk.ts
|
|
189
|
+
async function makeRoom(path) {
|
|
190
|
+
await mkdir(dirname(path), { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
function diskFiles() {
|
|
147
193
|
return {
|
|
148
|
-
|
|
149
|
-
|
|
194
|
+
async readText(path) {
|
|
195
|
+
return readFile(path, "utf8");
|
|
196
|
+
},
|
|
197
|
+
async readBytes(path) {
|
|
198
|
+
const buffer = await readFile(path);
|
|
199
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
200
|
+
},
|
|
201
|
+
async size(path) {
|
|
202
|
+
return (await stat(path)).size;
|
|
203
|
+
},
|
|
204
|
+
async writeText(path, text) {
|
|
205
|
+
await makeRoom(path);
|
|
206
|
+
await writeFile(path, text, "utf8");
|
|
207
|
+
},
|
|
208
|
+
async writeBytes(path, bytes) {
|
|
209
|
+
await makeRoom(path);
|
|
210
|
+
await writeFile(path, bytes);
|
|
211
|
+
}
|
|
150
212
|
};
|
|
151
213
|
}
|
|
152
|
-
function tryParseJson(text) {
|
|
153
|
-
try {
|
|
154
|
-
return JSON.parse(text);
|
|
155
|
-
} catch {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
214
|
//#endregion
|
|
160
|
-
//#region src/
|
|
215
|
+
//#region src/profile/disk.ts
|
|
216
|
+
const SERVICE = "atlass";
|
|
161
217
|
function configDir() {
|
|
162
|
-
|
|
218
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
219
|
+
return join(base, "atlass");
|
|
163
220
|
}
|
|
164
221
|
function configPath() {
|
|
165
222
|
return join(configDir(), "config.json");
|
|
166
223
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const raw = await readFile(configPath(), "utf8");
|
|
170
|
-
const parsed = JSON.parse(raw);
|
|
171
|
-
return {
|
|
172
|
-
site: parsed.site,
|
|
173
|
-
email: parsed.email,
|
|
174
|
-
bitbucket: parsed.bitbucket
|
|
175
|
-
};
|
|
176
|
-
} catch {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
224
|
+
function entry(email, kind) {
|
|
225
|
+
return new Entry(SERVICE, kind === "bitbucket" ? `${email}:bitbucket` : email);
|
|
179
226
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
227
|
+
function diskProfile() {
|
|
228
|
+
return {
|
|
229
|
+
async read() {
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(await readFile(configPath(), "utf8"));
|
|
232
|
+
return {
|
|
233
|
+
site: parsed.site,
|
|
234
|
+
email: parsed.email,
|
|
235
|
+
bitbucket: parsed.bitbucket
|
|
236
|
+
};
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
async write(config) {
|
|
242
|
+
await mkdir(configDir(), { recursive: true });
|
|
243
|
+
await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
244
|
+
},
|
|
245
|
+
async clear() {
|
|
246
|
+
await rm(configPath(), { force: true });
|
|
247
|
+
},
|
|
248
|
+
async token(email, kind) {
|
|
249
|
+
return entry(email, kind).getPassword();
|
|
250
|
+
},
|
|
251
|
+
async setToken(email, kind, token) {
|
|
252
|
+
entry(email, kind).setPassword(token);
|
|
253
|
+
},
|
|
254
|
+
async deleteToken(email, kind) {
|
|
255
|
+
try {
|
|
256
|
+
entry(email, kind).deleteCredential();
|
|
257
|
+
} catch {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
183
262
|
}
|
|
184
|
-
|
|
185
|
-
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/terminal/pager.ts
|
|
265
|
+
const DEFAULT_ROWS = 24;
|
|
266
|
+
const DEFAULT_COLUMNS = 80;
|
|
267
|
+
const DEFAULT_PAGER = "less";
|
|
268
|
+
const DEFAULT_LESS = "FRX";
|
|
269
|
+
function renderedHeight(text, columns) {
|
|
270
|
+
const width = Math.max(1, columns);
|
|
271
|
+
return text.split("\n").map((line) => Math.max(1, Math.ceil(stripVTControlCharacters(line).length / width))).reduce((sum, rows) => sum + rows, 0);
|
|
186
272
|
}
|
|
187
|
-
function
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return
|
|
273
|
+
function shouldPage(text, screen) {
|
|
274
|
+
if (!screen.isTTY) return false;
|
|
275
|
+
const rows = screen.rows ?? DEFAULT_ROWS;
|
|
276
|
+
return renderedHeight(text, screen.columns ?? DEFAULT_COLUMNS) > rows;
|
|
191
277
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
278
|
+
function pagerCommand(env) {
|
|
279
|
+
const [command = DEFAULT_PAGER, ...args] = (env["PAGER"] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
280
|
+
return {
|
|
281
|
+
command,
|
|
282
|
+
args,
|
|
283
|
+
env: {
|
|
284
|
+
...env,
|
|
285
|
+
LESS: env["LESS"] ?? DEFAULT_LESS
|
|
286
|
+
}
|
|
287
|
+
};
|
|
198
288
|
}
|
|
199
|
-
function
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
289
|
+
async function tryPager(text, options = {}) {
|
|
290
|
+
if (!shouldPage(text, options.screen ?? process.stdout)) return false;
|
|
291
|
+
return pipeToPager(text, pagerCommand(options.env ?? process.env));
|
|
292
|
+
}
|
|
293
|
+
function pipeToPager(text, pager) {
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
const child = spawn(pager.command, pager.args, {
|
|
296
|
+
stdio: [
|
|
297
|
+
"pipe",
|
|
298
|
+
"inherit",
|
|
299
|
+
"inherit"
|
|
300
|
+
],
|
|
301
|
+
env: pager.env
|
|
302
|
+
});
|
|
303
|
+
child.once("error", () => resolve(false));
|
|
304
|
+
child.once("close", () => resolve(true));
|
|
305
|
+
child.stdin.once("error", () => {});
|
|
306
|
+
child.stdin.end(`${text}\n`);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/terminal.ts
|
|
311
|
+
var NotInteractiveError = class extends Error {
|
|
312
|
+
constructor(spec) {
|
|
313
|
+
super(spec.flag ? `Cannot prompt without a terminal. Pass ${spec.flag}.` : `Cannot prompt without a terminal: ${spec.message}`);
|
|
314
|
+
this.name = "NotInteractiveError";
|
|
204
315
|
}
|
|
316
|
+
};
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/terminal/prompts.ts
|
|
319
|
+
const inquirerPrompts = {
|
|
320
|
+
text: (spec) => input({
|
|
321
|
+
message: spec.message,
|
|
322
|
+
required: spec.required,
|
|
323
|
+
default: spec.default,
|
|
324
|
+
validate: spec.validate
|
|
325
|
+
}),
|
|
326
|
+
secret: (spec) => password({
|
|
327
|
+
message: spec.message,
|
|
328
|
+
mask: spec.mask
|
|
329
|
+
}),
|
|
330
|
+
confirm: (spec) => confirm({
|
|
331
|
+
message: spec.message,
|
|
332
|
+
default: spec.default
|
|
333
|
+
}),
|
|
334
|
+
pick: (spec) => select({
|
|
335
|
+
message: spec.message,
|
|
336
|
+
choices: spec.choices,
|
|
337
|
+
default: spec.default,
|
|
338
|
+
pageSize: spec.pageSize
|
|
339
|
+
}),
|
|
340
|
+
pickMany: (spec) => checkbox({
|
|
341
|
+
message: spec.message,
|
|
342
|
+
choices: spec.choices,
|
|
343
|
+
required: spec.required,
|
|
344
|
+
pageSize: spec.pageSize
|
|
345
|
+
}),
|
|
346
|
+
edit: (spec) => editor({
|
|
347
|
+
message: spec.message,
|
|
348
|
+
postfix: spec.postfix,
|
|
349
|
+
default: spec.default,
|
|
350
|
+
validate: spec.validate
|
|
351
|
+
}),
|
|
352
|
+
searchPick: (spec) => search({
|
|
353
|
+
message: spec.message,
|
|
354
|
+
source: (term) => spec.source(term),
|
|
355
|
+
pageSize: spec.pageSize
|
|
356
|
+
})
|
|
357
|
+
};
|
|
358
|
+
async function refuse(spec) {
|
|
359
|
+
throw new NotInteractiveError(spec);
|
|
360
|
+
}
|
|
361
|
+
const refusingPrompts = {
|
|
362
|
+
text: refuse,
|
|
363
|
+
secret: refuse,
|
|
364
|
+
confirm: refuse,
|
|
365
|
+
pick: refuse,
|
|
366
|
+
pickMany: refuse,
|
|
367
|
+
edit: refuse,
|
|
368
|
+
searchPick: refuse
|
|
369
|
+
};
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/terminal/tty.ts
|
|
372
|
+
const DEFAULT_WIDTH = 80;
|
|
373
|
+
function ttyTerminal(interactive = true) {
|
|
374
|
+
return {
|
|
375
|
+
interactive,
|
|
376
|
+
out(value) {
|
|
377
|
+
if (Array.isArray(value) && value.length === 0) return;
|
|
378
|
+
console.log(Array.isArray(value) ? value.join("\n") : value);
|
|
379
|
+
},
|
|
380
|
+
err(value) {
|
|
381
|
+
console.error(value);
|
|
382
|
+
},
|
|
383
|
+
json(value) {
|
|
384
|
+
console.log(JSON.stringify(value, null, 2));
|
|
385
|
+
},
|
|
386
|
+
async page(text, options = {}) {
|
|
387
|
+
if (options.pager !== false && await tryPager(text)) return;
|
|
388
|
+
console.log(text);
|
|
389
|
+
},
|
|
390
|
+
get width() {
|
|
391
|
+
return process.stdout.columns ?? DEFAULT_WIDTH;
|
|
392
|
+
},
|
|
393
|
+
ask: interactive ? inquirerPrompts : refusingPrompts
|
|
394
|
+
};
|
|
205
395
|
}
|
|
206
|
-
|
|
207
|
-
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region src/terminal/open.ts
|
|
398
|
+
function openTerminal(args, stdin) {
|
|
399
|
+
return ttyTerminal(stdin.isTTY === true && !refusedInput(args.at(-1)));
|
|
208
400
|
}
|
|
209
|
-
function
|
|
210
|
-
|
|
401
|
+
function refusedInput(last) {
|
|
402
|
+
for (let command = asCommand(last); command; command = command.parent) if (command.opts()["input"] === false) return true;
|
|
403
|
+
return false;
|
|
211
404
|
}
|
|
212
|
-
function
|
|
213
|
-
|
|
405
|
+
function asCommand(value) {
|
|
406
|
+
const command = value;
|
|
407
|
+
return typeof command?.opts === "function" ? command : null;
|
|
214
408
|
}
|
|
215
|
-
|
|
216
|
-
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/cli/run.ts
|
|
411
|
+
const SIGINT_EXIT_CODE = 130;
|
|
412
|
+
function bareAction(fn) {
|
|
413
|
+
return guarded((env, args) => fn(env, ...args));
|
|
414
|
+
}
|
|
415
|
+
function jiraAction(fn) {
|
|
416
|
+
return guarded(async (env, args) => fn({
|
|
417
|
+
...env,
|
|
418
|
+
session: await openSession(env.profile)
|
|
419
|
+
}, ...args));
|
|
420
|
+
}
|
|
421
|
+
function bitbucketAction(fn) {
|
|
422
|
+
return guarded(async (env, args) => fn({
|
|
423
|
+
...env,
|
|
424
|
+
session: await openBitbucketSession(env.profile)
|
|
425
|
+
}, ...args));
|
|
426
|
+
}
|
|
427
|
+
function guarded(fn) {
|
|
428
|
+
return async (...args) => {
|
|
429
|
+
try {
|
|
430
|
+
await fn({
|
|
431
|
+
term: openTerminal(args, process.stdin),
|
|
432
|
+
files: diskFiles(),
|
|
433
|
+
profile: diskProfile()
|
|
434
|
+
}, args);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
fail(err);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
217
439
|
}
|
|
218
|
-
function
|
|
219
|
-
|
|
440
|
+
function fail(err) {
|
|
441
|
+
if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
|
|
442
|
+
ttyTerminal().err(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
443
|
+
process.exit(1);
|
|
220
444
|
}
|
|
221
|
-
function
|
|
222
|
-
return
|
|
445
|
+
function isPromptInterrupt(err) {
|
|
446
|
+
return err instanceof Error && err.name === "ExitPromptError";
|
|
223
447
|
}
|
|
224
|
-
|
|
225
|
-
|
|
448
|
+
//#endregion
|
|
449
|
+
//#region src/util/parse.ts
|
|
450
|
+
function parseIssueKey(input) {
|
|
451
|
+
const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
|
|
452
|
+
return match ? match[0] : null;
|
|
226
453
|
}
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
if (
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
token
|
|
236
|
-
};
|
|
454
|
+
function parsePageId(input) {
|
|
455
|
+
const trimmed = input.trim();
|
|
456
|
+
if (/^\d+$/.test(trimmed)) return trimmed;
|
|
457
|
+
const fromPath = trimmed.match(/\/pages\/(\d+)/);
|
|
458
|
+
if (fromPath) return fromPath[1];
|
|
459
|
+
const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
|
|
460
|
+
if (fromQuery) return fromQuery[1];
|
|
461
|
+
return null;
|
|
237
462
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
463
|
+
function isExternalHref(href) {
|
|
464
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
|
|
465
|
+
}
|
|
466
|
+
function resolveRepo(flag, config) {
|
|
467
|
+
if (flag) {
|
|
468
|
+
if (flag.includes("/")) {
|
|
469
|
+
const parts = flag.split("/");
|
|
470
|
+
const [workspace, repo] = parts;
|
|
471
|
+
if (parts.length !== 2 || !workspace || !repo) throw new Error(`Invalid --repo "${flag}". Expected "workspace/slug" or "slug".`);
|
|
472
|
+
return {
|
|
473
|
+
workspace,
|
|
474
|
+
repo
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (!config.workspace) throw new Error(`No Bitbucket workspace configured. Pass --repo workspace/slug or run \`atlass bitbucket login\`.`);
|
|
478
|
+
return {
|
|
479
|
+
workspace: config.workspace,
|
|
480
|
+
repo: flag
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (config.workspace && config.defaultRepo) return {
|
|
484
|
+
workspace: config.workspace,
|
|
485
|
+
repo: config.defaultRepo
|
|
250
486
|
};
|
|
487
|
+
throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
|
|
488
|
+
}
|
|
489
|
+
const DEFAULT_LIMIT = 25;
|
|
490
|
+
const MAX_LIMIT = 100;
|
|
491
|
+
function parseLimit(value) {
|
|
492
|
+
if (!value) return DEFAULT_LIMIT;
|
|
493
|
+
const n = Number.parseInt(value, 10);
|
|
494
|
+
if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
|
|
495
|
+
return Math.min(n, MAX_LIMIT);
|
|
496
|
+
}
|
|
497
|
+
function siteOrigin(input) {
|
|
498
|
+
let value = input.trim();
|
|
499
|
+
if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
|
|
500
|
+
return new URL(value).origin;
|
|
251
501
|
}
|
|
252
502
|
//#endregion
|
|
253
503
|
//#region src/commands/auth.ts
|
|
254
|
-
async function login() {
|
|
255
|
-
const site = siteOrigin(await
|
|
504
|
+
async function login({ term, profile }) {
|
|
505
|
+
const site = siteOrigin(await term.ask.text({
|
|
256
506
|
message: "Atlassian site (e.g. acme.atlassian.net):",
|
|
257
507
|
required: true
|
|
258
508
|
}));
|
|
259
|
-
const email = await
|
|
509
|
+
const email = await term.ask.text({
|
|
260
510
|
message: "Account email:",
|
|
261
511
|
required: true
|
|
262
512
|
});
|
|
263
|
-
const token = await
|
|
513
|
+
const token = await term.ask.secret({
|
|
264
514
|
message: "API token (from id.atlassian.com/manage-profile/security/api-tokens):",
|
|
265
515
|
mask: true
|
|
266
516
|
});
|
|
267
|
-
const me = await
|
|
517
|
+
const me = await sessionFor({
|
|
268
518
|
site,
|
|
269
519
|
email,
|
|
270
520
|
token
|
|
271
521
|
}).getJson("/rest/api/3/myself");
|
|
272
|
-
await
|
|
273
|
-
|
|
522
|
+
const existing = await profile.read() ?? {};
|
|
523
|
+
await profile.write({
|
|
524
|
+
...existing,
|
|
274
525
|
site,
|
|
275
526
|
email
|
|
276
527
|
});
|
|
277
|
-
|
|
278
|
-
|
|
528
|
+
await profile.setToken(email, "atlassian", token);
|
|
529
|
+
term.out(`Logged in as ${me.displayName} on ${site}.`);
|
|
279
530
|
}
|
|
280
|
-
async function logout() {
|
|
281
|
-
const config = await
|
|
282
|
-
if (config?.email) deleteToken(config.email);
|
|
531
|
+
async function logout({ term, profile }) {
|
|
532
|
+
const config = await profile.read();
|
|
533
|
+
if (config?.email) await profile.deleteToken(config.email, "atlassian");
|
|
283
534
|
const bitbucketLogin = config?.bitbucket && config.email ? {
|
|
284
535
|
email: config.email,
|
|
285
536
|
bitbucket: config.bitbucket
|
|
286
537
|
} : null;
|
|
287
|
-
if (bitbucketLogin) await
|
|
288
|
-
else await
|
|
289
|
-
|
|
538
|
+
if (bitbucketLogin) await profile.write(bitbucketLogin);
|
|
539
|
+
else await profile.clear();
|
|
540
|
+
term.out("Logged out. Credentials removed.");
|
|
290
541
|
}
|
|
291
|
-
async function status() {
|
|
292
|
-
const config = await
|
|
542
|
+
async function status({ term, profile }) {
|
|
543
|
+
const config = await profile.read();
|
|
293
544
|
if (!config || !config.site || !config.email) {
|
|
294
|
-
|
|
545
|
+
term.out("Not logged in. Run `atlass auth login`.");
|
|
295
546
|
return;
|
|
296
547
|
}
|
|
297
|
-
const hasToken =
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
548
|
+
const hasToken = await profile.token(config.email, "atlassian") !== null;
|
|
549
|
+
term.out([
|
|
550
|
+
`Site: ${config.site}`,
|
|
551
|
+
`Email: ${config.email}`,
|
|
552
|
+
`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`
|
|
553
|
+
]);
|
|
301
554
|
}
|
|
302
555
|
//#endregion
|
|
303
556
|
//#region src/cli/auth.ts
|
|
304
557
|
function registerAuth(auth) {
|
|
305
558
|
auth.description("Manage Atlassian credentials");
|
|
306
|
-
auth.command("login").description("Store site, email, and API token").action(
|
|
307
|
-
auth.command("logout").description("Remove stored credentials").action(
|
|
308
|
-
auth.command("status").description("Show the current login").action(
|
|
559
|
+
auth.command("login").description("Store site, email, and API token").action(bareAction(login));
|
|
560
|
+
auth.command("logout").description("Remove stored credentials").action(bareAction(logout));
|
|
561
|
+
auth.command("status").description("Show the current login").action(bareAction(status));
|
|
309
562
|
return auth;
|
|
310
563
|
}
|
|
311
564
|
//#endregion
|
|
565
|
+
//#region src/api/bitbucket-user.ts
|
|
566
|
+
async function fetchCurrentUserUuid(client) {
|
|
567
|
+
const user = await client.getJson("/2.0/user");
|
|
568
|
+
if (!user.uuid) throw new Error("Bitbucket did not return an account uuid for the current user.");
|
|
569
|
+
return user.uuid;
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region src/commands/bitbucket-auth.ts
|
|
573
|
+
async function bitbucketLogin({ term, profile }) {
|
|
574
|
+
const existing = await profile.read() ?? {};
|
|
575
|
+
const email = existing.email ?? await term.ask.text({
|
|
576
|
+
message: "Account email:",
|
|
577
|
+
required: true
|
|
578
|
+
});
|
|
579
|
+
const workspace = (await term.ask.text({
|
|
580
|
+
message: "Bitbucket workspace (e.g. acme):",
|
|
581
|
+
required: true
|
|
582
|
+
})).trim();
|
|
583
|
+
const defaultRepo = (await term.ask.text({ message: "Default repo slug (optional):" })).trim() || void 0;
|
|
584
|
+
const token = await term.ask.secret({
|
|
585
|
+
message: "Bitbucket API token (needs pipeline, pull request, account, and workspace read scopes):",
|
|
586
|
+
mask: true
|
|
587
|
+
});
|
|
588
|
+
const session = bitbucketSessionFor(email, token, { workspace });
|
|
589
|
+
const ws = await verifyWorkspace(session, workspace);
|
|
590
|
+
const uuid = await fetchCurrentUserUuid(session).catch(() => void 0);
|
|
591
|
+
await profile.write({
|
|
592
|
+
...existing,
|
|
593
|
+
email,
|
|
594
|
+
bitbucket: {
|
|
595
|
+
workspace,
|
|
596
|
+
...defaultRepo ? { defaultRepo } : {},
|
|
597
|
+
...uuid ? { uuid } : {}
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
await profile.setToken(email, "bitbucket", token);
|
|
601
|
+
term.out(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
|
|
602
|
+
}
|
|
603
|
+
async function bitbucketLogout({ term, profile }) {
|
|
604
|
+
const config = await profile.read();
|
|
605
|
+
if (config?.email) await profile.deleteToken(config.email, "bitbucket");
|
|
606
|
+
const jiraLogin = config?.site && config.email ? {
|
|
607
|
+
site: config.site,
|
|
608
|
+
email: config.email
|
|
609
|
+
} : null;
|
|
610
|
+
if (jiraLogin) await profile.write(jiraLogin);
|
|
611
|
+
else await profile.clear();
|
|
612
|
+
term.out("Logged out of Bitbucket. Credentials removed.");
|
|
613
|
+
}
|
|
614
|
+
async function bitbucketStatus({ term, profile }) {
|
|
615
|
+
const config = await profile.read();
|
|
616
|
+
if (!config?.email || !config.bitbucket?.workspace) {
|
|
617
|
+
term.out("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const hasToken = await profile.token(config.email, "bitbucket") !== null;
|
|
621
|
+
term.out([
|
|
622
|
+
`Workspace: ${config.bitbucket.workspace}`,
|
|
623
|
+
`Email: ${config.email}`,
|
|
624
|
+
`Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`,
|
|
625
|
+
`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`
|
|
626
|
+
]);
|
|
627
|
+
}
|
|
628
|
+
async function rememberBitbucketUuid(profile, uuid) {
|
|
629
|
+
const config = await profile.read();
|
|
630
|
+
if (!config?.bitbucket) return;
|
|
631
|
+
await profile.write({
|
|
632
|
+
...config,
|
|
633
|
+
bitbucket: {
|
|
634
|
+
...config.bitbucket,
|
|
635
|
+
uuid
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
async function verifyWorkspace(transport, workspace) {
|
|
640
|
+
try {
|
|
641
|
+
return await transport.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
|
|
642
|
+
} catch (err) {
|
|
643
|
+
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error(`Could not verify Bitbucket workspace "${workspace}" (401/403). Check the token and that it has workspace read + read:pipeline:bitbucket scopes.`);
|
|
644
|
+
if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
|
|
645
|
+
throw err;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
//#endregion
|
|
312
649
|
//#region src/api/bitbucket-repo.ts
|
|
313
650
|
const BITBUCKET_WEB_ORIGIN = "https://bitbucket.org";
|
|
314
651
|
function repoPath(ref, resource) {
|
|
@@ -403,13 +740,6 @@ async function listPullRequests(client, ref, params) {
|
|
|
403
740
|
return collectPages(client, `${repoPath(ref, "pullrequests")}?${pullRequestsQuery(params)}`, params.limit, (value) => toPullRequestSummary(ref, value));
|
|
404
741
|
}
|
|
405
742
|
//#endregion
|
|
406
|
-
//#region src/api/bitbucket-user.ts
|
|
407
|
-
async function fetchCurrentUserUuid(client) {
|
|
408
|
-
const user = await client.getJson("/2.0/user");
|
|
409
|
-
if (!user.uuid) throw new Error("Bitbucket did not return an account uuid for the current user.");
|
|
410
|
-
return user.uuid;
|
|
411
|
-
}
|
|
412
|
-
//#endregion
|
|
413
743
|
//#region src/api/bitbucket-pipelines.ts
|
|
414
744
|
const BUILD_NUMBER_SCAN_LIMIT = 1e3;
|
|
415
745
|
function pipelineStatus(state) {
|
|
@@ -513,7 +843,7 @@ function formatDateTime(iso) {
|
|
|
513
843
|
return date.toISOString().replace("T", " ").slice(0, 16);
|
|
514
844
|
}
|
|
515
845
|
const MINUTE = 60;
|
|
516
|
-
const HOUR =
|
|
846
|
+
const HOUR = 3600;
|
|
517
847
|
const DAY = 24 * HOUR;
|
|
518
848
|
function relativeTime(iso, nowMs) {
|
|
519
849
|
if (!iso) return "-";
|
|
@@ -559,43 +889,43 @@ function alignedRows(items, nowMs, cells) {
|
|
|
559
889
|
json: row.json
|
|
560
890
|
}));
|
|
561
891
|
}
|
|
562
|
-
function
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
892
|
+
function formatRows(rows, options) {
|
|
893
|
+
if (rows.length === 0) return [options.empty];
|
|
894
|
+
const lines = rows.map((row) => formatRow(row, options.width));
|
|
895
|
+
return options.footer ? [...lines, options.footer] : lines;
|
|
896
|
+
}
|
|
897
|
+
function writeRows(term, rows, options) {
|
|
898
|
+
if (options.json) term.json(rows.map((r) => r.json));
|
|
899
|
+
else term.out(formatRows(rows, {
|
|
900
|
+
...options,
|
|
901
|
+
width: term.width
|
|
902
|
+
}));
|
|
573
903
|
}
|
|
574
904
|
const COPY_CONCURRENCY = 5;
|
|
575
|
-
async function runSearch(rows, options, noun, copyOne) {
|
|
905
|
+
async function runSearch(term, rows, options, noun, copyOne) {
|
|
576
906
|
if (!(options.copy && !options.json && rows.length > 0)) {
|
|
577
|
-
|
|
907
|
+
writeRows(term, rows, options);
|
|
578
908
|
return;
|
|
579
909
|
}
|
|
580
910
|
if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
|
|
581
|
-
await copySelected(rows, noun, copyOne);
|
|
911
|
+
await copySelected(term, rows, noun, copyOne);
|
|
582
912
|
}
|
|
583
913
|
function searchFooter(limit) {
|
|
584
914
|
return `
|
|
585
915
|
Showing first ${limit}; refine with flags or raise --limit.`;
|
|
586
916
|
}
|
|
587
|
-
async function copySelected(rows, noun, copyOne) {
|
|
588
|
-
if (!
|
|
589
|
-
const selected = await
|
|
917
|
+
async function copySelected(term, rows, noun, copyOne) {
|
|
918
|
+
if (!term.interactive) throw new Error("--copy requires an interactive terminal.");
|
|
919
|
+
const selected = await term.ask.pickMany({
|
|
590
920
|
message: `Select ${noun.plural} to copy:`,
|
|
591
921
|
choices: rows.map((r) => ({
|
|
592
|
-
name: formatRow(r),
|
|
922
|
+
name: formatRow(r, term.width),
|
|
593
923
|
value: r.id
|
|
594
924
|
})),
|
|
595
925
|
pageSize: 20
|
|
596
926
|
});
|
|
597
927
|
if (selected.length === 0) {
|
|
598
|
-
|
|
928
|
+
term.out("Nothing selected.");
|
|
599
929
|
return;
|
|
600
930
|
}
|
|
601
931
|
let copied = 0;
|
|
@@ -611,10 +941,10 @@ async function copySelected(rows, noun, copyOne) {
|
|
|
611
941
|
}
|
|
612
942
|
await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
|
|
613
943
|
const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
|
|
614
|
-
if (failures.length === 0)
|
|
615
|
-
else
|
|
944
|
+
if (failures.length === 0) term.out(summary);
|
|
945
|
+
else term.out(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
|
|
616
946
|
}
|
|
617
|
-
function formatRow(row, width
|
|
947
|
+
function formatRow(row, width) {
|
|
618
948
|
const room = width - stripVTControlCharacters(row.fixedColumns).length - 2;
|
|
619
949
|
const text = room > 0 ? truncate(row.freeText, room) : "";
|
|
620
950
|
const columns = hyperlink(row.id, row.url) + row.fixedColumns.slice(row.id.length);
|
|
@@ -625,141 +955,21 @@ function truncate(text, max) {
|
|
|
625
955
|
return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
|
|
626
956
|
}
|
|
627
957
|
//#endregion
|
|
628
|
-
//#region src/util/parse.ts
|
|
629
|
-
function parseIssueKey(input) {
|
|
630
|
-
const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
|
|
631
|
-
return match ? match[0] : null;
|
|
632
|
-
}
|
|
633
|
-
function parsePageId(input) {
|
|
634
|
-
const trimmed = input.trim();
|
|
635
|
-
if (/^\d+$/.test(trimmed)) return trimmed;
|
|
636
|
-
const fromPath = trimmed.match(/\/pages\/(\d+)/);
|
|
637
|
-
if (fromPath) return fromPath[1];
|
|
638
|
-
const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
|
|
639
|
-
if (fromQuery) return fromQuery[1];
|
|
640
|
-
return null;
|
|
641
|
-
}
|
|
642
|
-
function isExternalHref(href) {
|
|
643
|
-
return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
|
|
644
|
-
}
|
|
645
|
-
function resolveRepo(flag, config) {
|
|
646
|
-
if (flag) {
|
|
647
|
-
if (flag.includes("/")) {
|
|
648
|
-
const parts = flag.split("/");
|
|
649
|
-
const [workspace, repo] = parts;
|
|
650
|
-
if (parts.length !== 2 || !workspace || !repo) throw new Error(`Invalid --repo "${flag}". Expected "workspace/slug" or "slug".`);
|
|
651
|
-
return {
|
|
652
|
-
workspace,
|
|
653
|
-
repo
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
if (!config.workspace) throw new Error(`No Bitbucket workspace configured. Pass --repo workspace/slug or run \`atlass bitbucket login\`.`);
|
|
657
|
-
return {
|
|
658
|
-
workspace: config.workspace,
|
|
659
|
-
repo: flag
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
if (config.workspace && config.defaultRepo) return {
|
|
663
|
-
workspace: config.workspace,
|
|
664
|
-
repo: config.defaultRepo
|
|
665
|
-
};
|
|
666
|
-
throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
|
|
667
|
-
}
|
|
668
|
-
const DEFAULT_LIMIT = 25;
|
|
669
|
-
const MAX_LIMIT = 100;
|
|
670
|
-
function parseLimit(value) {
|
|
671
|
-
if (!value) return DEFAULT_LIMIT;
|
|
672
|
-
const n = Number.parseInt(value, 10);
|
|
673
|
-
if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
|
|
674
|
-
return Math.min(n, MAX_LIMIT);
|
|
675
|
-
}
|
|
676
|
-
//#endregion
|
|
677
958
|
//#region src/commands/bitbucket.ts
|
|
678
|
-
async function
|
|
679
|
-
const
|
|
680
|
-
const email = existing.email ?? await input({
|
|
681
|
-
message: "Account email:",
|
|
682
|
-
required: true
|
|
683
|
-
});
|
|
684
|
-
const workspace = (await input({
|
|
685
|
-
message: "Bitbucket workspace (e.g. acme):",
|
|
686
|
-
required: true
|
|
687
|
-
})).trim();
|
|
688
|
-
const defaultRepo = (await input({ message: "Default repo slug (optional):" })).trim() || void 0;
|
|
689
|
-
const token = await password({
|
|
690
|
-
message: "Bitbucket API token (needs pipeline, pull request, account, and workspace read scopes):",
|
|
691
|
-
mask: true
|
|
692
|
-
});
|
|
693
|
-
const client = new AtlassianClient({
|
|
694
|
-
site: BITBUCKET_ORIGIN,
|
|
695
|
-
email,
|
|
696
|
-
token
|
|
697
|
-
});
|
|
698
|
-
const ws = await verifyWorkspace(client, workspace);
|
|
699
|
-
const uuid = await fetchCurrentUserUuid(client).catch(() => void 0);
|
|
700
|
-
await writeConfig({
|
|
701
|
-
...existing,
|
|
702
|
-
email,
|
|
703
|
-
bitbucket: {
|
|
704
|
-
workspace,
|
|
705
|
-
...defaultRepo ? { defaultRepo } : {},
|
|
706
|
-
...uuid ? { uuid } : {}
|
|
707
|
-
}
|
|
708
|
-
});
|
|
709
|
-
saveBitbucketToken(email, token);
|
|
710
|
-
console.log(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
|
|
711
|
-
}
|
|
712
|
-
async function rememberBitbucketUuid(uuid) {
|
|
713
|
-
const config = await readConfig();
|
|
714
|
-
if (!config?.bitbucket) return;
|
|
715
|
-
await writeConfig({
|
|
716
|
-
...config,
|
|
717
|
-
bitbucket: {
|
|
718
|
-
...config.bitbucket,
|
|
719
|
-
uuid
|
|
720
|
-
}
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
async function bitbucketLogout() {
|
|
724
|
-
const config = await readConfig();
|
|
725
|
-
if (config?.email) deleteBitbucketToken(config.email);
|
|
726
|
-
const jiraLogin = config?.site && config.email ? {
|
|
727
|
-
site: config.site,
|
|
728
|
-
email: config.email
|
|
729
|
-
} : null;
|
|
730
|
-
if (jiraLogin) await writeConfig(jiraLogin);
|
|
731
|
-
else await clearConfig();
|
|
732
|
-
console.log("Logged out of Bitbucket. Credentials removed.");
|
|
733
|
-
}
|
|
734
|
-
async function bitbucketStatus() {
|
|
735
|
-
const config = await readConfig();
|
|
736
|
-
if (!config?.email || !config.bitbucket?.workspace) {
|
|
737
|
-
console.log("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
740
|
-
const hasToken = readBitbucketToken(config.email) !== null;
|
|
741
|
-
console.log(`Workspace: ${config.bitbucket.workspace}`);
|
|
742
|
-
console.log(`Email: ${config.email}`);
|
|
743
|
-
console.log(`Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`);
|
|
744
|
-
console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`);
|
|
745
|
-
}
|
|
746
|
-
async function bitbucketPipelines(options) {
|
|
747
|
-
const auth = await requireBitbucketAuth();
|
|
748
|
-
const ref = resolveRepo(options.repo, auth);
|
|
749
|
-
const client = new AtlassianClient(auth);
|
|
959
|
+
async function bitbucketPipelines({ session, term }, options) {
|
|
960
|
+
const ref = resolveRepo(options.repo, session);
|
|
750
961
|
const limit = parseLimit(options.limit);
|
|
751
|
-
|
|
962
|
+
writeRows(term, pipelineRows(await withScopeHint(PIPELINE_SCOPE, () => listPipelines(session, ref, limit)), Date.now()), {
|
|
752
963
|
json: options.json,
|
|
753
964
|
empty: "No pipelines found."
|
|
754
965
|
});
|
|
755
966
|
}
|
|
756
|
-
async function bitbucketPipeline(arg, options) {
|
|
967
|
+
async function bitbucketPipeline({ session, term }, arg, options) {
|
|
757
968
|
const buildNumber = parseBuildNumber(arg);
|
|
758
|
-
const
|
|
759
|
-
const
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
printPipelineDetail(detail, await withScopeHint(PIPELINE_SCOPE, () => listSteps(client, ref, detail.uuid)), Date.now());
|
|
969
|
+
const ref = resolveRepo(options.repo, session);
|
|
970
|
+
const detail = await withScopeHint(PIPELINE_SCOPE, () => getPipeline(session, ref, buildNumber));
|
|
971
|
+
const steps = await withScopeHint(PIPELINE_SCOPE, () => listSteps(session, ref, detail.uuid));
|
|
972
|
+
term.out(formatPipelineDetail(detail, steps, Date.now()));
|
|
763
973
|
}
|
|
764
974
|
function pipelineRows(pipelines, nowMs) {
|
|
765
975
|
return alignedRows(pipelines, nowMs, (p) => ({
|
|
@@ -798,33 +1008,26 @@ function formatStepRows(steps) {
|
|
|
798
1008
|
const ws = Math.max(...rows.map((r) => r.status.length));
|
|
799
1009
|
return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
|
|
800
1010
|
}
|
|
801
|
-
function
|
|
802
|
-
console.log(`Pipeline #${detail.buildNumber} ${detail.status || "-"}`);
|
|
803
|
-
if (detail.repo) console.log(`Repo: ${detail.repo}`);
|
|
804
|
-
console.log(`Ref: ${refWithCommit(detail)}`);
|
|
805
|
-
if (detail.trigger) console.log(`Trigger: ${detail.trigger}`);
|
|
806
|
-
console.log(`Duration: ${formatDuration(detail.durationSeconds)}`);
|
|
1011
|
+
function formatPipelineDetail(detail, steps, nowMs) {
|
|
807
1012
|
const by = detail.creator ? ` by ${detail.creator}` : "";
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1013
|
+
return [
|
|
1014
|
+
`Pipeline #${detail.buildNumber} ${detail.status || "-"}`,
|
|
1015
|
+
...detail.repo ? [`Repo: ${detail.repo}`] : [],
|
|
1016
|
+
`Ref: ${refWithCommit(detail)}`,
|
|
1017
|
+
...detail.trigger ? [`Trigger: ${detail.trigger}`] : [],
|
|
1018
|
+
`Duration: ${formatDuration(detail.durationSeconds)}`,
|
|
1019
|
+
`Created: ${relativeTime(detail.createdOn, nowMs)}${by}`,
|
|
1020
|
+
...steps.length > 0 ? [
|
|
1021
|
+
"",
|
|
1022
|
+
"Steps:",
|
|
1023
|
+
...formatStepRows(steps)
|
|
1024
|
+
] : []
|
|
1025
|
+
];
|
|
814
1026
|
}
|
|
815
1027
|
function refWithCommit(pipeline) {
|
|
816
1028
|
if (!pipeline.ref) return pipeline.commit || "-";
|
|
817
1029
|
return pipeline.commit ? `${pipeline.ref} (${pipeline.commit})` : pipeline.ref;
|
|
818
1030
|
}
|
|
819
|
-
async function verifyWorkspace(client, workspace) {
|
|
820
|
-
try {
|
|
821
|
-
return await client.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
|
|
822
|
-
} catch (err) {
|
|
823
|
-
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error(`Could not verify Bitbucket workspace "${workspace}" (401/403). Check the token and that it has workspace read + read:pipeline:bitbucket scopes.`);
|
|
824
|
-
if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
|
|
825
|
-
throw err;
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
1031
|
function parseBuildNumber(arg) {
|
|
829
1032
|
const raw = (arg ?? "").replace(/^#/, "").trim();
|
|
830
1033
|
if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
|
|
@@ -843,23 +1046,21 @@ async function withScopeHint(scope, fn) {
|
|
|
843
1046
|
//#region src/commands/bitbucket-prs.ts
|
|
844
1047
|
const PULL_REQUEST_SCOPE = "read:pullrequest:bitbucket";
|
|
845
1048
|
const ACCOUNT_SCOPE = "read:account";
|
|
846
|
-
async function bitbucketPrs(options) {
|
|
1049
|
+
async function bitbucketPrs({ session, term, profile }, options) {
|
|
847
1050
|
if (options.query && (options.author || options.reviewer)) throw new Error("--query cannot be combined with --author or --reviewer.");
|
|
848
1051
|
const states = pullRequestStates(options);
|
|
849
|
-
const
|
|
850
|
-
const ref = resolveRepo(options.repo, auth);
|
|
851
|
-
const client = new AtlassianClient(auth);
|
|
1052
|
+
const ref = resolveRepo(options.repo, session);
|
|
852
1053
|
const limit = parseLimit(options.limit);
|
|
853
1054
|
const query = pullRequestQuery({
|
|
854
|
-
...await resolvePrincipals(
|
|
1055
|
+
...await resolvePrincipals(session, profile, options),
|
|
855
1056
|
query: options.query
|
|
856
1057
|
});
|
|
857
|
-
const prs = await withScopeHint(PULL_REQUEST_SCOPE, () => listPullRequests(
|
|
1058
|
+
const prs = await withScopeHint(PULL_REQUEST_SCOPE, () => listPullRequests(session, ref, {
|
|
858
1059
|
limit,
|
|
859
1060
|
states,
|
|
860
1061
|
query
|
|
861
1062
|
}));
|
|
862
|
-
|
|
1063
|
+
writeRows(term, pullRequestRows(prs, Date.now()), {
|
|
863
1064
|
json: options.json,
|
|
864
1065
|
empty: emptyMessage(options),
|
|
865
1066
|
footer: prs.length === limit ? searchFooter(limit) : void 0
|
|
@@ -883,8 +1084,8 @@ function pullRequestRows(prs, nowMs) {
|
|
|
883
1084
|
};
|
|
884
1085
|
});
|
|
885
1086
|
}
|
|
886
|
-
async function resolvePrincipals(
|
|
887
|
-
const mine = isMe(options.author) || isMe(options.reviewer) ? await currentUuid(
|
|
1087
|
+
async function resolvePrincipals(session, profile, options) {
|
|
1088
|
+
const mine = isMe(options.author) || isMe(options.reviewer) ? await currentUuid(session, profile) : void 0;
|
|
888
1089
|
return {
|
|
889
1090
|
author: isMe(options.author) ? mine : options.author,
|
|
890
1091
|
reviewer: isMe(options.reviewer) ? mine : options.reviewer
|
|
@@ -893,10 +1094,10 @@ async function resolvePrincipals(client, auth, options) {
|
|
|
893
1094
|
function isMe(value) {
|
|
894
1095
|
return value?.toLowerCase() === "me";
|
|
895
1096
|
}
|
|
896
|
-
async function currentUuid(
|
|
897
|
-
if (
|
|
898
|
-
const uuid = await withScopeHint(ACCOUNT_SCOPE, () => fetchCurrentUserUuid(
|
|
899
|
-
await rememberBitbucketUuid(uuid);
|
|
1097
|
+
async function currentUuid(session, profile) {
|
|
1098
|
+
if (session.uuid) return session.uuid;
|
|
1099
|
+
const uuid = await withScopeHint(ACCOUNT_SCOPE, () => fetchCurrentUserUuid(session));
|
|
1100
|
+
await rememberBitbucketUuid(profile, uuid);
|
|
900
1101
|
return uuid;
|
|
901
1102
|
}
|
|
902
1103
|
function emptyMessage(options) {
|
|
@@ -906,12 +1107,12 @@ function emptyMessage(options) {
|
|
|
906
1107
|
//#region src/cli/bitbucket.ts
|
|
907
1108
|
function registerBitbucket(bitbucket) {
|
|
908
1109
|
bitbucket.description("Bitbucket commands");
|
|
909
|
-
bitbucket.command("login").description("Store Bitbucket workspace and API token").action(
|
|
910
|
-
bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(
|
|
911
|
-
bitbucket.command("status").description("Show the current Bitbucket login").action(
|
|
912
|
-
bitbucket.command("pipelines").description("List recent pipeline runs for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(
|
|
913
|
-
bitbucket.command("prs").description("List pull requests for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-s, --state <state...>", "open, merged, declined, or superseded").option("--all", "include every state").option("-a, --author <who>", "limit to an author ('me', an account id, or a uuid)").option("--reviewer <who>", "limit to a reviewer ('me', an account id, or a uuid)").option("-q, --query <bbql>", "raw Bitbucket query (not for use with --author or --reviewer)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(
|
|
914
|
-
bitbucket.command("pipeline <number>").description("Show one pipeline run and its steps").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").action(
|
|
1110
|
+
bitbucket.command("login").description("Store Bitbucket workspace and API token").action(bareAction(bitbucketLogin));
|
|
1111
|
+
bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(bareAction(bitbucketLogout));
|
|
1112
|
+
bitbucket.command("status").description("Show the current Bitbucket login").action(bareAction(bitbucketStatus));
|
|
1113
|
+
bitbucket.command("pipelines").description("List recent pipeline runs for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(bitbucketAction(bitbucketPipelines));
|
|
1114
|
+
bitbucket.command("prs").description("List pull requests for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-s, --state <state...>", "open, merged, declined, or superseded").option("--all", "include every state").option("-a, --author <who>", "limit to an author ('me', an account id, or a uuid)").option("--reviewer <who>", "limit to a reviewer ('me', an account id, or a uuid)").option("-q, --query <bbql>", "raw Bitbucket query (not for use with --author or --reviewer)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(bitbucketAction(bitbucketPrs));
|
|
1115
|
+
bitbucket.command("pipeline <number>").description("Show one pipeline run and its steps").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").action(bitbucketAction(bitbucketPipeline));
|
|
915
1116
|
return bitbucket;
|
|
916
1117
|
}
|
|
917
1118
|
//#endregion
|
|
@@ -1303,9 +1504,10 @@ function cqlStringLiteral(value) {
|
|
|
1303
1504
|
}
|
|
1304
1505
|
//#endregion
|
|
1305
1506
|
//#region src/commands/resolve-ref.ts
|
|
1306
|
-
async function resolveRef(arg, kind) {
|
|
1307
|
-
const raw = arg ?? await
|
|
1507
|
+
async function resolveRef(ask, arg, kind) {
|
|
1508
|
+
const raw = arg ?? await ask.text({
|
|
1308
1509
|
message: kind.message,
|
|
1510
|
+
flag: kind.flag,
|
|
1309
1511
|
required: true
|
|
1310
1512
|
});
|
|
1311
1513
|
const ref = kind.parse(raw);
|
|
@@ -1368,7 +1570,6 @@ function applyMarks(text, marks) {
|
|
|
1368
1570
|
if (typeof value === "string") href = value;
|
|
1369
1571
|
break;
|
|
1370
1572
|
}
|
|
1371
|
-
default: break;
|
|
1372
1573
|
}
|
|
1373
1574
|
if (href) out = `[${out}](${href})`;
|
|
1374
1575
|
return out;
|
|
@@ -1446,7 +1647,8 @@ const PANEL_LABELS = {
|
|
|
1446
1647
|
error: "Error"
|
|
1447
1648
|
};
|
|
1448
1649
|
function renderPanel(node, ctx, indent) {
|
|
1449
|
-
|
|
1650
|
+
const type = typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info";
|
|
1651
|
+
return prefixLines(`**${PANEL_LABELS[type] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
|
|
1450
1652
|
}
|
|
1451
1653
|
function renderExpand(node, ctx, indent) {
|
|
1452
1654
|
return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
|
|
@@ -1732,9 +1934,10 @@ function scalarString(value) {
|
|
|
1732
1934
|
}
|
|
1733
1935
|
function frontmatter(fields) {
|
|
1734
1936
|
const lines = ["---"];
|
|
1735
|
-
for (const [key, value] of Object.entries(fields)) if (Array.isArray(value))
|
|
1736
|
-
|
|
1737
|
-
|
|
1937
|
+
for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) {
|
|
1938
|
+
if (value.length === 0) lines.push(`${key}: []`);
|
|
1939
|
+
else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
|
|
1940
|
+
} else if (typeof value === "number") lines.push(`${key}: ${value}`);
|
|
1738
1941
|
else lines.push(`${key}: ${quote(value)}`);
|
|
1739
1942
|
lines.push("---");
|
|
1740
1943
|
return lines.join("\n");
|
|
@@ -1780,21 +1983,23 @@ function leadingH1(lines) {
|
|
|
1780
1983
|
}
|
|
1781
1984
|
function commentsSection(comments) {
|
|
1782
1985
|
if (comments.length === 0) return "";
|
|
1986
|
+
const blocks = comments.map((c) => {
|
|
1987
|
+
const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
|
|
1988
|
+
return c.body ? `${heading}\n\n${c.body}` : heading;
|
|
1989
|
+
});
|
|
1783
1990
|
return [
|
|
1784
1991
|
COMMENTS_HEADING,
|
|
1785
1992
|
"",
|
|
1786
|
-
|
|
1787
|
-
const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
|
|
1788
|
-
return c.body ? `${heading}\n\n${c.body}` : heading;
|
|
1789
|
-
}).join("\n\n")
|
|
1993
|
+
blocks.join("\n\n")
|
|
1790
1994
|
].join("\n");
|
|
1791
1995
|
}
|
|
1792
1996
|
function attachmentsSection(attachments) {
|
|
1793
1997
|
if (attachments.length === 0) return "";
|
|
1998
|
+
const items = attachments.map((a) => `- [${a.filename}](${a.relativePath})`);
|
|
1794
1999
|
return [
|
|
1795
2000
|
ATTACHMENTS_HEADING,
|
|
1796
2001
|
"",
|
|
1797
|
-
...
|
|
2002
|
+
...items
|
|
1798
2003
|
].join("\n");
|
|
1799
2004
|
}
|
|
1800
2005
|
//#endregion
|
|
@@ -1859,7 +2064,6 @@ function planCopy(source, out) {
|
|
|
1859
2064
|
return {
|
|
1860
2065
|
...content,
|
|
1861
2066
|
filePath: target.filePath,
|
|
1862
|
-
assetsDir: target.assetsDir,
|
|
1863
2067
|
downloads: planDownloads(attachments, target)
|
|
1864
2068
|
};
|
|
1865
2069
|
}
|
|
@@ -1905,21 +2109,18 @@ function numberedName(name, suffix) {
|
|
|
1905
2109
|
}
|
|
1906
2110
|
//#endregion
|
|
1907
2111
|
//#region src/copy/run.ts
|
|
1908
|
-
async function runCopy(
|
|
1909
|
-
const landed = await download(
|
|
1910
|
-
await
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
}
|
|
1914
|
-
async function download(plan, fetchBytes) {
|
|
1915
|
-
if (plan.downloads.length === 0) return [];
|
|
1916
|
-
await mkdir(plan.assetsDir, { recursive: true });
|
|
2112
|
+
async function runCopy(env, plan) {
|
|
2113
|
+
const landed = await download(env, plan);
|
|
2114
|
+
await env.files.writeText(plan.filePath, renderCopy(plan, landed));
|
|
2115
|
+
env.term.out(wroteLine(plan.filePath, landed.length));
|
|
2116
|
+
}
|
|
2117
|
+
async function download({ term, files, session }, plan) {
|
|
1917
2118
|
const landed = [];
|
|
1918
2119
|
for (const { path, ...attachment } of plan.downloads) try {
|
|
1919
|
-
await
|
|
2120
|
+
await files.writeBytes(path, await session.getBinary(attachment.url));
|
|
1920
2121
|
landed.push(attachment);
|
|
1921
2122
|
} catch (err) {
|
|
1922
|
-
|
|
2123
|
+
term.err(` ! could not download ${attachment.filename}: ${err.message}`);
|
|
1923
2124
|
}
|
|
1924
2125
|
return landed;
|
|
1925
2126
|
}
|
|
@@ -2136,80 +2337,31 @@ function withUploadedId(node, ids) {
|
|
|
2136
2337
|
}
|
|
2137
2338
|
//#endregion
|
|
2138
2339
|
//#region src/update/run.ts
|
|
2139
|
-
async function runPlan(plan, options, push) {
|
|
2340
|
+
async function runPlan(term, plan, options, push) {
|
|
2140
2341
|
if (options.dryRun) {
|
|
2141
|
-
|
|
2342
|
+
term.out(formatPlan(plan));
|
|
2142
2343
|
return;
|
|
2143
2344
|
}
|
|
2144
2345
|
const verdict = plan.verdict;
|
|
2145
2346
|
if (verdict.kind === "refuse") throw new Error(verdict.message);
|
|
2146
2347
|
if (verdict.kind === "confirm") {
|
|
2147
|
-
if (!await confirm({
|
|
2348
|
+
if (!await term.ask.confirm({
|
|
2148
2349
|
message: verdict.message,
|
|
2350
|
+
flag: "--force",
|
|
2149
2351
|
default: false
|
|
2150
2352
|
})) {
|
|
2151
|
-
|
|
2353
|
+
term.out("Aborted.");
|
|
2152
2354
|
return;
|
|
2153
2355
|
}
|
|
2154
2356
|
}
|
|
2155
2357
|
await push();
|
|
2156
2358
|
}
|
|
2157
2359
|
//#endregion
|
|
2158
|
-
//#region src/util/pager.ts
|
|
2159
|
-
const DEFAULT_ROWS = 24;
|
|
2160
|
-
const DEFAULT_COLUMNS = 80;
|
|
2161
|
-
const DEFAULT_PAGER = "less";
|
|
2162
|
-
const DEFAULT_LESS = "FRX";
|
|
2163
|
-
function renderedHeight(text, columns) {
|
|
2164
|
-
const width = Math.max(1, columns);
|
|
2165
|
-
return text.split("\n").map((line) => Math.max(1, Math.ceil(stripVTControlCharacters(line).length / width))).reduce((sum, rows) => sum + rows, 0);
|
|
2166
|
-
}
|
|
2167
|
-
function shouldPage(text, term) {
|
|
2168
|
-
if (!term.isTTY) return false;
|
|
2169
|
-
const rows = term.rows ?? DEFAULT_ROWS;
|
|
2170
|
-
return renderedHeight(text, term.columns ?? DEFAULT_COLUMNS) > rows;
|
|
2171
|
-
}
|
|
2172
|
-
function pagerCommand(env) {
|
|
2173
|
-
const [command = DEFAULT_PAGER, ...args] = (env["PAGER"] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
2174
|
-
return {
|
|
2175
|
-
command,
|
|
2176
|
-
args,
|
|
2177
|
-
env: {
|
|
2178
|
-
...env,
|
|
2179
|
-
LESS: env["LESS"] ?? DEFAULT_LESS
|
|
2180
|
-
}
|
|
2181
|
-
};
|
|
2182
|
-
}
|
|
2183
|
-
async function printPaged(text, options = {}) {
|
|
2184
|
-
const term = options.term ?? process.stdout;
|
|
2185
|
-
if (options.pager === false || !shouldPage(text, term)) {
|
|
2186
|
-
console.log(text);
|
|
2187
|
-
return;
|
|
2188
|
-
}
|
|
2189
|
-
if (!await pipeToPager(text, pagerCommand(options.env ?? process.env))) console.log(text);
|
|
2190
|
-
}
|
|
2191
|
-
function pipeToPager(text, pager) {
|
|
2192
|
-
return new Promise((resolve) => {
|
|
2193
|
-
const child = spawn(pager.command, pager.args, {
|
|
2194
|
-
stdio: [
|
|
2195
|
-
"pipe",
|
|
2196
|
-
"inherit",
|
|
2197
|
-
"inherit"
|
|
2198
|
-
],
|
|
2199
|
-
env: pager.env
|
|
2200
|
-
});
|
|
2201
|
-
child.once("error", () => resolve(false));
|
|
2202
|
-
child.once("close", () => resolve(true));
|
|
2203
|
-
child.stdin.once("error", () => {});
|
|
2204
|
-
child.stdin.end(`${text}\n`);
|
|
2205
|
-
});
|
|
2206
|
-
}
|
|
2207
|
-
//#endregion
|
|
2208
2360
|
//#region src/commands/confluence.ts
|
|
2209
|
-
async function confluenceView(arg, options) {
|
|
2210
|
-
const
|
|
2211
|
-
const
|
|
2212
|
-
await
|
|
2361
|
+
async function confluenceView({ session, term }, arg, options) {
|
|
2362
|
+
const id = await resolveRef(term.ask, arg, PAGE_REF);
|
|
2363
|
+
const lines = formatPageView(await fetchPage(session, session.site, id), Date.now(), options.allComments ?? false);
|
|
2364
|
+
await term.page(lines.join("\n"), { pager: options.pager });
|
|
2213
2365
|
}
|
|
2214
2366
|
function formatPageView(page, nowMs, allComments) {
|
|
2215
2367
|
return [
|
|
@@ -2228,37 +2380,35 @@ function formatPageView(page, nowMs, allComments) {
|
|
|
2228
2380
|
...attachmentSection(page.attachments)
|
|
2229
2381
|
];
|
|
2230
2382
|
}
|
|
2231
|
-
async function confluenceCopy(arg, options) {
|
|
2232
|
-
|
|
2233
|
-
const id = await resolveRef(arg, PAGE_REF);
|
|
2234
|
-
await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
|
|
2383
|
+
async function confluenceCopy(env, arg, options) {
|
|
2384
|
+
await copyPage(env, await resolveRef(env.term.ask, arg, PAGE_REF), options.out);
|
|
2235
2385
|
}
|
|
2236
|
-
async function confluenceUpdate(arg, options) {
|
|
2237
|
-
const file = arg ?? await
|
|
2386
|
+
async function confluenceUpdate({ session, term, files }, arg, options) {
|
|
2387
|
+
const file = arg ?? await term.ask.text({
|
|
2238
2388
|
message: "Path to the page Markdown file:",
|
|
2389
|
+
flag: "[file]",
|
|
2239
2390
|
required: true
|
|
2240
2391
|
});
|
|
2241
|
-
const src = parsePageSource(await
|
|
2242
|
-
const
|
|
2243
|
-
const
|
|
2244
|
-
|
|
2245
|
-
await runPlan(plan, options, async () => {
|
|
2392
|
+
const src = parsePageSource(await files.readText(file));
|
|
2393
|
+
const state = await fetchPageState(session, src.id);
|
|
2394
|
+
const plan = planPageUpdate(src, state, await listAttachments(session, src.id), await statLocalImages(files, dirname(resolve(file)), src.body), options);
|
|
2395
|
+
await runPlan(term, plan, options, async () => {
|
|
2246
2396
|
const ids = /* @__PURE__ */ new Map();
|
|
2247
2397
|
for (const upload of plan.uploads) {
|
|
2248
|
-
|
|
2249
|
-
const bytes = await
|
|
2250
|
-
ids.set(upload.href, await uploadAttachment(
|
|
2398
|
+
term.err(`Uploading ${upload.filename} ...`);
|
|
2399
|
+
const bytes = await files.readBytes(upload.path);
|
|
2400
|
+
ids.set(upload.href, await uploadAttachment(session, src.id, upload.filename, bytes));
|
|
2251
2401
|
}
|
|
2252
|
-
const version = await updatePage(
|
|
2402
|
+
const version = await updatePage(session, src.id, {
|
|
2253
2403
|
title: plan.headline.next,
|
|
2254
2404
|
nextVersion: state.version + 1,
|
|
2255
2405
|
body: withUploadedIds(plan.body, ids),
|
|
2256
2406
|
message: options.message ?? "Updated via atlass"
|
|
2257
2407
|
});
|
|
2258
|
-
|
|
2408
|
+
term.out(`Updated page ${src.id} to version ${version}.`);
|
|
2259
2409
|
});
|
|
2260
2410
|
}
|
|
2261
|
-
async function statLocalImages(dir, md) {
|
|
2411
|
+
async function statLocalImages(files, dir, md) {
|
|
2262
2412
|
const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
|
|
2263
2413
|
return Promise.all(hrefs.map(async (href) => {
|
|
2264
2414
|
const path = isAbsolute(href) ? href : resolve(dir, href);
|
|
@@ -2266,47 +2416,46 @@ async function statLocalImages(dir, md) {
|
|
|
2266
2416
|
href,
|
|
2267
2417
|
path,
|
|
2268
2418
|
filename: basename(path),
|
|
2269
|
-
...await fileSize(path)
|
|
2419
|
+
...await fileSize(files, path)
|
|
2270
2420
|
};
|
|
2271
2421
|
}));
|
|
2272
2422
|
}
|
|
2273
|
-
async function fileSize(path) {
|
|
2423
|
+
async function fileSize(files, path) {
|
|
2274
2424
|
try {
|
|
2275
|
-
return { size:
|
|
2425
|
+
return { size: await files.size(path) };
|
|
2276
2426
|
} catch {
|
|
2277
2427
|
return {};
|
|
2278
2428
|
}
|
|
2279
2429
|
}
|
|
2280
|
-
async function confluenceSearch(query, options) {
|
|
2430
|
+
async function confluenceSearch(env, query, options) {
|
|
2281
2431
|
if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
|
|
2282
|
-
await listPages({
|
|
2432
|
+
await listPages(env, {
|
|
2283
2433
|
text: query,
|
|
2284
2434
|
space: options.space,
|
|
2285
2435
|
cql: options.cql
|
|
2286
2436
|
}, "No matching pages.", options);
|
|
2287
2437
|
}
|
|
2288
|
-
async function confluenceList(options) {
|
|
2289
|
-
await listPages({
|
|
2438
|
+
async function confluenceList(env, options) {
|
|
2439
|
+
await listPages(env, {
|
|
2290
2440
|
starred: true,
|
|
2291
2441
|
space: options.space
|
|
2292
2442
|
}, options.space ? `No starred pages in ${options.space}.` : "No starred pages.", options);
|
|
2293
2443
|
}
|
|
2294
|
-
async function listPages(filter, empty, options) {
|
|
2444
|
+
async function listPages(env, filter, empty, options) {
|
|
2445
|
+
const { session, term } = env;
|
|
2295
2446
|
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
2296
|
-
const auth = await requireAuth();
|
|
2297
|
-
const client = new AtlassianClient(auth);
|
|
2298
2447
|
const limit = parseLimit(options.limit);
|
|
2299
|
-
const { pages, hasMore } = await searchPages(
|
|
2448
|
+
const { pages, hasMore } = await searchPages(session, session.site, {
|
|
2300
2449
|
...filter,
|
|
2301
2450
|
limit
|
|
2302
2451
|
});
|
|
2303
|
-
await runSearch(formatPageRows(pages, Date.now()), {
|
|
2452
|
+
await runSearch(term, formatPageRows(pages, Date.now()), {
|
|
2304
2453
|
json: options.json,
|
|
2305
2454
|
copy: options.copy,
|
|
2306
2455
|
out: options.out,
|
|
2307
2456
|
empty,
|
|
2308
2457
|
footer: hasMore ? searchFooter(limit) : void 0
|
|
2309
|
-
}, PAGE_NOUN, (id) => copyPage(
|
|
2458
|
+
}, PAGE_NOUN, (id) => copyPage(env, id, options.out));
|
|
2310
2459
|
}
|
|
2311
2460
|
function formatPageRows(pages, nowMs) {
|
|
2312
2461
|
return alignedRows(pages, nowMs, (p) => ({
|
|
@@ -2334,12 +2483,13 @@ const PAGE_NOUN = {
|
|
|
2334
2483
|
singular: "page",
|
|
2335
2484
|
plural: "pages"
|
|
2336
2485
|
};
|
|
2337
|
-
async function copyPage(
|
|
2338
|
-
|
|
2339
|
-
await runCopy(planPageCopy(await fetchPage(
|
|
2486
|
+
async function copyPage(env, id, out) {
|
|
2487
|
+
env.term.err(`Fetching page ${id} ...`);
|
|
2488
|
+
await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
|
|
2340
2489
|
}
|
|
2341
2490
|
const PAGE_REF = {
|
|
2342
2491
|
message: "Confluence page id or URL:",
|
|
2492
|
+
flag: "[page]",
|
|
2343
2493
|
parse: parsePageId,
|
|
2344
2494
|
notFound: (raw) => `Could not find a page id in "${raw}".`
|
|
2345
2495
|
};
|
|
@@ -2347,11 +2497,11 @@ const PAGE_REF = {
|
|
|
2347
2497
|
//#region src/cli/confluence.ts
|
|
2348
2498
|
function registerConfluence(confluence) {
|
|
2349
2499
|
confluence.description("Confluence commands");
|
|
2350
|
-
confluence.command("view [page]").description("Show a Confluence page (id or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(
|
|
2351
|
-
confluence.command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(
|
|
2352
|
-
confluence.command("update [file]").description("Update a Confluence page from an edited Markdown file").option("--title", "also push the H1 as the page title").option("-m, --message <text>", "version message (default 'Updated via atlass')").option("-f, --force", "skip the stale-version and data-loss checks").option("--dry-run", "show what would change without writing").action(
|
|
2353
|
-
confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(
|
|
2354
|
-
confluence.command("search [query]").description("Search Confluence pages (text query, --space, or --cql)").option("-s, --space <key>", "limit to a space").option("--cql <cql>", "raw CQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(
|
|
2500
|
+
confluence.command("view [page]").description("Show a Confluence page (id or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(jiraAction(confluenceView));
|
|
2501
|
+
confluence.command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(jiraAction(confluenceCopy));
|
|
2502
|
+
confluence.command("update [file]").description("Update a Confluence page from an edited Markdown file").option("--title", "also push the H1 as the page title").option("-m, --message <text>", "version message (default 'Updated via atlass')").option("-f, --force", "skip the stale-version and data-loss checks").option("--dry-run", "show what would change without writing").action(jiraAction(confluenceUpdate));
|
|
2503
|
+
confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(jiraAction(confluenceList));
|
|
2504
|
+
confluence.command("search [query]").description("Search Confluence pages (text query, --space, or --cql)").option("-s, --space <key>", "limit to a space").option("--cql <cql>", "raw CQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(jiraAction(confluenceSearch));
|
|
2355
2505
|
return confluence;
|
|
2356
2506
|
}
|
|
2357
2507
|
//#endregion
|
|
@@ -2462,10 +2612,10 @@ function projectSearchQuery(query, startAt) {
|
|
|
2462
2612
|
}
|
|
2463
2613
|
//#endregion
|
|
2464
2614
|
//#region src/commands/jira-create-resolve.ts
|
|
2465
|
-
async function resolveProject(client, site, arg, strict) {
|
|
2615
|
+
async function resolveProject(ask, client, site, arg, strict) {
|
|
2466
2616
|
if (arg) return arg.toUpperCase();
|
|
2467
2617
|
if (strict) throw new Error("A project key is required, e.g. `jira create BSC Bug --summary ...`.");
|
|
2468
|
-
return
|
|
2618
|
+
return ask.searchPick({
|
|
2469
2619
|
message: "Project:",
|
|
2470
2620
|
source: async (term) => (await listProjects(client, site, term)).map((p) => ({
|
|
2471
2621
|
name: `${p.key} ${p.name}`,
|
|
@@ -2474,7 +2624,7 @@ async function resolveProject(client, site, arg, strict) {
|
|
|
2474
2624
|
pageSize: 15
|
|
2475
2625
|
});
|
|
2476
2626
|
}
|
|
2477
|
-
async function resolveType(project, types, arg, strict) {
|
|
2627
|
+
async function resolveType(ask, project, types, arg, strict) {
|
|
2478
2628
|
if (types.length === 0) throw new Error(`You cannot create issues in ${project}.`);
|
|
2479
2629
|
if (arg) {
|
|
2480
2630
|
const found = matchType(types, arg);
|
|
@@ -2483,7 +2633,7 @@ async function resolveType(project, types, arg, strict) {
|
|
|
2483
2633
|
throw new Error(`${project} has no issue type "${arg}"; available: ${names}.`);
|
|
2484
2634
|
}
|
|
2485
2635
|
if (strict) throw new Error(`An issue type is required; ${project} has: ${types.map((t) => t.name).join(", ")}.`);
|
|
2486
|
-
return
|
|
2636
|
+
return ask.pick({
|
|
2487
2637
|
message: "Issue type:",
|
|
2488
2638
|
choices: types.map((t) => ({
|
|
2489
2639
|
name: t.name,
|
|
@@ -2548,7 +2698,7 @@ function matchCascading(field, value) {
|
|
|
2548
2698
|
}
|
|
2549
2699
|
//#endregion
|
|
2550
2700
|
//#region src/create/encode.ts
|
|
2551
|
-
const SERVER_FILLED = new Set([
|
|
2701
|
+
const SERVER_FILLED = /* @__PURE__ */ new Set([
|
|
2552
2702
|
"project",
|
|
2553
2703
|
"issuetype",
|
|
2554
2704
|
"reporter"
|
|
@@ -2628,7 +2778,7 @@ async function encodeField(field, values, resolveUser) {
|
|
|
2628
2778
|
}
|
|
2629
2779
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
2630
2780
|
const DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i;
|
|
2631
|
-
const NAMED_TYPES = new Set([
|
|
2781
|
+
const NAMED_TYPES = /* @__PURE__ */ new Set([
|
|
2632
2782
|
"priority",
|
|
2633
2783
|
"version",
|
|
2634
2784
|
"component",
|
|
@@ -2809,16 +2959,16 @@ function formatIssueTypeRows(types) {
|
|
|
2809
2959
|
//#endregion
|
|
2810
2960
|
//#region src/create/prompt.ts
|
|
2811
2961
|
const PROMPT_SOURCE = "prompt";
|
|
2812
|
-
async function walkFields(meta, deps) {
|
|
2962
|
+
async function walkFields(ask, meta, deps) {
|
|
2813
2963
|
const firstPass = meta.filter((f) => f.required && !SERVER_FILLED.has(f.fieldId));
|
|
2814
2964
|
const inputs = [];
|
|
2815
2965
|
for (const field of firstPass) {
|
|
2816
|
-
const answer = await promptField(field, deps, true);
|
|
2966
|
+
const answer = await promptField(ask, field, deps, true);
|
|
2817
2967
|
if (answer) inputs.push(answer);
|
|
2818
2968
|
}
|
|
2819
2969
|
const optional = meta.filter((f) => !firstPass.includes(f) && !SERVER_FILLED.has(f.fieldId));
|
|
2820
2970
|
if (optional.length === 0) return inputs;
|
|
2821
|
-
const chosen = await
|
|
2971
|
+
const chosen = await ask.pickMany({
|
|
2822
2972
|
message: "Set any optional fields?",
|
|
2823
2973
|
choices: optional.map((f) => ({
|
|
2824
2974
|
name: `${f.name} (${fieldTypeLabel(f.schema)})`,
|
|
@@ -2827,13 +2977,13 @@ async function walkFields(meta, deps) {
|
|
|
2827
2977
|
pageSize: 15
|
|
2828
2978
|
});
|
|
2829
2979
|
for (const field of optional.filter((f) => chosen.includes(f.fieldId))) {
|
|
2830
|
-
const answer = await promptField(field, deps, false);
|
|
2980
|
+
const answer = await promptField(ask, field, deps, false);
|
|
2831
2981
|
if (answer) inputs.push(answer);
|
|
2832
2982
|
}
|
|
2833
2983
|
return inputs;
|
|
2834
2984
|
}
|
|
2835
|
-
async function promptField(field, deps, required) {
|
|
2836
|
-
const answer = await askValues(field, deps, required);
|
|
2985
|
+
async function promptField(ask, field, deps, required) {
|
|
2986
|
+
const answer = await askValues(ask, field, deps, required);
|
|
2837
2987
|
if (answer.values.length === 0) return void 0;
|
|
2838
2988
|
return {
|
|
2839
2989
|
name: field.fieldId,
|
|
@@ -2846,14 +2996,14 @@ const SKIP = {
|
|
|
2846
2996
|
name: "(skip)",
|
|
2847
2997
|
value: ""
|
|
2848
2998
|
};
|
|
2849
|
-
async function askValues(field, deps, required) {
|
|
2999
|
+
async function askValues(ask, field, deps, required) {
|
|
2850
3000
|
const { schema } = field;
|
|
2851
3001
|
const message = `${field.name}:`;
|
|
2852
3002
|
const options = field.allowedValues ?? [];
|
|
2853
|
-
if (schema.type === "option-with-child") return askCascading(field, required);
|
|
3003
|
+
if (schema.type === "option-with-child") return askCascading(ask, field, required);
|
|
2854
3004
|
if (options.length > 0 && schema.type === "array") {
|
|
2855
3005
|
const defaults = new Set(defaultIds(field));
|
|
2856
|
-
return { values: await
|
|
3006
|
+
return { values: await ask.pickMany({
|
|
2857
3007
|
message,
|
|
2858
3008
|
choices: options.map((v) => ({
|
|
2859
3009
|
...choice(v),
|
|
@@ -2865,7 +3015,7 @@ async function askValues(field, deps, required) {
|
|
|
2865
3015
|
}
|
|
2866
3016
|
if (options.length > 0) {
|
|
2867
3017
|
const choices = options.map(choice);
|
|
2868
|
-
const picked = await
|
|
3018
|
+
const picked = await ask.pick({
|
|
2869
3019
|
message,
|
|
2870
3020
|
choices: required ? choices : [SKIP, ...choices],
|
|
2871
3021
|
default: defaultIds(field)[0],
|
|
@@ -2873,9 +3023,9 @@ async function askValues(field, deps, required) {
|
|
|
2873
3023
|
});
|
|
2874
3024
|
return { values: picked ? [picked] : [] };
|
|
2875
3025
|
}
|
|
2876
|
-
if (schema.type === "user") return askUser(field, deps, required);
|
|
3026
|
+
if (schema.type === "user") return askUser(ask, field, deps, required);
|
|
2877
3027
|
if (isMultiLine(schema)) {
|
|
2878
|
-
const text = await
|
|
3028
|
+
const text = await ask.edit({
|
|
2879
3029
|
message: `${field.name} (opens your editor, Markdown):`,
|
|
2880
3030
|
postfix: ".md",
|
|
2881
3031
|
default: primitiveDefault(field),
|
|
@@ -2884,7 +3034,7 @@ async function askValues(field, deps, required) {
|
|
|
2884
3034
|
return { values: text.trim() ? [text] : [] };
|
|
2885
3035
|
}
|
|
2886
3036
|
const hint = inputHint(field);
|
|
2887
|
-
const text = await
|
|
3037
|
+
const text = await ask.text({
|
|
2888
3038
|
message: hint ? `${field.name} (${hint}):` : message,
|
|
2889
3039
|
default: primitiveDefault(field),
|
|
2890
3040
|
validate: async (v) => {
|
|
@@ -2922,10 +3072,10 @@ function primitiveDefault(field) {
|
|
|
2922
3072
|
if (!field.hasDefaultValue || d == null) return void 0;
|
|
2923
3073
|
return typeof d === "string" || typeof d === "number" ? String(d) : void 0;
|
|
2924
3074
|
}
|
|
2925
|
-
async function askCascading(field, required) {
|
|
3075
|
+
async function askCascading(ask, field, required) {
|
|
2926
3076
|
const options = field.allowedValues ?? [];
|
|
2927
3077
|
const parentChoices = options.map(choice);
|
|
2928
|
-
const parentId = await
|
|
3078
|
+
const parentId = await ask.pick({
|
|
2929
3079
|
message: `${field.name}:`,
|
|
2930
3080
|
choices: required ? parentChoices : [SKIP, ...parentChoices],
|
|
2931
3081
|
pageSize: 15
|
|
@@ -2934,7 +3084,7 @@ async function askCascading(field, required) {
|
|
|
2934
3084
|
const parent = options.find((v) => v.id === parentId);
|
|
2935
3085
|
const children = parent?.children ?? [];
|
|
2936
3086
|
if (children.length === 0) return { values: [parentId] };
|
|
2937
|
-
const childId = await
|
|
3087
|
+
const childId = await ask.pick({
|
|
2938
3088
|
message: `${field.name} > ${allowedLabel(parent)}:`,
|
|
2939
3089
|
choices: [{
|
|
2940
3090
|
name: "(none)",
|
|
@@ -2952,10 +3102,10 @@ const UNASSIGNED = {
|
|
|
2952
3102
|
name: "(unassigned)",
|
|
2953
3103
|
value: ""
|
|
2954
3104
|
};
|
|
2955
|
-
async function askUser(field, deps, required) {
|
|
3105
|
+
async function askUser(ask, field, deps, required) {
|
|
2956
3106
|
const fixed = required ? [ME] : [ME, UNASSIGNED];
|
|
2957
3107
|
const seen = /* @__PURE__ */ new Map();
|
|
2958
|
-
const picked = await
|
|
3108
|
+
const picked = await ask.searchPick({
|
|
2959
3109
|
message: `${field.name} (type to search):`,
|
|
2960
3110
|
source: async (term) => {
|
|
2961
3111
|
if (!term) return fixed;
|
|
@@ -2997,7 +3147,7 @@ function summarizeText(text) {
|
|
|
2997
3147
|
}
|
|
2998
3148
|
//#endregion
|
|
2999
3149
|
//#region src/commands/jira-create.ts
|
|
3000
|
-
async function flagInputs(options) {
|
|
3150
|
+
async function flagInputs(files, options) {
|
|
3001
3151
|
const inputs = [];
|
|
3002
3152
|
const one = (name, value, source) => {
|
|
3003
3153
|
if (value !== void 0) inputs.push({
|
|
@@ -3010,7 +3160,7 @@ async function flagInputs(options) {
|
|
|
3010
3160
|
one("description", options.description, "--description");
|
|
3011
3161
|
if (options.descriptionFile !== void 0) {
|
|
3012
3162
|
if (options.description !== void 0) throw new Error("--description and --description-file cannot be used together.");
|
|
3013
|
-
one("description", await
|
|
3163
|
+
one("description", await files.readText(options.descriptionFile), "--description-file");
|
|
3014
3164
|
}
|
|
3015
3165
|
one("assignee", options.assignee, "--assignee");
|
|
3016
3166
|
one("priority", options.priority, "--priority");
|
|
@@ -3037,17 +3187,16 @@ function parseFieldFlag(raw) {
|
|
|
3037
3187
|
source: "--field"
|
|
3038
3188
|
};
|
|
3039
3189
|
}
|
|
3040
|
-
async function jiraCreate(projectArg, typeArg, options) {
|
|
3041
|
-
const inputs = await flagInputs(options);
|
|
3042
|
-
const strict = inputs.length > 0 ||
|
|
3043
|
-
const
|
|
3044
|
-
const
|
|
3045
|
-
const
|
|
3046
|
-
const
|
|
3047
|
-
const
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
searchUsers: (query) => searchAssignableUsers(client, project, query),
|
|
3190
|
+
async function jiraCreate({ session, term, files }, projectArg, typeArg, options) {
|
|
3191
|
+
const inputs = await flagInputs(files, options);
|
|
3192
|
+
const strict = inputs.length > 0 || !term.interactive;
|
|
3193
|
+
const project = await resolveProject(term.ask, session, session.site, projectArg, strict);
|
|
3194
|
+
const types = await fetchCreateIssueTypes(session, project);
|
|
3195
|
+
const type = await resolveType(term.ask, project, types, typeArg, strict);
|
|
3196
|
+
const meta = await fetchCreateFields(session, project, type.id);
|
|
3197
|
+
const resolveUser = userResolver(session, project);
|
|
3198
|
+
if (!strict) inputs.push(...await walkFields(term.ask, meta, {
|
|
3199
|
+
searchUsers: (query) => searchAssignableUsers(session, project, query),
|
|
3051
3200
|
validate: async (field, value) => {
|
|
3052
3201
|
return (await encodeCreate([field], [{
|
|
3053
3202
|
name: field.fieldId,
|
|
@@ -3065,23 +3214,23 @@ async function jiraCreate(projectArg, typeArg, options) {
|
|
|
3065
3214
|
...result.fields
|
|
3066
3215
|
};
|
|
3067
3216
|
if (options.dryRun) {
|
|
3068
|
-
|
|
3217
|
+
term.json({ fields });
|
|
3069
3218
|
return;
|
|
3070
3219
|
}
|
|
3071
3220
|
if (!strict) {
|
|
3072
|
-
|
|
3073
|
-
if (!await confirm({
|
|
3221
|
+
term.out(formatReview(meta, inputs));
|
|
3222
|
+
if (!await term.ask.confirm({
|
|
3074
3223
|
message: `Create ${project} ${type.name}?`,
|
|
3075
3224
|
default: true
|
|
3076
3225
|
})) {
|
|
3077
|
-
|
|
3226
|
+
term.out("Aborted.");
|
|
3078
3227
|
return;
|
|
3079
3228
|
}
|
|
3080
3229
|
}
|
|
3081
3230
|
try {
|
|
3082
|
-
const created = await createIssue(
|
|
3083
|
-
if (options.json)
|
|
3084
|
-
else
|
|
3231
|
+
const created = await createIssue(session, session.site, fields);
|
|
3232
|
+
if (options.json) term.json(created);
|
|
3233
|
+
else term.out(`Created ${created.key} ${created.url}`);
|
|
3085
3234
|
} catch (err) {
|
|
3086
3235
|
throw describeRejection(err, meta);
|
|
3087
3236
|
}
|
|
@@ -3107,19 +3256,18 @@ function describeRejection(err, meta) {
|
|
|
3107
3256
|
...perField
|
|
3108
3257
|
].join("\n"));
|
|
3109
3258
|
}
|
|
3110
|
-
async function jiraFields(projectArg, typeArg, options) {
|
|
3111
|
-
const client = new AtlassianClient(await requireAuth());
|
|
3259
|
+
async function jiraFields({ session, term }, projectArg, typeArg, options) {
|
|
3112
3260
|
const project = projectArg.toUpperCase();
|
|
3113
|
-
const types = await fetchCreateIssueTypes(
|
|
3261
|
+
const types = await fetchCreateIssueTypes(session, project);
|
|
3114
3262
|
if (!typeArg) {
|
|
3115
|
-
if (options.json)
|
|
3116
|
-
else if (types.length === 0)
|
|
3117
|
-
else
|
|
3263
|
+
if (options.json) term.json(types);
|
|
3264
|
+
else if (types.length === 0) term.out(`You cannot create issues in ${project}.`);
|
|
3265
|
+
else term.out(formatIssueTypeRows(types));
|
|
3118
3266
|
return;
|
|
3119
3267
|
}
|
|
3120
|
-
const fields = await fetchCreateFields(
|
|
3121
|
-
if (options.json)
|
|
3122
|
-
else
|
|
3268
|
+
const fields = await fetchCreateFields(session, project, (await resolveType(term.ask, project, types, typeArg, true)).id);
|
|
3269
|
+
if (options.json) term.json(fields);
|
|
3270
|
+
else term.out(formatFieldRows(fields));
|
|
3123
3271
|
}
|
|
3124
3272
|
//#endregion
|
|
3125
3273
|
//#region src/api/jira-search.ts
|
|
@@ -3295,47 +3443,34 @@ function dedupeAndSortStatuses(statuses) {
|
|
|
3295
3443
|
}
|
|
3296
3444
|
//#endregion
|
|
3297
3445
|
//#region src/commands/jira.ts
|
|
3298
|
-
async function jiraProjects(query, options) {
|
|
3299
|
-
const
|
|
3300
|
-
|
|
3301
|
-
if (
|
|
3302
|
-
|
|
3303
|
-
return;
|
|
3304
|
-
}
|
|
3305
|
-
if (projects.length === 0) {
|
|
3306
|
-
console.log("No matching projects.");
|
|
3307
|
-
return;
|
|
3308
|
-
}
|
|
3309
|
-
for (const line of formatProjectRows(projects)) console.log(line);
|
|
3446
|
+
async function jiraProjects({ session, term }, query, options) {
|
|
3447
|
+
const projects = await listProjects(session, session.site, query);
|
|
3448
|
+
if (options.json) term.json(projects);
|
|
3449
|
+
else if (projects.length === 0) term.out("No matching projects.");
|
|
3450
|
+
else term.out(formatProjectRows(projects));
|
|
3310
3451
|
}
|
|
3311
3452
|
function formatProjectRows(projects) {
|
|
3312
3453
|
const width = Math.max(...projects.map((p) => p.key.length));
|
|
3313
3454
|
return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
|
|
3314
3455
|
}
|
|
3315
|
-
async function jiraStatuses(query, options) {
|
|
3316
|
-
let statuses = await listStatuses(
|
|
3456
|
+
async function jiraStatuses({ session, term }, query, options) {
|
|
3457
|
+
let statuses = await listStatuses(session, options.project);
|
|
3317
3458
|
if (query) {
|
|
3318
3459
|
const needle = query.toLowerCase();
|
|
3319
3460
|
statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
|
|
3320
3461
|
}
|
|
3321
|
-
if (options.json)
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
}
|
|
3325
|
-
if (statuses.length === 0) {
|
|
3326
|
-
console.log("No matching statuses.");
|
|
3327
|
-
return;
|
|
3328
|
-
}
|
|
3329
|
-
for (const line of formatStatusRows(statuses)) console.log(line);
|
|
3462
|
+
if (options.json) term.json(statuses);
|
|
3463
|
+
else if (statuses.length === 0) term.out("No matching statuses.");
|
|
3464
|
+
else term.out(formatStatusRows(statuses));
|
|
3330
3465
|
}
|
|
3331
3466
|
function formatStatusRows(statuses) {
|
|
3332
3467
|
const width = Math.max(...statuses.map((s) => s.name.length));
|
|
3333
3468
|
return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
|
|
3334
3469
|
}
|
|
3335
|
-
async function jiraView(arg, options) {
|
|
3336
|
-
const
|
|
3337
|
-
const
|
|
3338
|
-
await
|
|
3470
|
+
async function jiraView({ session, term }, arg, options) {
|
|
3471
|
+
const key = await resolveRef(term.ask, arg, ISSUE_REF);
|
|
3472
|
+
const lines = formatIssueView(await fetchIssue(session, session.site, key), Date.now(), options.allComments ?? false);
|
|
3473
|
+
await term.page(lines.join("\n"), { pager: options.pager });
|
|
3339
3474
|
}
|
|
3340
3475
|
const CATEGORY_COLORS = {
|
|
3341
3476
|
new: kleur.cyan,
|
|
@@ -3365,46 +3500,44 @@ function formatIssueView(issue, nowMs, allComments) {
|
|
|
3365
3500
|
...attachmentSection(issue.attachments)
|
|
3366
3501
|
];
|
|
3367
3502
|
}
|
|
3368
|
-
async function jiraCopy(arg, options) {
|
|
3369
|
-
|
|
3370
|
-
const key = await resolveRef(arg, ISSUE_REF);
|
|
3371
|
-
await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
|
|
3503
|
+
async function jiraCopy(env, arg, options) {
|
|
3504
|
+
await copyIssue(env, await resolveRef(env.term.ask, arg, ISSUE_REF), options.out);
|
|
3372
3505
|
}
|
|
3373
|
-
async function jiraUpdate(arg, options) {
|
|
3374
|
-
const
|
|
3506
|
+
async function jiraUpdate({ session, term, files }, arg, options) {
|
|
3507
|
+
const file = arg ?? await term.ask.text({
|
|
3375
3508
|
message: "Path to the issue Markdown file:",
|
|
3509
|
+
flag: "[file]",
|
|
3376
3510
|
required: true
|
|
3377
|
-
})
|
|
3378
|
-
const
|
|
3379
|
-
const
|
|
3380
|
-
|
|
3381
|
-
await runPlan(plan, options, async () => {
|
|
3511
|
+
});
|
|
3512
|
+
const src = parseIssueSource(await files.readText(file));
|
|
3513
|
+
const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
|
|
3514
|
+
await runPlan(term, plan, options, async () => {
|
|
3382
3515
|
const { current, next } = plan.headline;
|
|
3383
|
-
await updateIssue(
|
|
3516
|
+
await updateIssue(session, src.key, {
|
|
3384
3517
|
description: plan.body,
|
|
3385
3518
|
summary: next !== current ? next : void 0
|
|
3386
3519
|
});
|
|
3387
|
-
|
|
3520
|
+
term.out(`Updated ${src.key}.`);
|
|
3388
3521
|
});
|
|
3389
3522
|
}
|
|
3390
|
-
async function copyIssue(
|
|
3391
|
-
|
|
3392
|
-
await runCopy(planIssueCopy(await fetchIssue(
|
|
3523
|
+
async function copyIssue(env, key, out) {
|
|
3524
|
+
env.term.err(`Fetching ${key} ...`);
|
|
3525
|
+
await runCopy(env, planIssueCopy(await fetchIssue(env.session, env.session.site, key), out));
|
|
3393
3526
|
}
|
|
3394
3527
|
const ISSUE_REF = {
|
|
3395
3528
|
message: "Jira issue key or URL:",
|
|
3529
|
+
flag: "[issue]",
|
|
3396
3530
|
parse: parseIssueKey,
|
|
3397
3531
|
notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
|
|
3398
3532
|
};
|
|
3399
3533
|
//#endregion
|
|
3400
3534
|
//#region src/commands/jira-search.ts
|
|
3401
|
-
async function jiraSearch(query, options) {
|
|
3535
|
+
async function jiraSearch(env, query, options) {
|
|
3536
|
+
const { session, term } = env;
|
|
3402
3537
|
if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
|
|
3403
3538
|
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
3404
|
-
const auth = await requireAuth();
|
|
3405
|
-
const client = new AtlassianClient(auth);
|
|
3406
3539
|
const limit = parseLimit(options.limit);
|
|
3407
|
-
const issues = await searchIssues(
|
|
3540
|
+
const issues = await searchIssues(session, session.site, {
|
|
3408
3541
|
text: query,
|
|
3409
3542
|
project: options.project,
|
|
3410
3543
|
assignee: options.assignee,
|
|
@@ -3412,29 +3545,28 @@ async function jiraSearch(query, options) {
|
|
|
3412
3545
|
jql: options.jql,
|
|
3413
3546
|
limit
|
|
3414
3547
|
});
|
|
3415
|
-
await runSearch(formatIssueRows(issues, Date.now()), {
|
|
3548
|
+
await runSearch(term, formatIssueRows(issues, Date.now()), {
|
|
3416
3549
|
json: options.json,
|
|
3417
3550
|
copy: options.copy,
|
|
3418
3551
|
out: options.out,
|
|
3419
3552
|
empty: "No matching issues.",
|
|
3420
3553
|
footer: issues.length === limit ? searchFooter(limit) : void 0
|
|
3421
|
-
}, ISSUE_NOUN, (key) => copyIssue(
|
|
3554
|
+
}, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
|
|
3422
3555
|
}
|
|
3423
|
-
async function jiraList(options) {
|
|
3556
|
+
async function jiraList(env, options) {
|
|
3557
|
+
const { session, term } = env;
|
|
3424
3558
|
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
3425
|
-
const
|
|
3426
|
-
const client = new AtlassianClient(auth);
|
|
3427
|
-
const { issues, truncated } = await listAssignedIssues(client, auth.site, {
|
|
3559
|
+
const { issues, truncated } = await listAssignedIssues(session, session.site, {
|
|
3428
3560
|
all: options.all,
|
|
3429
3561
|
project: options.project
|
|
3430
3562
|
});
|
|
3431
|
-
await runSearch(formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
|
|
3563
|
+
await runSearch(term, formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
|
|
3432
3564
|
json: options.json,
|
|
3433
3565
|
copy: options.copy,
|
|
3434
3566
|
out: options.out,
|
|
3435
3567
|
empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
|
|
3436
3568
|
footer: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
|
|
3437
|
-
}, ISSUE_NOUN, (key) => copyIssue(
|
|
3569
|
+
}, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
|
|
3438
3570
|
}
|
|
3439
3571
|
function formatIssueRows(issues, nowMs) {
|
|
3440
3572
|
return alignedRows(issues, nowMs, (i) => ({
|
|
@@ -3457,20 +3589,20 @@ function collect(value, previous = []) {
|
|
|
3457
3589
|
}
|
|
3458
3590
|
function registerJira(jira) {
|
|
3459
3591
|
jira.description("Jira commands");
|
|
3460
|
-
jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(
|
|
3461
|
-
jira.command("statuses [query]").description("List statuses (optionally filtered by name, scoped with --project)").option("-p, --project <key>", "limit to statuses used by a project").option("--json", "output results as JSON").action(
|
|
3462
|
-
jira.command("create [project] [type]").description("Create a Jira issue, prompting for fields or taking them all as flags").option("-s, --summary <text>", "issue summary").option("-d, --description <markdown>", "description as Markdown").option("--description-file <path>", "read the description from a Markdown file").option("-a, --assignee <who>", "assignee: me, an account id, or a name to look up").option("--priority <name>", "priority name").option("-l, --label <label>", "label (repeatable, or comma separated)", collect).option("-c, --component <name>", "component (repeatable, or comma separated)", collect).option("--parent <key>", "parent issue key for subtasks").option("-f, --field <name=value>", "any other create-screen field (repeatable)", collect).option("--
|
|
3463
|
-
jira.command("fields <project> [type]").description("Show the issue types you can create in a project, or the create form for one type").option("--json", "output results as JSON").action(
|
|
3464
|
-
jira.command("view [issue]").description("Show a Jira issue (key or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(
|
|
3465
|
-
jira.command("copy [issue]").description("Copy a Jira issue (key or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(
|
|
3466
|
-
jira.command("update [file]").description("Update a Jira issue description from an edited Markdown file").option("--summary", "also push the H1 as the issue summary").option("-f, --force", "skip the stale-issue and data-loss checks").option("--dry-run", "show what would change without writing").action(
|
|
3467
|
-
jira.command("list").description("List open issues assigned to you").option("-p, --project <key>", "limit to a project").option("-a, --all", "include Done issues updated in the last 30 days").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(
|
|
3468
|
-
jira.command("search [query]").description("Search Jira issues (text query, filters, or --jql)").option("-p, --project <key>", "limit to a project").option("-a, --assignee <who>", "limit to an assignee (or 'me')").option("-s, --status <status>", "limit to a status").option("--jql <jql>", "raw JQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(
|
|
3592
|
+
jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(jiraAction(jiraProjects));
|
|
3593
|
+
jira.command("statuses [query]").description("List statuses (optionally filtered by name, scoped with --project)").option("-p, --project <key>", "limit to statuses used by a project").option("--json", "output results as JSON").action(jiraAction(jiraStatuses));
|
|
3594
|
+
jira.command("create [project] [type]").description("Create a Jira issue, prompting for fields or taking them all as flags").option("-s, --summary <text>", "issue summary").option("-d, --description <markdown>", "description as Markdown").option("--description-file <path>", "read the description from a Markdown file").option("-a, --assignee <who>", "assignee: me, an account id, or a name to look up").option("--priority <name>", "priority name").option("-l, --label <label>", "label (repeatable, or comma separated)", collect).option("-c, --component <name>", "component (repeatable, or comma separated)", collect).option("--parent <key>", "parent issue key for subtasks").option("-f, --field <name=value>", "any other create-screen field (repeatable)", collect).option("--dry-run", "print the resolved payload instead of creating").option("--json", "print the created issue as JSON").action(jiraAction(jiraCreate));
|
|
3595
|
+
jira.command("fields <project> [type]").description("Show the issue types you can create in a project, or the create form for one type").option("--json", "output results as JSON").action(jiraAction(jiraFields));
|
|
3596
|
+
jira.command("view [issue]").description("Show a Jira issue (key or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(jiraAction(jiraView));
|
|
3597
|
+
jira.command("copy [issue]").description("Copy a Jira issue (key or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(jiraAction(jiraCopy));
|
|
3598
|
+
jira.command("update [file]").description("Update a Jira issue description from an edited Markdown file").option("--summary", "also push the H1 as the issue summary").option("-f, --force", "skip the stale-issue and data-loss checks").option("--dry-run", "show what would change without writing").action(jiraAction(jiraUpdate));
|
|
3599
|
+
jira.command("list").description("List open issues assigned to you").option("-p, --project <key>", "limit to a project").option("-a, --all", "include Done issues updated in the last 30 days").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(jiraAction(jiraList));
|
|
3600
|
+
jira.command("search [query]").description("Search Jira issues (text query, filters, or --jql)").option("-p, --project <key>", "limit to a project").option("-a, --assignee <who>", "limit to an assignee (or 'me')").option("-s, --status <status>", "limit to a status").option("--jql <jql>", "raw JQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(jiraAction(jiraSearch));
|
|
3469
3601
|
return jira;
|
|
3470
3602
|
}
|
|
3471
3603
|
//#endregion
|
|
3472
3604
|
//#region package.json
|
|
3473
|
-
var version = "1.
|
|
3605
|
+
var version = "1.13.0";
|
|
3474
3606
|
//#endregion
|
|
3475
3607
|
//#region src/cli/build.ts
|
|
3476
3608
|
const PRODUCTS = {
|
|
@@ -3478,16 +3610,22 @@ const PRODUCTS = {
|
|
|
3478
3610
|
confluence: registerConfluence,
|
|
3479
3611
|
bitbucket: registerBitbucket
|
|
3480
3612
|
};
|
|
3613
|
+
const NO_INPUT = "never prompt; fail if a required value is missing";
|
|
3614
|
+
function withNoInput(command) {
|
|
3615
|
+
command.option("--no-input", NO_INPUT);
|
|
3616
|
+
for (const sub of command.commands) withNoInput(sub);
|
|
3617
|
+
return command;
|
|
3618
|
+
}
|
|
3481
3619
|
function buildAtlass() {
|
|
3482
3620
|
const program = new Command().name("atlass").description("Copy Jira issues and Confluence pages to Markdown.").version(version);
|
|
3483
3621
|
registerAuth(program.command("auth"));
|
|
3484
3622
|
registerJira(program.command("jira"));
|
|
3485
3623
|
registerConfluence(program.command("confluence").alias("conf"));
|
|
3486
3624
|
registerBitbucket(program.command("bitbucket").alias("bb"));
|
|
3487
|
-
return program;
|
|
3625
|
+
return withNoInput(program);
|
|
3488
3626
|
}
|
|
3489
3627
|
function buildStandalone(product) {
|
|
3490
|
-
return PRODUCTS[product](new Command().name(product).version(version));
|
|
3628
|
+
return withNoInput(PRODUCTS[product](new Command().name(product).version(version)));
|
|
3491
3629
|
}
|
|
3492
3630
|
//#endregion
|
|
3493
3631
|
export { buildStandalone as n, fail as r, buildAtlass as t };
|