atlass 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,33 +1,62 @@
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 kleur from "kleur";
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";
10
+ import { common, createLowlight } from "lowlight";
9
11
  import { marked } from "marked";
10
12
  import { randomUUID } from "node:crypto";
11
- import { common, createLowlight } from "lowlight";
12
- import { spawn } from "node:child_process";
13
- //#region src/cli/run.ts
14
- const SIGINT_EXIT_CODE = 130;
15
- function run(fn) {
16
- return async (...args) => {
17
- try {
18
- await fn(...args);
19
- } catch (err) {
20
- fail(err);
21
- }
22
- };
13
+ //#region src/api/http-error.ts
14
+ var HttpError = class extends Error {
15
+ status;
16
+ jira;
17
+ path;
18
+ constructor(status, message, jira = {
19
+ errorMessages: [],
20
+ errors: {}
21
+ }, path = "") {
22
+ super(message);
23
+ this.status = status;
24
+ this.jira = jira;
25
+ this.path = path;
26
+ this.name = "HttpError";
27
+ }
28
+ };
29
+ function httpError(status, path, body, loginHint) {
30
+ if (status === 401 || status === 403) return new HttpError(status, `Authentication failed (401/403). Run \`${loginHint}\` to update your token.`, void 0, path);
31
+ if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
32
+ if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
33
+ if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
34
+ if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`, jiraErrors(body));
35
+ return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`, jiraErrors(body));
23
36
  }
24
- function fail(err) {
25
- if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
26
- console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
27
- process.exit(1);
37
+ function extractErrorMessage(body) {
38
+ if (!body) return "";
39
+ const json = tryParseJson(body);
40
+ const { errorMessages, errors } = jiraErrors(body);
41
+ const fromJira = [...errorMessages, ...Object.entries(errors).map(([f, m]) => `${f}: ${m}`)];
42
+ if (fromJira.length) return fromJira.join("; ");
43
+ if (json?.message) return json.message;
44
+ if (json?.error?.message) return json.error.message;
45
+ return body.slice(0, 300);
28
46
  }
29
- function isPromptInterrupt(err) {
30
- return err instanceof Error && err.name === "ExitPromptError";
47
+ function jiraErrors(body) {
48
+ const json = tryParseJson(body);
49
+ return {
50
+ errorMessages: json?.errorMessages ?? [],
51
+ errors: json?.errors ?? {}
52
+ };
53
+ }
54
+ function tryParseJson(text) {
55
+ try {
56
+ return JSON.parse(text);
57
+ } catch {
58
+ return null;
59
+ }
31
60
  }
32
61
  //#endregion
33
62
  //#region src/api/client.ts
@@ -35,10 +64,12 @@ const DISABLE_XSRF_CHECK = { "X-Atlassian-Token": "nocheck" };
35
64
  var AtlassianClient = class {
36
65
  site;
37
66
  authHeader;
38
- constructor(auth) {
39
- this.site = auth.site;
40
- const basic = Buffer.from(`${auth.email}:${auth.token}`).toString("base64");
67
+ loginHint;
68
+ constructor(credentials, loginHint) {
69
+ this.site = credentials.site;
70
+ const basic = Buffer.from(`${credentials.email}:${credentials.token}`).toString("base64");
41
71
  this.authHeader = `Basic ${basic}`;
72
+ this.loginHint = loginHint;
42
73
  }
43
74
  async request(path, init) {
44
75
  const res = await fetch(`${this.site}${path}`, {
@@ -51,7 +82,7 @@ var AtlassianClient = class {
51
82
  });
52
83
  if (!res.ok) {
53
84
  const body = await res.text().catch(() => "");
54
- throw httpError(res.status, path, body);
85
+ throw httpError(res.status, path, body, this.loginHint);
55
86
  }
56
87
  return res;
57
88
  }
@@ -111,204 +142,539 @@ function pathAndQuery(absoluteUrl) {
111
142
  const u = new URL(absoluteUrl);
112
143
  return u.pathname + u.search;
113
144
  }
114
- var HttpError = class extends Error {
115
- status;
116
- jira;
117
- constructor(status, message, jira = {
118
- errorMessages: [],
119
- errors: {}
120
- }) {
121
- super(message);
122
- this.status = status;
123
- this.jira = jira;
124
- this.name = "HttpError";
145
+ //#endregion
146
+ //#region src/api/session.ts
147
+ const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
148
+ const ATLASSIAN_LOGIN = "atlass auth login";
149
+ const BITBUCKET_LOGIN = "atlass bitbucket login";
150
+ var BitbucketClient = class extends AtlassianClient {
151
+ workspace;
152
+ defaultRepo;
153
+ uuid;
154
+ constructor(email, token, bitbucket) {
155
+ super({
156
+ site: BITBUCKET_ORIGIN,
157
+ email,
158
+ token
159
+ }, BITBUCKET_LOGIN);
160
+ this.workspace = bitbucket.workspace;
161
+ this.defaultRepo = bitbucket.defaultRepo;
162
+ this.uuid = bitbucket.uuid;
125
163
  }
126
164
  };
127
- function httpError(status, path, body = "") {
128
- if (status === 401 || status === 403) return new HttpError(status, "Authentication failed (401/403). Run `atlass auth login` to update your token.");
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));
165
+ function sessionFor(credentials) {
166
+ return new AtlassianClient(credentials, ATLASSIAN_LOGIN);
134
167
  }
135
- function extractErrorMessage(body) {
136
- if (!body) return "";
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);
168
+ function bitbucketSessionFor(email, token, bitbucket) {
169
+ return new BitbucketClient(email, token, bitbucket);
144
170
  }
145
- function jiraErrors(body) {
146
- const json = tryParseJson(body);
171
+ async function openSession(profile) {
172
+ const config = await profile.read();
173
+ if (!config?.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
174
+ const token = await profile.token(config.email, "atlassian");
175
+ if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
176
+ return sessionFor({
177
+ site: config.site,
178
+ email: config.email,
179
+ token
180
+ });
181
+ }
182
+ async function openBitbucketSession(profile) {
183
+ const config = await profile.read();
184
+ if (!config?.email || !config.bitbucket?.workspace) throw new Error("Not logged in to Bitbucket. Run `atlass bitbucket login` first.");
185
+ const token = await profile.token(config.email, "bitbucket");
186
+ if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
187
+ return bitbucketSessionFor(config.email, token, config.bitbucket);
188
+ }
189
+ //#endregion
190
+ //#region src/files/disk.ts
191
+ async function makeRoom(path) {
192
+ await mkdir(dirname(path), { recursive: true });
193
+ }
194
+ function diskFiles() {
147
195
  return {
148
- errorMessages: json?.errorMessages ?? [],
149
- errors: json?.errors ?? {}
196
+ async readText(path) {
197
+ return readFile(path, "utf8");
198
+ },
199
+ async readBytes(path) {
200
+ const buffer = await readFile(path);
201
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
202
+ },
203
+ async size(path) {
204
+ return (await stat(path)).size;
205
+ },
206
+ async writeText(path, text) {
207
+ await makeRoom(path);
208
+ await writeFile(path, text, "utf8");
209
+ },
210
+ async writeBytes(path, bytes) {
211
+ await makeRoom(path);
212
+ await writeFile(path, bytes);
213
+ }
150
214
  };
151
215
  }
152
- function tryParseJson(text) {
153
- try {
154
- return JSON.parse(text);
155
- } catch {
156
- return null;
157
- }
158
- }
159
216
  //#endregion
160
- //#region src/config.ts
217
+ //#region src/profile/disk.ts
218
+ const SERVICE = "atlass";
161
219
  function configDir() {
162
- return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "atlass");
220
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
221
+ return join(base, "atlass");
163
222
  }
164
223
  function configPath() {
165
224
  return join(configDir(), "config.json");
166
225
  }
167
- async function readConfig() {
168
- try {
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
- }
179
- }
180
- async function writeConfig(config) {
181
- await mkdir(configDir(), { recursive: true });
182
- await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
226
+ function entry(email, kind) {
227
+ return new Entry(SERVICE, kind === "bitbucket" ? `${email}:bitbucket` : email);
183
228
  }
184
- async function clearConfig() {
185
- await rm(configPath(), { force: true });
186
- }
187
- function siteOrigin(input) {
188
- let value = input.trim();
189
- if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
190
- return new URL(value).origin;
229
+ function diskProfile() {
230
+ return {
231
+ async read() {
232
+ try {
233
+ const parsed = JSON.parse(await readFile(configPath(), "utf8"));
234
+ return {
235
+ site: parsed.site,
236
+ email: parsed.email,
237
+ bitbucket: parsed.bitbucket
238
+ };
239
+ } catch {
240
+ return null;
241
+ }
242
+ },
243
+ async write(config) {
244
+ await mkdir(configDir(), { recursive: true });
245
+ await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
246
+ },
247
+ async clear() {
248
+ await rm(configPath(), { force: true });
249
+ },
250
+ async token(email, kind) {
251
+ return entry(email, kind).getPassword();
252
+ },
253
+ async setToken(email, kind, token) {
254
+ entry(email, kind).setPassword(token);
255
+ },
256
+ async deleteToken(email, kind) {
257
+ try {
258
+ entry(email, kind).deleteCredential();
259
+ } catch {
260
+ return;
261
+ }
262
+ }
263
+ };
191
264
  }
192
265
  //#endregion
193
- //#region src/credentials.ts
194
- const SERVICE = "atlass";
195
- const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
196
- function entry(key) {
197
- return new Entry(SERVICE, key);
266
+ //#region src/terminal/pager.ts
267
+ const DEFAULT_ROWS = 24;
268
+ const DEFAULT_COLUMNS = 80;
269
+ const DEFAULT_PAGER = "less";
270
+ const DEFAULT_LESS = "FRX";
271
+ function renderedHeight(text, columns) {
272
+ const width = Math.max(1, columns);
273
+ return text.split("\n").map((line) => Math.max(1, Math.ceil(stripVTControlCharacters(line).length / width))).reduce((sum, rows) => sum + rows, 0);
198
274
  }
199
- function deleteEntryIfPresent(key) {
200
- try {
201
- entry(key).deleteCredential();
202
- } catch {
203
- return;
204
- }
275
+ function shouldPage(text, screen) {
276
+ if (!screen.isTTY) return false;
277
+ const rows = screen.rows ?? DEFAULT_ROWS;
278
+ return renderedHeight(text, screen.columns ?? DEFAULT_COLUMNS) > rows;
205
279
  }
206
- function saveToken(email, token) {
207
- entry(email).setPassword(token);
280
+ function pagerCommand(env) {
281
+ const [command = DEFAULT_PAGER, ...args] = (env["PAGER"] ?? "").trim().split(/\s+/).filter(Boolean);
282
+ return {
283
+ command,
284
+ args,
285
+ env: {
286
+ ...env,
287
+ LESS: env["LESS"] ?? DEFAULT_LESS
288
+ }
289
+ };
208
290
  }
209
- function readToken(email) {
210
- return entry(email).getPassword();
291
+ async function tryPager(text, options = {}) {
292
+ if (!shouldPage(text, options.screen ?? process.stdout)) return false;
293
+ return pipeToPager(text, pagerCommand(options.env ?? process.env));
211
294
  }
212
- function deleteToken(email) {
213
- deleteEntryIfPresent(email);
295
+ function pipeToPager(text, pager) {
296
+ return new Promise((resolve) => {
297
+ const child = spawn(pager.command, pager.args, {
298
+ stdio: [
299
+ "pipe",
300
+ "inherit",
301
+ "inherit"
302
+ ],
303
+ env: pager.env
304
+ });
305
+ child.once("error", () => resolve(false));
306
+ child.once("close", () => resolve(true));
307
+ child.stdin.once("error", () => {});
308
+ child.stdin.end(`${text}\n`);
309
+ });
214
310
  }
215
- function bitbucketKey(email) {
216
- return `${email}:bitbucket`;
311
+ //#endregion
312
+ //#region src/terminal.ts
313
+ var NotInteractiveError = class extends Error {
314
+ constructor(spec) {
315
+ super(spec.flag ? `Cannot prompt without a terminal. Pass ${spec.flag}.` : `Cannot prompt without a terminal: ${spec.message}`);
316
+ this.name = "NotInteractiveError";
317
+ }
318
+ };
319
+ //#endregion
320
+ //#region src/terminal/prompts.ts
321
+ const inquirerPrompts = {
322
+ text: (spec) => input({
323
+ message: spec.message,
324
+ required: spec.required,
325
+ default: spec.default,
326
+ validate: spec.validate
327
+ }),
328
+ secret: (spec) => password({
329
+ message: spec.message,
330
+ mask: spec.mask
331
+ }),
332
+ confirm: (spec) => confirm({
333
+ message: spec.message,
334
+ default: spec.default
335
+ }),
336
+ pick: (spec) => select({
337
+ message: spec.message,
338
+ choices: spec.choices,
339
+ default: spec.default,
340
+ pageSize: spec.pageSize
341
+ }),
342
+ pickMany: (spec) => checkbox({
343
+ message: spec.message,
344
+ choices: spec.choices,
345
+ required: spec.required,
346
+ pageSize: spec.pageSize
347
+ }),
348
+ edit: (spec) => editor({
349
+ message: spec.message,
350
+ postfix: spec.postfix,
351
+ default: spec.default,
352
+ validate: spec.validate
353
+ }),
354
+ searchPick: (spec) => search({
355
+ message: spec.message,
356
+ source: (term) => spec.source(term),
357
+ pageSize: spec.pageSize
358
+ })
359
+ };
360
+ async function refuse(spec) {
361
+ throw new NotInteractiveError(spec);
362
+ }
363
+ const refusingPrompts = {
364
+ text: refuse,
365
+ secret: refuse,
366
+ confirm: refuse,
367
+ pick: refuse,
368
+ pickMany: refuse,
369
+ edit: refuse,
370
+ searchPick: refuse
371
+ };
372
+ //#endregion
373
+ //#region src/terminal/tty.ts
374
+ const DEFAULT_WIDTH = 80;
375
+ function ttyTerminal(interactive = true) {
376
+ return {
377
+ interactive,
378
+ out(value) {
379
+ if (Array.isArray(value) && value.length === 0) return;
380
+ console.log(Array.isArray(value) ? value.join("\n") : value);
381
+ },
382
+ err(value) {
383
+ console.error(value);
384
+ },
385
+ json(value) {
386
+ console.log(JSON.stringify(value, null, 2));
387
+ },
388
+ async page(text, options = {}) {
389
+ if (options.pager !== false && await tryPager(text)) return;
390
+ console.log(text);
391
+ },
392
+ get width() {
393
+ return process.stdout.columns ?? DEFAULT_WIDTH;
394
+ },
395
+ ask: interactive ? inquirerPrompts : refusingPrompts
396
+ };
217
397
  }
218
- function saveBitbucketToken(email, token) {
219
- entry(bitbucketKey(email)).setPassword(token);
398
+ //#endregion
399
+ //#region src/terminal/open.ts
400
+ function openTerminal(args, stdin) {
401
+ return ttyTerminal(stdin.isTTY === true && !refusedInput(args.at(-1)));
220
402
  }
221
- function readBitbucketToken(email) {
222
- return entry(bitbucketKey(email)).getPassword();
403
+ function refusedInput(last) {
404
+ for (let command = asCommand(last); command; command = command.parent) if (command.opts()["input"] === false) return true;
405
+ return false;
223
406
  }
224
- function deleteBitbucketToken(email) {
225
- deleteEntryIfPresent(bitbucketKey(email));
407
+ function asCommand(value) {
408
+ const command = value;
409
+ return typeof command?.opts === "function" ? command : null;
226
410
  }
227
- async function requireAuth() {
228
- const config = await readConfig();
229
- if (!config || !config.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
230
- const token = readToken(config.email);
231
- if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
232
- return {
233
- site: config.site,
234
- email: config.email,
235
- token
411
+ //#endregion
412
+ //#region src/cli/run.ts
413
+ const SIGINT_EXIT_CODE = 130;
414
+ function bareAction(fn) {
415
+ return guarded((env, args) => fn(env, ...args));
416
+ }
417
+ function jiraAction(fn) {
418
+ return guarded(async (env, args) => fn({
419
+ ...env,
420
+ session: await openSession(env.profile)
421
+ }, ...args));
422
+ }
423
+ function bitbucketAction(fn) {
424
+ return guarded(async (env, args) => fn({
425
+ ...env,
426
+ session: await openBitbucketSession(env.profile)
427
+ }, ...args));
428
+ }
429
+ function guarded(fn) {
430
+ return async (...args) => {
431
+ try {
432
+ await fn({
433
+ term: openTerminal(args, process.stdin),
434
+ files: diskFiles(),
435
+ profile: diskProfile()
436
+ }, args);
437
+ } catch (err) {
438
+ fail(err);
439
+ }
236
440
  };
237
441
  }
238
- async function requireBitbucketAuth() {
239
- const config = await readConfig();
240
- if (!config || !config.email || !config.bitbucket?.workspace) throw new Error("Not logged in to Bitbucket. Run `atlass bitbucket login` first.");
241
- const token = readBitbucketToken(config.email);
242
- if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
243
- return {
244
- site: BITBUCKET_ORIGIN,
245
- email: config.email,
246
- token,
247
- workspace: config.bitbucket.workspace,
248
- defaultRepo: config.bitbucket.defaultRepo,
249
- uuid: config.bitbucket.uuid
250
- };
442
+ function fail(err) {
443
+ if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
444
+ ttyTerminal().err(`Error: ${err instanceof Error ? err.message : String(err)}`);
445
+ process.exit(1);
446
+ }
447
+ function isPromptInterrupt(err) {
448
+ return err instanceof Error && err.name === "ExitPromptError";
251
449
  }
252
450
  //#endregion
253
- //#region src/commands/auth.ts
254
- async function login() {
255
- const site = siteOrigin(await input({
256
- message: "Atlassian site (e.g. acme.atlassian.net):",
257
- required: true
258
- }));
259
- const email = await input({
260
- message: "Account email:",
261
- required: true
262
- });
263
- const token = await password({
264
- message: "API token (from id.atlassian.com/manage-profile/security/api-tokens):",
265
- mask: true
266
- });
267
- const me = await new AtlassianClient({
268
- site,
269
- email,
270
- token
271
- }).getJson("/rest/api/3/myself");
272
- await writeConfig({
273
- ...await readConfig() ?? {},
274
- site,
275
- email
276
- });
277
- saveToken(email, token);
278
- console.log(`Logged in as ${me.displayName} on ${site}.`);
451
+ //#region src/util/parse.ts
452
+ function parseIssueKey(input) {
453
+ const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
454
+ return match ? match[0] : null;
279
455
  }
280
- async function logout() {
281
- const config = await readConfig();
282
- if (config?.email) deleteToken(config.email);
283
- const bitbucketLogin = config?.bitbucket && config.email ? {
284
- email: config.email,
285
- bitbucket: config.bitbucket
286
- } : null;
287
- if (bitbucketLogin) await writeConfig(bitbucketLogin);
288
- else await clearConfig();
289
- console.log("Logged out. Credentials removed.");
456
+ function parsePageId(input) {
457
+ const trimmed = input.trim();
458
+ if (/^\d+$/.test(trimmed)) return trimmed;
459
+ const fromPath = trimmed.match(/\/pages\/(\d+)/);
460
+ if (fromPath) return fromPath[1];
461
+ const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
462
+ if (fromQuery) return fromQuery[1];
463
+ return null;
290
464
  }
291
- async function status() {
292
- const config = await readConfig();
293
- if (!config || !config.site || !config.email) {
294
- console.log("Not logged in. Run `atlass auth login`.");
295
- return;
465
+ const PULL_REQUEST_HOSTS = ["bitbucket.org", "www.bitbucket.org"];
466
+ const PULL_REQUEST_PATH = /^\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)(?:\/|$)/;
467
+ function parsePullRequestRef(input) {
468
+ const trimmed = input.trim();
469
+ const bare = trimmed.replace(/^#/, "");
470
+ if (/^\d+$/.test(bare)) return { id: Number.parseInt(bare, 10) };
471
+ if (!/^https?:\/\//i.test(trimmed)) return null;
472
+ const url = parseUrl(trimmed);
473
+ if (!url || !PULL_REQUEST_HOSTS.includes(url.hostname.toLowerCase())) return null;
474
+ const match = PULL_REQUEST_PATH.exec(url.pathname);
475
+ if (!match) return null;
476
+ const [, workspace = "", repo = "", id = ""] = match;
477
+ return {
478
+ id: Number.parseInt(id, 10),
479
+ repo: {
480
+ workspace: decodeURIComponent(workspace),
481
+ repo: decodeURIComponent(repo)
482
+ }
483
+ };
484
+ }
485
+ function parseUrl(value) {
486
+ try {
487
+ return new URL(value);
488
+ } catch {
489
+ return null;
296
490
  }
297
- const hasToken = readToken(config.email) !== null;
298
- console.log(`Site: ${config.site}`);
299
- console.log(`Email: ${config.email}`);
300
- console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
491
+ }
492
+ function isExternalHref(href) {
493
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
494
+ }
495
+ function resolveRepo(flag, config) {
496
+ if (flag) {
497
+ if (flag.includes("/")) {
498
+ const parts = flag.split("/");
499
+ const [workspace, repo] = parts;
500
+ if (parts.length !== 2 || !workspace || !repo) throw new Error(`Invalid --repo "${flag}". Expected "workspace/slug" or "slug".`);
501
+ return {
502
+ workspace,
503
+ repo
504
+ };
505
+ }
506
+ if (!config.workspace) throw new Error(`No Bitbucket workspace configured. Pass --repo workspace/slug or run \`atlass bitbucket login\`.`);
507
+ return {
508
+ workspace: config.workspace,
509
+ repo: flag
510
+ };
511
+ }
512
+ if (config.workspace && config.defaultRepo) return {
513
+ workspace: config.workspace,
514
+ repo: config.defaultRepo
515
+ };
516
+ throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
517
+ }
518
+ const DEFAULT_LIMIT = 25;
519
+ const MAX_LIMIT = 100;
520
+ function parseLimit(value) {
521
+ if (!value) return DEFAULT_LIMIT;
522
+ const n = Number.parseInt(value, 10);
523
+ if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
524
+ return Math.min(n, MAX_LIMIT);
525
+ }
526
+ function siteOrigin(input) {
527
+ let value = input.trim();
528
+ if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
529
+ return new URL(value).origin;
530
+ }
531
+ //#endregion
532
+ //#region src/commands/auth.ts
533
+ async function login({ term, profile }) {
534
+ const site = siteOrigin(await term.ask.text({
535
+ message: "Atlassian site (e.g. acme.atlassian.net):",
536
+ required: true
537
+ }));
538
+ const email = await term.ask.text({
539
+ message: "Account email:",
540
+ required: true
541
+ });
542
+ const token = await term.ask.secret({
543
+ message: "API token (from id.atlassian.com/manage-profile/security/api-tokens):",
544
+ mask: true
545
+ });
546
+ const me = await sessionFor({
547
+ site,
548
+ email,
549
+ token
550
+ }).getJson("/rest/api/3/myself");
551
+ const existing = await profile.read() ?? {};
552
+ await profile.write({
553
+ ...existing,
554
+ site,
555
+ email
556
+ });
557
+ await profile.setToken(email, "atlassian", token);
558
+ term.out(`Logged in as ${me.displayName} on ${site}.`);
559
+ }
560
+ async function logout({ term, profile }) {
561
+ const config = await profile.read();
562
+ if (config?.email) await profile.deleteToken(config.email, "atlassian");
563
+ const bitbucketLogin = config?.bitbucket && config.email ? {
564
+ email: config.email,
565
+ bitbucket: config.bitbucket
566
+ } : null;
567
+ if (bitbucketLogin) await profile.write(bitbucketLogin);
568
+ else await profile.clear();
569
+ term.out("Logged out. Credentials removed.");
570
+ }
571
+ async function status({ term, profile }) {
572
+ const config = await profile.read();
573
+ if (!config || !config.site || !config.email) {
574
+ term.out("Not logged in. Run `atlass auth login`.");
575
+ return;
576
+ }
577
+ const hasToken = await profile.token(config.email, "atlassian") !== null;
578
+ term.out([
579
+ `Site: ${config.site}`,
580
+ `Email: ${config.email}`,
581
+ `Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`
582
+ ]);
301
583
  }
302
584
  //#endregion
303
585
  //#region src/cli/auth.ts
304
586
  function registerAuth(auth) {
305
587
  auth.description("Manage Atlassian credentials");
306
- auth.command("login").description("Store site, email, and API token").action(run(login));
307
- auth.command("logout").description("Remove stored credentials").action(run(logout));
308
- auth.command("status").description("Show the current login").action(run(status));
588
+ auth.command("login").description("Store site, email, and API token").action(bareAction(login));
589
+ auth.command("logout").description("Remove stored credentials").action(bareAction(logout));
590
+ auth.command("status").description("Show the current login").action(bareAction(status));
309
591
  return auth;
310
592
  }
311
593
  //#endregion
594
+ //#region src/api/bitbucket-user.ts
595
+ async function fetchCurrentUserUuid(client) {
596
+ const user = await client.getJson("/2.0/user");
597
+ if (!user.uuid) throw new Error("Bitbucket did not return an account uuid for the current user.");
598
+ return user.uuid;
599
+ }
600
+ //#endregion
601
+ //#region src/commands/bitbucket-auth.ts
602
+ async function bitbucketLogin({ term, profile }) {
603
+ const existing = await profile.read() ?? {};
604
+ const email = existing.email ?? await term.ask.text({
605
+ message: "Account email:",
606
+ required: true
607
+ });
608
+ const workspace = (await term.ask.text({
609
+ message: "Bitbucket workspace (e.g. acme):",
610
+ required: true
611
+ })).trim();
612
+ const defaultRepo = (await term.ask.text({ message: "Default repo slug (optional):" })).trim() || void 0;
613
+ const token = await term.ask.secret({
614
+ message: "Bitbucket API token (needs pipeline, pull request, account, and workspace read scopes):",
615
+ mask: true
616
+ });
617
+ const session = bitbucketSessionFor(email, token, { workspace });
618
+ const ws = await verifyWorkspace(session, workspace);
619
+ const uuid = await fetchCurrentUserUuid(session).catch(() => void 0);
620
+ await profile.write({
621
+ ...existing,
622
+ email,
623
+ bitbucket: {
624
+ workspace,
625
+ ...defaultRepo ? { defaultRepo } : {},
626
+ ...uuid ? { uuid } : {}
627
+ }
628
+ });
629
+ await profile.setToken(email, "bitbucket", token);
630
+ term.out(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
631
+ }
632
+ async function bitbucketLogout({ term, profile }) {
633
+ const config = await profile.read();
634
+ if (config?.email) await profile.deleteToken(config.email, "bitbucket");
635
+ const jiraLogin = config?.site && config.email ? {
636
+ site: config.site,
637
+ email: config.email
638
+ } : null;
639
+ if (jiraLogin) await profile.write(jiraLogin);
640
+ else await profile.clear();
641
+ term.out("Logged out of Bitbucket. Credentials removed.");
642
+ }
643
+ async function bitbucketStatus({ term, profile }) {
644
+ const config = await profile.read();
645
+ if (!config?.email || !config.bitbucket?.workspace) {
646
+ term.out("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
647
+ return;
648
+ }
649
+ const hasToken = await profile.token(config.email, "bitbucket") !== null;
650
+ term.out([
651
+ `Workspace: ${config.bitbucket.workspace}`,
652
+ `Email: ${config.email}`,
653
+ `Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`,
654
+ `Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`
655
+ ]);
656
+ }
657
+ async function rememberBitbucketUuid(profile, uuid) {
658
+ const config = await profile.read();
659
+ if (!config?.bitbucket) return;
660
+ await profile.write({
661
+ ...config,
662
+ bitbucket: {
663
+ ...config.bitbucket,
664
+ uuid
665
+ }
666
+ });
667
+ }
668
+ async function verifyWorkspace(transport, workspace) {
669
+ try {
670
+ return await transport.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
671
+ } catch (err) {
672
+ 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.`);
673
+ if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
674
+ throw err;
675
+ }
676
+ }
677
+ //#endregion
312
678
  //#region src/api/bitbucket-repo.ts
313
679
  const BITBUCKET_WEB_ORIGIN = "https://bitbucket.org";
314
680
  function repoPath(ref, resource) {
@@ -322,13 +688,34 @@ async function* walkPages(client, firstPath) {
322
688
  path = page.next ? pathAndQuery(page.next) : null;
323
689
  }
324
690
  }
325
- async function collectPages(client, first, limit, map) {
326
- const out = [];
327
- for await (const value of walkPages(client, first)) {
328
- out.push(map(value));
329
- if (out.length >= limit) break;
691
+ async function collectCapped(client, first, cap, map) {
692
+ const items = [];
693
+ let total;
694
+ let path = first;
695
+ while (path) {
696
+ const page = await client.getJson(path);
697
+ total ??= page.size;
698
+ for (const value of page.values ?? []) {
699
+ items.push(map(value));
700
+ if (items.length >= cap) return {
701
+ items,
702
+ total,
703
+ truncated: hasMore(page, total, cap)
704
+ };
705
+ }
706
+ path = page.next ? pathAndQuery(page.next) : null;
330
707
  }
331
- return out;
708
+ return {
709
+ items,
710
+ total: total ?? items.length,
711
+ truncated: false
712
+ };
713
+ }
714
+ function hasMore(page, total, cap) {
715
+ return total === void 0 ? Boolean(page.next) : total > cap;
716
+ }
717
+ async function collectPages(client, first, limit, map) {
718
+ return (await collectCapped(client, first, limit, map)).items;
332
719
  }
333
720
  //#endregion
334
721
  //#region src/api/bitbucket-pull-requests.ts
@@ -402,100 +789,115 @@ function toPullRequestSummary(ref, pr) {
402
789
  async function listPullRequests(client, ref, params) {
403
790
  return collectPages(client, `${repoPath(ref, "pullrequests")}?${pullRequestsQuery(params)}`, params.limit, (value) => toPullRequestSummary(ref, value));
404
791
  }
405
- //#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;
792
+ const PAGELEN = 100;
793
+ const RESOLUTION_FIELDS = "fields=%2Bvalues.resolution.*";
794
+ function pullRequestPath(ref, id, resource) {
795
+ const base = `${repoPath(ref, "pullrequests")}/${id}`;
796
+ return resource ? `${base}/${resource}` : base;
411
797
  }
412
- //#endregion
413
- //#region src/api/bitbucket-pipelines.ts
414
- const BUILD_NUMBER_SCAN_LIMIT = 1e3;
415
- function pipelineStatus(state) {
416
- if (!state) return "";
417
- if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
418
- return state.name ?? "";
798
+ function toPullRequestDetail(ref, value) {
799
+ return {
800
+ id: value.id,
801
+ title: value.title ?? "",
802
+ description: value.description ?? "",
803
+ state: value.state ?? "",
804
+ draft: value.draft ?? false,
805
+ author: value.author?.display_name ?? "",
806
+ sourceBranch: value.source?.branch?.name ?? "",
807
+ destinationBranch: value.destination?.branch?.name ?? "",
808
+ closedBy: value.closed_by?.display_name ?? "",
809
+ createdOn: value.created_on ?? "",
810
+ updatedOn: value.updated_on ?? "",
811
+ url: pullRequestUrl(ref, value.id),
812
+ reviewers: toReviewers(value),
813
+ participants: toParticipants(value)
814
+ };
419
815
  }
420
- function pipelinesQuery(limit) {
421
- return new URLSearchParams({
422
- sort: "-created_on",
423
- pagelen: String(Math.min(Math.max(limit, 1), 100))
424
- }).toString();
816
+ function toParticipants(value) {
817
+ const rows = /* @__PURE__ */ new Map();
818
+ const users = [value.author, ...(value.participants ?? []).map((entry) => entry.user)];
819
+ for (const user of users) {
820
+ if (!user?.account_id || !user.display_name) continue;
821
+ rows.set(user.account_id, {
822
+ accountId: user.account_id,
823
+ name: user.display_name
824
+ });
825
+ }
826
+ return [...rows.values()];
425
827
  }
426
- function wallClockSeconds(startOn, endOn) {
427
- if (!startOn || !endOn) return null;
428
- const start = Date.parse(startOn);
429
- const end = Date.parse(endOn);
430
- if (Number.isNaN(start) || Number.isNaN(end)) return null;
431
- return Math.floor((end - start) / 1e3);
828
+ function toReviewers(value) {
829
+ const rows = /* @__PURE__ */ new Map();
830
+ for (const requested of value.reviewers ?? []) rows.set(principalKey(requested), {
831
+ name: requested.display_name ?? "",
832
+ state: ""
833
+ });
834
+ for (const participant of value.participants ?? []) {
835
+ const state = participantState(participant);
836
+ if (participant.role !== "REVIEWER" && !state) continue;
837
+ rows.set(principalKey(participant.user ?? {}), {
838
+ name: participant.user?.display_name ?? "",
839
+ state
840
+ });
841
+ }
842
+ return [...rows.values()];
432
843
  }
433
- function shortHash(hash) {
434
- return hash?.slice(0, 7) ?? "";
844
+ function participantState(participant) {
845
+ if (participant.state) return participant.state.toUpperCase();
846
+ return participant.approved ? "APPROVED" : "";
435
847
  }
436
- function pipelineUrl(ref, buildNumber) {
437
- return `${BITBUCKET_WEB_ORIGIN}/${ref.workspace}/${ref.repo}/pipelines/results/${buildNumber}`;
848
+ function principalKey(user) {
849
+ return user.uuid ?? user.display_name ?? "";
438
850
  }
439
- function toPipelineSummary(ref, p) {
851
+ function toComment(value) {
852
+ if (value.deleted) return null;
853
+ const body = value.content?.raw ?? "";
854
+ if (!body.trim()) return null;
440
855
  return {
441
- buildNumber: p.build_number,
442
- status: pipelineStatus(p.state),
443
- ref: p.target?.ref_name ?? "",
444
- commit: shortHash(p.target?.commit?.hash),
445
- durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
446
- createdOn: p.created_on ?? "",
447
- creator: p.creator?.display_name ?? "",
448
- uuid: p.uuid,
449
- url: pipelineUrl(ref, p.build_number)
856
+ id: value.id,
857
+ parentId: value.parent?.id ?? null,
858
+ author: value.user?.display_name ?? "",
859
+ created: value.created_on ?? "",
860
+ body,
861
+ anchor: inlineAnchor(value),
862
+ resolved: toResolution(value.resolution)
450
863
  };
451
864
  }
452
- function toPipelineDetail(ref, p) {
865
+ function toResolution(value) {
866
+ if (!value?.created_on) return null;
453
867
  return {
454
- ...toPipelineSummary(ref, p),
455
- repo: p.repository?.full_name ?? "",
456
- trigger: p.trigger?.name ?? ""
868
+ by: value.user?.display_name ?? "",
869
+ at: value.created_on
457
870
  };
458
871
  }
459
- function toStepSummary(step) {
872
+ function inlineAnchor(value) {
873
+ const path = value.inline?.path;
874
+ if (!path) return "";
875
+ const line = value.inline?.to ?? value.inline?.from;
876
+ return line == null ? path : `${path}:${line}`;
877
+ }
878
+ function toChangedFile(value) {
879
+ const before = value.old?.path ?? "";
880
+ const after = value.new?.path ?? "";
460
881
  return {
461
- name: step.name ?? "",
462
- status: pipelineStatus(step.state),
463
- durationSeconds: wallClockSeconds(step.started_on, step.completed_on)
882
+ path: before && after && before !== after ? `${before} -> ${after}` : after || before,
883
+ status: value.status ?? "",
884
+ added: value.lines_added ?? 0,
885
+ removed: value.lines_removed ?? 0
464
886
  };
465
887
  }
466
- function pipelinesPath(ref) {
467
- return repoPath(ref, "pipelines");
468
- }
469
- async function listPipelines(client, ref, limit) {
470
- return collectPages(client, `${pipelinesPath(ref)}?${pipelinesQuery(limit)}`, limit, (value) => toPipelineSummary(ref, value));
471
- }
472
- async function getPipeline(client, ref, buildNumber) {
473
- const found = await fetchByBuildNumber(client, ref, buildNumber) ?? await findInRecentRuns(client, ref, buildNumber);
474
- if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${BUILD_NUMBER_SCAN_LIMIT} most recent runs. It may be too old.`);
475
- return toPipelineDetail(ref, found);
476
- }
477
- async function fetchByBuildNumber(client, ref, buildNumber) {
478
- try {
479
- return await client.getJson(`${pipelinesPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
480
- } catch (err) {
481
- if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
482
- throw err;
483
- }
888
+ async function getPullRequest(client, ref, id) {
889
+ return toPullRequestDetail(ref, await client.getJson(pullRequestPath(ref, id)));
484
890
  }
485
- async function findInRecentRuns(client, ref, buildNumber) {
486
- const first = `${pipelinesPath(ref)}?${pipelinesQuery(100)}`;
487
- let scanned = 0;
488
- for await (const pipeline of walkPages(client, first)) {
489
- if (pipeline.build_number === buildNumber) return pipeline;
490
- if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
491
- }
492
- return null;
891
+ async function listComments(client, ref, id) {
892
+ const page = await collectCapped(client, `${pullRequestPath(ref, id, "comments")}?pagelen=${PAGELEN}&${RESOLUTION_FIELDS}`, 200, toComment);
893
+ const items = page.items.filter((comment) => comment !== null).sort((a, b) => a.created.localeCompare(b.created));
894
+ return {
895
+ ...page,
896
+ items
897
+ };
493
898
  }
494
- async function listSteps(client, ref, pipelineId) {
495
- const out = [];
496
- const first = `${pipelinesPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
497
- for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
498
- return out;
899
+ async function listChangedFiles(client, ref, id) {
900
+ return collectCapped(client, `${pullRequestPath(ref, id, "diffstat")}?pagelen=${PAGELEN}`, 50, toChangedFile);
499
901
  }
500
902
  //#endregion
501
903
  //#region src/util/format.ts
@@ -507,13 +909,19 @@ function formatDuration(seconds) {
507
909
  if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
508
910
  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
509
911
  }
912
+ const pad = (n) => String(n).padStart(2, "0");
913
+ function formatDate$1(iso) {
914
+ const date = new Date(iso);
915
+ if (Number.isNaN(date.getTime())) return iso;
916
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
917
+ }
510
918
  function formatDateTime(iso) {
511
919
  const date = new Date(iso);
512
920
  if (Number.isNaN(date.getTime())) return iso;
513
- return date.toISOString().replace("T", " ").slice(0, 16);
921
+ return `${formatDate$1(iso)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
514
922
  }
515
923
  const MINUTE = 60;
516
- const HOUR = 60 * MINUTE;
924
+ const HOUR = 3600;
517
925
  const DAY = 24 * HOUR;
518
926
  function relativeTime(iso, nowMs) {
519
927
  if (!iso) return "-";
@@ -529,254 +937,72 @@ function relativeTime(iso, nowMs) {
529
937
  return `${Math.floor(days / 365)}y ago`;
530
938
  }
531
939
  //#endregion
532
- //#region src/util/link.ts
533
- const OSC8 = "\x1B]8;;";
534
- const BEL = "\x07";
535
- function hyperlink(text, url) {
536
- if (!kleur.enabled || !url) return text;
537
- return `${OSC8}${url}${BEL}${text}${OSC8}${BEL}`;
538
- }
539
- //#endregion
540
- //#region src/commands/search-run.ts
541
- function alignedRows(items, nowMs, cells) {
542
- const rows = items.map((item) => {
543
- const cell = cells(item);
544
- return {
545
- ...cell,
546
- age: relativeTime(cell.timestamp, nowMs),
547
- json: { ...item }
548
- };
549
- });
550
- const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
551
- const idWidth = width((row) => row.id);
552
- const labelWidth = width((row) => row.label);
553
- const ageWidth = width((row) => row.age);
554
- return rows.map((row) => ({
555
- id: row.id,
556
- url: row.url,
557
- fixedColumns: `${row.id.padEnd(idWidth)} ${row.color(row.label.padEnd(labelWidth))} ${row.age.padEnd(ageWidth)}`,
558
- freeText: row.text,
559
- json: row.json
940
+ //#region src/commands/bitbucket-comments.ts
941
+ const MENTION = /@\{([^}]+)\}/g;
942
+ function renderedPrComments(comments, participants) {
943
+ const names = new Map(participants.map((person) => [person.accountId, person.name]));
944
+ return threadsOf(comments).map((thread) => renderThread(thread, names));
945
+ }
946
+ function threadsOf(comments) {
947
+ const byId = new Map(comments.map((comment) => [comment.id, comment]));
948
+ const threads = /* @__PURE__ */ new Map();
949
+ for (const comment of comments) {
950
+ const root = rootOf(comment, byId);
951
+ const thread = threads.get(root.id);
952
+ if (thread) thread.push(comment);
953
+ else threads.set(root.id, [comment]);
954
+ }
955
+ return [...threads.values()];
956
+ }
957
+ function rootOf(comment, byId) {
958
+ const seen = /* @__PURE__ */ new Set([comment.id]);
959
+ let current = comment;
960
+ while (current.parentId !== null) {
961
+ const parent = byId.get(current.parentId);
962
+ if (!parent || seen.has(parent.id)) break;
963
+ seen.add(parent.id);
964
+ current = parent;
965
+ }
966
+ return current;
967
+ }
968
+ function renderThread(thread, names) {
969
+ const rows = thread.map((comment, index) => ({
970
+ author: comment.author,
971
+ created: comment.created,
972
+ markdown: resolveMentions(comment.body, names),
973
+ ...index === 0 ? { anchor: comment.anchor } : {}
560
974
  }));
561
- }
562
- function printRows(rows, options) {
563
- if (options.json) {
564
- console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
565
- return;
566
- }
567
- if (rows.length === 0) {
568
- console.log(options.empty);
569
- return;
570
- }
571
- for (const row of rows) console.log(formatRow(row));
572
- if (options.footer) console.log(options.footer);
573
- }
574
- const COPY_CONCURRENCY = 5;
575
- async function runSearch(rows, options, noun, copyOne) {
576
- if (!(options.copy && !options.json && rows.length > 0)) {
577
- printRows(rows, options);
578
- return;
579
- }
580
- 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);
582
- }
583
- function searchFooter(limit) {
584
- return `
585
- Showing first ${limit}; refine with flags or raise --limit.`;
586
- }
587
- async function copySelected(rows, noun, copyOne) {
588
- if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
589
- const selected = await checkbox({
590
- message: `Select ${noun.plural} to copy:`,
591
- choices: rows.map((r) => ({
592
- name: formatRow(r),
593
- value: r.id
594
- })),
595
- pageSize: 20
596
- });
597
- if (selected.length === 0) {
598
- console.log("Nothing selected.");
599
- return;
600
- }
601
- let copied = 0;
602
- const failures = [];
603
- const queue = [...selected];
604
- async function worker() {
605
- for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
606
- await copyOne(id);
607
- copied++;
608
- } catch (err) {
609
- failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
610
- }
611
- }
612
- await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
613
- const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
614
- if (failures.length === 0) console.log(summary);
615
- else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
616
- }
617
- function formatRow(row, width = process.stdout.columns ?? 80) {
618
- const room = width - stripVTControlCharacters(row.fixedColumns).length - 2;
619
- const text = room > 0 ? truncate(row.freeText, room) : "";
620
- const columns = hyperlink(row.id, row.url) + row.fixedColumns.slice(row.id.length);
621
- return text ? `${columns} ${hyperlink(text, row.url)}` : columns;
622
- }
623
- function truncate(text, max) {
624
- const clean = text.replace(/\s+/g, " ").trim();
625
- return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
626
- }
627
- //#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);
975
+ const trailer = resolvedTrailer(thread);
976
+ const last = rows.at(-1);
977
+ if (trailer && last) last.trailer = trailer;
978
+ return rows;
979
+ }
980
+ function resolvedTrailer(thread) {
981
+ const resolved = thread.find((comment) => comment.resolved)?.resolved;
982
+ if (!resolved) return "";
983
+ const by = resolved.by ? ` by ${resolved.by}` : "";
984
+ return kleur.green(` ↳ resolved${by} · ${formatDateTime(resolved.at)}`);
985
+ }
986
+ function resolveMentions(body, names) {
987
+ return body.replace(MENTION, (raw, id) => {
988
+ const name = names.get(id);
989
+ return name ? `@${name}` : raw;
990
+ });
675
991
  }
676
992
  //#endregion
677
- //#region src/commands/bitbucket.ts
678
- async function bitbucketLogin() {
679
- const existing = await readConfig() ?? {};
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);
750
- const limit = parseLimit(options.limit);
751
- printRows(pipelineRows(await withScopeHint(PIPELINE_SCOPE, () => listPipelines(client, ref, limit)), Date.now()), {
752
- json: options.json,
753
- empty: "No pipelines found."
754
- });
755
- }
756
- async function bitbucketPipeline(arg, options) {
757
- const buildNumber = parseBuildNumber(arg);
758
- const auth = await requireBitbucketAuth();
759
- const ref = resolveRepo(options.repo, auth);
760
- const client = new AtlassianClient(auth);
761
- const detail = await withScopeHint(PIPELINE_SCOPE, () => getPipeline(client, ref, buildNumber));
762
- printPipelineDetail(detail, await withScopeHint(PIPELINE_SCOPE, () => listSteps(client, ref, detail.uuid)), Date.now());
763
- }
764
- function pipelineRows(pipelines, nowMs) {
765
- return alignedRows(pipelines, nowMs, (p) => ({
766
- id: `#${p.buildNumber}`,
767
- url: p.url,
768
- label: p.status || "-",
769
- color: colorForBitbucketState(p.status),
770
- text: `${refWithCommit(p)} · ${formatDuration(p.durationSeconds)}`,
771
- timestamp: p.createdOn
772
- }));
773
- }
993
+ //#region src/commands/bitbucket-shared.ts
994
+ const PIPELINE_SCOPE = "read:pipeline:bitbucket";
995
+ const PULL_REQUEST_SCOPE = "read:pullrequest:bitbucket";
996
+ const REPOSITORY_SCOPE = "read:repository:bitbucket";
997
+ const ACCOUNT_SCOPE = "read:account";
774
998
  const STATE_COLORS = {
775
999
  SUCCESSFUL: kleur.green,
776
1000
  MERGED: kleur.green,
1001
+ APPROVED: kleur.green,
777
1002
  FAILED: kleur.red,
778
1003
  ERROR: kleur.red,
779
1004
  DECLINED: kleur.red,
1005
+ CHANGES_REQUESTED: kleur.red,
780
1006
  IN_PROGRESS: kleur.yellow,
781
1007
  PENDING: kleur.yellow,
782
1008
  OPEN: kleur.yellow,
@@ -788,875 +1014,1218 @@ const STATE_COLORS = {
788
1014
  function colorForBitbucketState(state) {
789
1015
  return STATE_COLORS[state.toUpperCase()] ?? kleur.white;
790
1016
  }
791
- function formatStepRows(steps) {
792
- const rows = steps.map((s) => ({
793
- name: s.name || "-",
794
- status: s.status || "-",
795
- dur: formatDuration(s.durationSeconds)
796
- }));
797
- const wn = Math.max(...rows.map((r) => r.name.length));
798
- const ws = Math.max(...rows.map((r) => r.status.length));
799
- return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
800
- }
801
- function printPipelineDetail(detail, steps, nowMs) {
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)}`);
807
- const by = detail.creator ? ` by ${detail.creator}` : "";
808
- console.log(`Created: ${relativeTime(detail.createdOn, nowMs)}${by}`);
809
- if (steps.length > 0) {
810
- console.log("");
811
- console.log("Steps:");
812
- for (const line of formatStepRows(steps)) console.log(line);
813
- }
814
- }
815
- function refWithCommit(pipeline) {
816
- if (!pipeline.ref) return pipeline.commit || "-";
817
- return pipeline.commit ? `${pipeline.ref} (${pipeline.commit})` : pipeline.ref;
818
- }
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
- function parseBuildNumber(arg) {
829
- const raw = (arg ?? "").replace(/^#/, "").trim();
830
- if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
831
- return Number.parseInt(raw, 10);
832
- }
833
- const PIPELINE_SCOPE = "read:pipeline:bitbucket";
834
1017
  async function withScopeHint(scope, fn) {
835
1018
  try {
836
1019
  return await fn();
837
1020
  } catch (err) {
838
- if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error(`Bitbucket rejected the request (401/403). Check the token has the ${scope} scope, or run \`atlass bitbucket login\` to update it.`);
1021
+ if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error(`Bitbucket rejected ${err.path || "the request"} (401/403). Check the token has the ${scope} scope, or run \`atlass bitbucket login\` to update it.`);
839
1022
  throw err;
840
1023
  }
841
1024
  }
842
1025
  //#endregion
843
- //#region src/commands/bitbucket-prs.ts
844
- const PULL_REQUEST_SCOPE = "read:pullrequest:bitbucket";
845
- const ACCOUNT_SCOPE = "read:account";
846
- async function bitbucketPrs(options) {
847
- if (options.query && (options.author || options.reviewer)) throw new Error("--query cannot be combined with --author or --reviewer.");
848
- const states = pullRequestStates(options);
849
- const auth = await requireBitbucketAuth();
850
- const ref = resolveRepo(options.repo, auth);
851
- const client = new AtlassianClient(auth);
852
- const limit = parseLimit(options.limit);
853
- const query = pullRequestQuery({
854
- ...await resolvePrincipals(client, auth, options),
855
- query: options.query
856
- });
857
- const prs = await withScopeHint(PULL_REQUEST_SCOPE, () => listPullRequests(client, ref, {
858
- limit,
859
- states,
860
- query
861
- }));
862
- printRows(pullRequestRows(prs, Date.now()), {
863
- json: options.json,
864
- empty: emptyMessage(options),
865
- footer: prs.length === limit ? searchFooter(limit) : void 0
866
- });
867
- }
868
- function pullRequestStates(options) {
869
- if (!options.all) return parsePullRequestStates(options.state);
870
- if (options.state) throw new Error("--all cannot be combined with --state.");
871
- return allPullRequestStates();
872
- }
873
- function pullRequestRows(prs, nowMs) {
874
- return alignedRows(prs, nowMs, (pr) => {
875
- const label = pr.draft ? "DRAFT" : pr.state || "-";
876
- return {
877
- id: `#${pr.id}`,
878
- url: pr.url,
879
- label,
880
- color: colorForBitbucketState(label),
881
- text: pr.title,
882
- timestamp: pr.updatedOn
883
- };
884
- });
885
- }
886
- async function resolvePrincipals(client, auth, options) {
887
- const mine = isMe(options.author) || isMe(options.reviewer) ? await currentUuid(client, auth) : void 0;
888
- return {
889
- author: isMe(options.author) ? mine : options.author,
890
- reviewer: isMe(options.reviewer) ? mine : options.reviewer
891
- };
1026
+ //#region src/adf/to-markdown-inline.ts
1027
+ function renderMedia(node, ctx) {
1028
+ const attrs = node.attrs ?? {};
1029
+ const alt = attrs.alt ?? "";
1030
+ const resolved = ctx.resolveMedia?.(attrs);
1031
+ if (resolved) return `![${alt}](${resolved})`;
1032
+ return `[embedded media: ${alt || attrs.id || "media"}]`;
892
1033
  }
893
- function isMe(value) {
894
- return value?.toLowerCase() === "me";
1034
+ function renderInline(nodes, ctx) {
1035
+ return nodes.map((n) => renderInlineNode(n, ctx)).join("");
895
1036
  }
896
- async function currentUuid(client, auth) {
897
- if (auth.uuid) return auth.uuid;
898
- const uuid = await withScopeHint(ACCOUNT_SCOPE, () => fetchCurrentUserUuid(client));
899
- await rememberBitbucketUuid(uuid);
900
- return uuid;
1037
+ function renderInlineNode(node, ctx) {
1038
+ switch (node.type) {
1039
+ case "text": return applyMarks(node.text ?? "", node.marks ?? []);
1040
+ case "hardBreak": return " \n";
1041
+ case "mention": return (typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : "") || "@unknown";
1042
+ case "emoji": {
1043
+ const text = node.attrs?.["text"];
1044
+ if (typeof text === "string" && text.length > 0) return text;
1045
+ const short = node.attrs?.["shortName"];
1046
+ return typeof short === "string" ? short : "";
1047
+ }
1048
+ case "date": return formatDate(node.attrs?.["timestamp"]);
1049
+ case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
1050
+ case "inlineCard": {
1051
+ const url = node.attrs?.["url"];
1052
+ if (typeof url === "string") return `[${url}](${url})`;
1053
+ return "";
1054
+ }
1055
+ case "media": return renderMedia(node, ctx);
1056
+ default: return node.text ?? "";
1057
+ }
901
1058
  }
902
- function emptyMessage(options) {
903
- return options.author || options.reviewer || options.query || options.state || options.all ? "No matching pull requests." : "No open pull requests.";
1059
+ function applyMarks(text, marks) {
1060
+ if (text.length === 0) return text;
1061
+ let out = text;
1062
+ let href;
1063
+ for (const mark of marks) switch (mark.type) {
1064
+ case "code":
1065
+ out = `\`${out}\``;
1066
+ break;
1067
+ case "strong":
1068
+ out = `**${out}**`;
1069
+ break;
1070
+ case "em":
1071
+ out = `*${out}*`;
1072
+ break;
1073
+ case "strike":
1074
+ out = `~~${out}~~`;
1075
+ break;
1076
+ case "link": {
1077
+ const value = mark.attrs?.["href"];
1078
+ if (typeof value === "string") href = value;
1079
+ break;
1080
+ }
1081
+ }
1082
+ if (href) out = `[${out}](${href})`;
1083
+ return out;
904
1084
  }
905
- //#endregion
906
- //#region src/cli/bitbucket.ts
907
- function registerBitbucket(bitbucket) {
908
- bitbucket.description("Bitbucket commands");
909
- bitbucket.command("login").description("Store Bitbucket workspace and API token").action(run(bitbucketLogin));
910
- bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(run(bitbucketLogout));
911
- bitbucket.command("status").description("Show the current Bitbucket login").action(run(bitbucketStatus));
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(run(bitbucketPipelines));
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(run(bitbucketPrs));
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(run(bitbucketPipeline));
915
- return bitbucket;
1085
+ function formatDate(timestamp) {
1086
+ const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
1087
+ if (Number.isNaN(ms)) return "";
1088
+ return new Date(ms).toISOString().slice(0, 10);
916
1089
  }
917
1090
  //#endregion
918
- //#region src/adf/from-markdown.ts
919
- function mediaNode(source, alt) {
920
- const attrs = { ...source };
921
- if (alt) attrs["alt"] = alt;
922
- return {
923
- type: "mediaSingle",
924
- attrs: { layout: "center" },
925
- content: [{
926
- type: "media",
927
- attrs
928
- }]
929
- };
1091
+ //#region src/adf/to-markdown.ts
1092
+ function adfToMarkdown(doc, options = {}) {
1093
+ if (!doc) return "";
1094
+ const ctx = { resolveMedia: options.resolveMedia };
1095
+ return (doc.type === "doc" ? renderBlocks(doc.content ?? [], ctx, "") : renderBlock(doc, ctx, "")).trim();
930
1096
  }
931
- function imageHrefs(md) {
932
- const hrefs = /* @__PURE__ */ new Set();
933
- marked.walkTokens(marked.lexer(md), (token) => {
934
- if (token.type === "image") hrefs.add(token.href);
935
- });
936
- return [...hrefs];
1097
+ function renderBlocks(nodes, ctx, indent) {
1098
+ return nodes.map((n) => renderBlock(n, ctx, indent)).filter((s) => s.length > 0).join("\n\n");
937
1099
  }
938
- function markdownToAdf(md, options = {}) {
939
- const ctx = { resolveImage: options.resolveImage ?? externalMedia };
940
- return {
941
- type: "doc",
942
- version: 1,
943
- content: blocks(marked.lexer(md), ctx)
944
- };
1100
+ function renderBlock(node, ctx, indent) {
1101
+ switch (node.type) {
1102
+ case "paragraph": return indent + renderInline(node.content ?? [], ctx);
1103
+ case "heading": {
1104
+ const level = clampLevel$1(node.attrs?.["level"]);
1105
+ return `${"#".repeat(level)} ${renderInline(node.content ?? [], ctx)}`;
1106
+ }
1107
+ case "bulletList": return renderList(node, ctx, indent, "bullet");
1108
+ case "orderedList": return renderList(node, ctx, indent, "ordered");
1109
+ case "taskList": return renderTaskList(node, ctx, indent);
1110
+ case "decisionList": return renderDecisionList(node, ctx, indent);
1111
+ case "codeBlock": return renderCodeBlock(node, indent);
1112
+ case "blockquote": return prefixLines(renderBlocks(node.content ?? [], ctx, ""), `${indent}> `);
1113
+ case "panel": return renderPanel(node, ctx, indent);
1114
+ case "rule": return `${indent}---`;
1115
+ case "table": return renderTable(node, ctx);
1116
+ case "mediaSingle":
1117
+ case "mediaGroup": return renderBlocks(node.content ?? [], ctx, indent);
1118
+ case "media": return indent + renderMedia(node, ctx);
1119
+ case "expand":
1120
+ case "nestedExpand": return renderExpand(node, ctx, indent);
1121
+ default:
1122
+ if (node.content?.length) return renderBlocks(node.content, ctx, indent);
1123
+ return node.text ? indent + node.text : "";
1124
+ }
945
1125
  }
946
- function blocks(tokens, ctx) {
947
- const out = [];
948
- for (const token of tokens) out.push(...block(token, ctx));
949
- return out;
1126
+ function renderList(node, ctx, indent, kind) {
1127
+ const start = kind === "ordered" ? toNumber(node.attrs?.["order"]) ?? 1 : 0;
1128
+ return (node.content ?? []).filter((n) => n.type === "listItem").map((item, i) => {
1129
+ return renderListItem(item, ctx, indent, kind === "ordered" ? `${start + i}. ` : "- ");
1130
+ }).join("\n");
950
1131
  }
951
- function block(token, ctx) {
952
- switch (token.type) {
953
- case "space": return [];
954
- case "heading": return [{
955
- type: "heading",
956
- attrs: { level: clampLevel$1(token.depth) },
957
- content: inline(token.tokens ?? [], ctx)
958
- }];
959
- case "paragraph": return paragraphsSplitAtImages(token.tokens ?? [], ctx);
960
- case "text": return paragraphsSplitAtImages(token.tokens ?? [textToken(token.text)], ctx);
961
- case "list": return [list(token, ctx)];
962
- case "code": return [codeBlock(token)];
963
- case "blockquote": return [{
964
- type: "blockquote",
965
- content: blocks(token.tokens ?? [], ctx)
966
- }];
967
- case "table": return [table(token, ctx)];
968
- case "hr": return [{ type: "rule" }];
969
- default: return [];
970
- }
1132
+ function renderListItem(item, ctx, indent, marker) {
1133
+ const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
1134
+ return (item.content ?? []).map((block, i) => i === 0 ? `${indent}${marker}${renderBlock(block, ctx, "")}` : renderBlock(block, ctx, continuationIndent)).join("\n");
971
1135
  }
972
- function paragraphsSplitAtImages(tokens, ctx) {
973
- const out = [];
974
- let buffer = [];
975
- const flush = () => {
976
- if (buffer.length === 0) return;
977
- const nodes = inline(buffer, ctx);
978
- if (nodes.length > 0) out.push({
979
- type: "paragraph",
980
- content: nodes
981
- });
982
- buffer = [];
983
- };
984
- for (const token of tokens) if (token.type === "image") {
985
- flush();
986
- const image = token;
987
- const node = ctx.resolveImage(image.href, image.text ?? "");
988
- if (node) out.push(node);
989
- } else buffer.push(token);
990
- flush();
991
- return out;
1136
+ function renderTaskList(node, ctx, indent) {
1137
+ return (node.content ?? []).filter((n) => n.type === "taskItem").map((item) => {
1138
+ return `${indent}- [${item.attrs?.["state"] === "DONE" ? "x" : " "}] ${renderInline(item.content ?? [], ctx)}`;
1139
+ }).join("\n");
992
1140
  }
993
- function list(token, ctx) {
994
- if (token.items.length > 0 && token.items.every((i) => i.task)) return {
995
- type: "taskList",
996
- attrs: { localId: randomUUID() },
997
- content: token.items.map((item) => ({
998
- type: "taskItem",
999
- attrs: {
1000
- localId: randomUUID(),
1001
- state: item.checked ? "DONE" : "TODO"
1002
- },
1003
- content: inline(taskItemInline(item), ctx)
1004
- }))
1005
- };
1006
- const node = {
1007
- type: token.ordered ? "orderedList" : "bulletList",
1008
- content: token.items.map((item) => listItem(item, ctx))
1009
- };
1010
- const start = Number(token.start);
1011
- if (token.ordered && Number.isFinite(start) && start !== 1) node.attrs = { order: start };
1012
- return node;
1141
+ function renderDecisionList(node, ctx, indent) {
1142
+ return (node.content ?? []).filter((n) => n.type === "decisionItem").map((item) => `${indent}- (decision) ${renderInline(item.content ?? [], ctx)}`).join("\n");
1013
1143
  }
1014
- function listItem(item, ctx) {
1015
- const children = blocks(item.tokens.filter((t) => t.type !== "checkbox"), ctx);
1016
- return {
1017
- type: "listItem",
1018
- content: children.length > 0 ? children : [emptyParagraph()]
1019
- };
1144
+ function renderCodeBlock(node, indent) {
1145
+ const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
1146
+ const code = (node.content ?? []).map((n) => n.text ?? "").join("");
1147
+ const fence = "```";
1148
+ return prefixLines(`${fence}${lang}\n${code}\n${fence}`, indent);
1020
1149
  }
1021
- function emptyParagraph() {
1022
- return {
1023
- type: "paragraph",
1024
- content: []
1025
- };
1150
+ const PANEL_LABELS = {
1151
+ info: "Info",
1152
+ note: "Note",
1153
+ warning: "Warning",
1154
+ success: "Success",
1155
+ error: "Error"
1156
+ };
1157
+ function renderPanel(node, ctx, indent) {
1158
+ const type = typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info";
1159
+ return prefixLines(`**${PANEL_LABELS[type] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
1026
1160
  }
1027
- function taskItemInline(item) {
1028
- const first = item.tokens.find((t) => t.type === "text");
1029
- if (first && "tokens" in first && first.tokens) return first.tokens;
1030
- return [textToken(item.text)];
1161
+ function renderExpand(node, ctx, indent) {
1162
+ return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
1031
1163
  }
1032
- function codeBlock(token) {
1033
- return {
1034
- type: "codeBlock",
1035
- attrs: token.lang ? { language: token.lang } : {},
1036
- content: token.text.length > 0 ? [{
1037
- type: "text",
1038
- text: token.text
1039
- }] : []
1164
+ function renderTable(node, ctx) {
1165
+ const rows = (node.content ?? []).filter((n) => n.type === "tableRow");
1166
+ if (rows.length === 0) return "";
1167
+ const grid = rows.map((row) => (row.content ?? []).map((cell) => renderCellOnOneLine(cell, ctx)));
1168
+ const cols = Math.max(...grid.map((r) => r.length));
1169
+ const pad = (r) => {
1170
+ const copy = [...r];
1171
+ while (copy.length < cols) copy.push("");
1172
+ return copy;
1040
1173
  };
1174
+ const header = pad(grid[0] ?? []);
1175
+ const lines = [`| ${header.join(" | ")} |`, `| ${header.map(() => "---").join(" | ")} |`];
1176
+ for (const row of grid.slice(1)) lines.push(`| ${pad(row).join(" | ")} |`);
1177
+ return lines.join("\n");
1041
1178
  }
1042
- function table(token, ctx) {
1043
- return {
1044
- type: "table",
1045
- content: [{
1046
- type: "tableRow",
1047
- content: token.header.map((cell) => tableCell(cell, "tableHeader", ctx))
1048
- }, ...token.rows.map((row) => ({
1049
- type: "tableRow",
1050
- content: row.map((cell) => tableCell(cell, "tableCell", ctx))
1051
- }))]
1052
- };
1179
+ function renderCellOnOneLine(cell, ctx) {
1180
+ return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
1053
1181
  }
1054
- function tableCell(cell, type, ctx) {
1055
- return {
1056
- type,
1057
- content: [{
1058
- type: "paragraph",
1059
- content: inline(cell.tokens, ctx)
1060
- }]
1061
- };
1182
+ function prefixLines(text, prefix) {
1183
+ return text.split("\n").map((line) => line.length > 0 ? prefix + line : prefix.trimEnd()).join("\n");
1062
1184
  }
1063
- function inline(tokens, ctx, marks = []) {
1064
- const out = [];
1065
- for (const token of tokens) out.push(...inlineNode(token, ctx, marks));
1066
- return out;
1185
+ function clampLevel$1(value) {
1186
+ const n = toNumber(value) ?? 1;
1187
+ return Math.min(6, Math.max(1, n));
1067
1188
  }
1068
- function inlineNode(token, ctx, marks) {
1069
- switch (token.type) {
1070
- case "text":
1071
- case "escape": {
1072
- const t = token;
1073
- if ("tokens" in t && t.tokens?.length) return inline(t.tokens, ctx, marks);
1074
- return textNode(t.text, marks);
1189
+ function toNumber(value) {
1190
+ return typeof value === "number" ? value : void 0;
1191
+ }
1192
+ //#endregion
1193
+ //#region src/markdown/highlight.ts
1194
+ const HEADING = /^#{1,6} /;
1195
+ const QUOTE = /^(\s*)>( |$)/;
1196
+ const LIST = /^(\s*)(-|\d+\.) (\[[ x]\] )?/;
1197
+ const RULE = /^\s*---$/;
1198
+ const TABLE_ROW = /^\|.*\|$/;
1199
+ const TABLE_SEPARATOR = /^\|( --- \|)+$/;
1200
+ const CELL_DIVIDER = /(?<!\\)\|/;
1201
+ const DETAILS_OPEN = /^(\s*)<details><summary>(.*)<\/summary>$/;
1202
+ const DETAILS_CLOSE = /^\s*<\/details>$/;
1203
+ const FENCE_OPEN = /^((?:\s|> |- |\d+\. )*)```(\S*)$/;
1204
+ const FENCE = "```";
1205
+ const INLINE = /(`[^`]+`)|\[([^\]]+)\]\(((?:[^()]|\([^()]*\))+)\)|\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(?!\*)(.+?)(?<!\*)\*(?!\*)|~~(.+?)~~/g;
1206
+ const TOKEN_STYLES = {
1207
+ keyword: kleur.magenta,
1208
+ built_in: kleur.magenta,
1209
+ literal: kleur.magenta,
1210
+ string: kleur.green,
1211
+ regexp: kleur.green,
1212
+ addition: kleur.green,
1213
+ number: kleur.yellow,
1214
+ symbol: kleur.yellow,
1215
+ meta: kleur.yellow,
1216
+ attr: kleur.yellow,
1217
+ comment: kleur.dim,
1218
+ quote: kleur.dim,
1219
+ deletion: kleur.dim,
1220
+ title: kleur.cyan,
1221
+ type: kleur.cyan,
1222
+ section: kleur.cyan
1223
+ };
1224
+ const lowlight = createLowlight(common);
1225
+ function highlightMarkdown(md) {
1226
+ const lines = md.split("\n");
1227
+ const out = [];
1228
+ for (let i = 0; i < lines.length; i++) {
1229
+ const line = lines[i] ?? "";
1230
+ const fence = line.match(FENCE_OPEN);
1231
+ if (!fence) {
1232
+ out.push(highlightLine(line, lines[i + 1]));
1233
+ continue;
1075
1234
  }
1076
- case "strong": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strong" }));
1077
- case "em": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "em" }));
1078
- case "del": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strike" }));
1079
- case "codespan": return textNode(token.text, withUniqueMark(marks, { type: "code" }));
1080
- case "link": {
1081
- const link = token;
1082
- return inline(link.tokens, ctx, withUniqueMark(marks, {
1083
- type: "link",
1084
- attrs: { href: link.href }
1085
- }));
1235
+ const [, prefix = "", lang = ""] = fence;
1236
+ const styledPrefix = prefix.replace(/\S+/g, (marker) => kleur.dim(marker));
1237
+ out.push(`${styledPrefix}${kleur.dim(`${FENCE}${lang}`)}`);
1238
+ const code = [];
1239
+ const prefixed = [];
1240
+ let j = i + 1;
1241
+ for (; j < lines.length; j++) {
1242
+ const raw = lines[j] ?? "";
1243
+ const hasPrefix = raw.startsWith(prefix) || raw === prefix.trimEnd();
1244
+ const body = raw.startsWith(prefix) ? raw.slice(prefix.length) : hasPrefix ? "" : raw;
1245
+ if (body === FENCE) break;
1246
+ code.push(body);
1247
+ prefixed.push(hasPrefix);
1086
1248
  }
1087
- case "br": return [{ type: "hardBreak" }];
1088
- case "html": return textNode(token.text, marks);
1089
- default: return "text" in token && token.text ? textNode(token.text, marks) : [];
1249
+ if (code.length > 0) highlightCode(code.join("\n"), lang).split("\n").forEach((codeLine, k) => {
1250
+ const lead = prefixed[k] ? styledPrefix : "";
1251
+ out.push(codeLine ? `${lead}${codeLine}` : lead.trimEnd());
1252
+ });
1253
+ if (j < lines.length) {
1254
+ const lead = (lines[j] ?? "").startsWith(prefix) ? styledPrefix : "";
1255
+ out.push(`${lead}${kleur.dim(FENCE)}`);
1256
+ }
1257
+ i = j;
1090
1258
  }
1259
+ return out.join("\n");
1091
1260
  }
1092
- function textNode(text, marks) {
1093
- if (text.length === 0) return [];
1094
- const node = {
1095
- type: "text",
1096
- text
1097
- };
1098
- if (marks.length > 0) node.marks = marks;
1099
- return [node];
1261
+ function highlightCode(code, lang) {
1262
+ if (!lang || !lowlight.registered(lang)) return styleEachLine(code, kleur.yellow);
1263
+ return renderTree(lowlight.highlight(lang, code), (text) => text);
1100
1264
  }
1101
- function withUniqueMark(marks, mark) {
1102
- return [...marks.filter((m) => m.type !== mark.type), mark];
1265
+ function renderTree(node, style) {
1266
+ if (node.type === "text") return styleEachLine(node.value, style);
1267
+ if (node.type !== "root" && node.type !== "element") return "";
1268
+ const own = node.type === "element" ? tokenStyle(node.properties["className"]) : void 0;
1269
+ const next = own ? (text) => style(own(text)) : style;
1270
+ return node.children.map((child) => renderTree(child, next)).join("");
1103
1271
  }
1104
- function externalMedia(href, alt) {
1105
- return mediaNode({
1106
- type: "external",
1107
- url: href
1108
- }, alt);
1272
+ function tokenStyle(className) {
1273
+ if (!Array.isArray(className)) return void 0;
1274
+ for (const name of className) {
1275
+ if (typeof name !== "string" || !name.startsWith("hljs-")) continue;
1276
+ const style = TOKEN_STYLES[name.slice(5)];
1277
+ if (style) return style;
1278
+ }
1109
1279
  }
1110
- function textToken(text) {
1111
- return {
1112
- type: "text",
1113
- raw: text,
1114
- text,
1115
- escaped: false
1116
- };
1280
+ function styleEachLine(text, style) {
1281
+ return text.split("\n").map((line) => line ? style(line) : line).join("\n");
1117
1282
  }
1118
- function clampLevel$1(value) {
1119
- return Math.min(6, Math.max(1, Math.trunc(value) || 1));
1283
+ function highlightLine(line, nextLine) {
1284
+ const quote = line.match(QUOTE);
1285
+ if (quote) {
1286
+ const [whole, indent = ""] = quote;
1287
+ const rest = line.slice(whole.length);
1288
+ const gap = whole.endsWith(" ") ? " " : "";
1289
+ return `${indent}${kleur.dim(">")}${gap}${highlightLine(rest, stripPrefix(nextLine, whole))}`;
1290
+ }
1291
+ const list = line.match(LIST);
1292
+ if (list) {
1293
+ const [whole, indent = "", marker = "", checkbox] = list;
1294
+ const rest = line.slice(whole.length);
1295
+ const box = checkbox ? `${kleur.bold(checkbox.trim())} ` : "";
1296
+ return `${indent}${kleur.dim(marker)} ${box}${highlightLine(rest, void 0)}`;
1297
+ }
1298
+ if (HEADING.test(line)) return kleur.bold().cyan(line);
1299
+ if (RULE.test(line)) return kleur.dim(line);
1300
+ if (TABLE_SEPARATOR.test(line)) return kleur.dim(line);
1301
+ if (TABLE_ROW.test(line)) return tableRow(line, nextLine !== void 0 && TABLE_SEPARATOR.test(nextLine));
1302
+ const details = line.match(DETAILS_OPEN);
1303
+ if (details) {
1304
+ const [, indent = "", title = ""] = details;
1305
+ return `${indent}${kleur.dim("<details><summary>")}${highlightInline(title)}${kleur.dim("</summary>")}`;
1306
+ }
1307
+ if (DETAILS_CLOSE.test(line)) return kleur.dim(line);
1308
+ return highlightInline(line);
1120
1309
  }
1121
- //#endregion
1122
- //#region src/api/confluence-attachments.ts
1123
- const UNKNOWN_SIZE = -1;
1124
- async function listAttachments(client, id) {
1125
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
1126
- filename: a.title ?? a.id,
1127
- fileId: mediaNodeId(a),
1128
- size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
1129
- }));
1310
+ function stripPrefix(line, prefix) {
1311
+ return line?.startsWith(prefix) ? line.slice(prefix.length) : void 0;
1130
1312
  }
1131
- async function uploadAttachment(client, pageId, filename, bytes) {
1132
- return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
1313
+ function tableRow(line, header) {
1314
+ const styled = line.split(CELL_DIVIDER).slice(1, -1).map((cell) => header ? kleur.bold(highlightInline(cell)) : highlightInline(cell));
1315
+ const pipe = kleur.dim("|");
1316
+ return `${pipe}${styled.join(pipe)}${pipe}`;
1133
1317
  }
1134
- async function fileIdByListing(client, pageId, filename) {
1135
- const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
1136
- if (match) return match.fileId;
1137
- throw new Error(`Upload of "${filename}" did not return a fileId.`);
1318
+ function highlightInline(text) {
1319
+ return text.replace(INLINE, (match, code, linkText, url, boldItalic, bold, italic, strike) => {
1320
+ if (code !== void 0) return kleur.yellow(code);
1321
+ if (linkText !== void 0 && url !== void 0) return link(linkText, url);
1322
+ if (boldItalic !== void 0) return kleur.bold().italic(match);
1323
+ if (bold !== void 0) return kleur.bold(`**${highlightInline(bold)}**`);
1324
+ if (italic !== void 0) return kleur.italic(`*${highlightInline(italic)}*`);
1325
+ if (strike !== void 0) return kleur.strikethrough(`~~${highlightInline(strike)}~~`);
1326
+ return match;
1327
+ });
1138
1328
  }
1139
- async function fetchAttachments(client, id) {
1140
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
1141
- mediaId: mediaNodeId(a),
1142
- filename: a.title ?? a.id,
1143
- url: withWikiContextPath(a.downloadLink ?? "")
1144
- }));
1329
+ function link(text, url) {
1330
+ const open = kleur.dim("[");
1331
+ const middle = kleur.dim("](");
1332
+ return `${open}${highlightInline(text)}${middle}${kleur.dim().underline(url)}${kleur.dim(")")}`;
1145
1333
  }
1146
- function mediaNodeId(a) {
1147
- return a.fileId ?? a.id;
1334
+ //#endregion
1335
+ //#region src/commands/view.ts
1336
+ const INDENT = " ";
1337
+ function fieldLines(pairs) {
1338
+ const kept = pairs.filter(([, value]) => value !== "");
1339
+ const width = Math.max(...kept.map(([label]) => label.length)) + 1;
1340
+ return kept.map(([label, value]) => `${kleur.dim(`${label}:`.padEnd(width))} ${value}`);
1148
1341
  }
1149
- function withWikiContextPath(link) {
1150
- if (link.startsWith("http") || link.startsWith("/wiki")) return link;
1151
- return `/wiki${link}`;
1342
+ function dateWithAge(iso, nowMs) {
1343
+ if (!iso) return "";
1344
+ return `${formatDate$1(iso)} (${relativeTime(iso, nowMs)})`;
1152
1345
  }
1153
- //#endregion
1154
- //#region src/api/confluence-pages.ts
1155
- async function fetchPage(client, site, id) {
1156
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
1157
- const names = new UserNames(client);
1158
- const [spaceKey, attachments, comments, author] = await Promise.all([
1159
- fetchSpaceKey(client, page.spaceId),
1160
- fetchAttachments(client, id),
1161
- fetchComments$1(client, id, names),
1162
- names.resolve(page.version?.authorId ?? page.authorId)
1163
- ]);
1164
- const webui = page._links?.webui ?? "";
1165
- return {
1166
- id: page.id,
1167
- title: page.title,
1168
- spaceKey,
1169
- version: page.version?.number ?? 0,
1170
- author,
1171
- createdAt: page.createdAt ?? "",
1172
- updatedAt: page.version?.createdAt ?? "",
1173
- url: webui ? `${site}/wiki${webui}` : "",
1174
- body: parseAdfJson(page.body?.atlas_doc_format?.value),
1175
- attachments,
1176
- comments
1177
- };
1346
+ function markdownBody(md) {
1347
+ return md ? ["", highlightMarkdown(md)] : [];
1178
1348
  }
1179
- async function fetchPageState(client, id) {
1180
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
1181
- return {
1182
- version: page.version?.number ?? 0,
1183
- title: page.title,
1184
- body: parseAdfJson(page.body?.atlas_doc_format?.value)
1185
- };
1349
+ function bodyLines(body) {
1350
+ return markdownBody(adfToMarkdown(body));
1351
+ }
1352
+ function renderedComments(comments) {
1353
+ return comments.map((comment) => [{
1354
+ author: comment.author,
1355
+ created: comment.created,
1356
+ markdown: adfToMarkdown(comment.body)
1357
+ }]);
1358
+ }
1359
+ function commentSection(threads, options) {
1360
+ const total = threads.reduce((running, thread) => running + thread.length, 0);
1361
+ if (total === 0) return [];
1362
+ const visible = options.allComments ? threads : threads.slice(-5);
1363
+ const lines = ["", kleur.bold(commentHeading(total, threads.length, visible.length, options))];
1364
+ for (const thread of visible) thread.forEach((comment, index) => {
1365
+ lines.push("", commentHeader(comment, index > 0), ...commentBody(comment, index > 0), ...commentTrailer(comment));
1366
+ });
1367
+ return lines;
1186
1368
  }
1187
- async function updatePage(client, id, params) {
1188
- return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
1189
- id,
1190
- status: "current",
1191
- title: params.title,
1192
- body: {
1193
- representation: "atlas_doc_format",
1194
- value: JSON.stringify(params.body)
1195
- },
1196
- version: {
1197
- number: params.nextVersion,
1198
- message: params.message
1199
- }
1200
- })).version?.number ?? params.nextVersion;
1369
+ function commentHeading(total, threads, visible, options) {
1370
+ const count = options.truncated ? `${total}+` : String(total);
1371
+ if (visible === threads) return `Comments (${count})`;
1372
+ if (threads === total) return `Comments (${count}, showing last ${visible} — --all-comments for all)`;
1373
+ return `Comments (${count} in ${threads} threads, showing last ${visible} threads — --all-comments for all)`;
1201
1374
  }
1202
- async function fetchSpaceKey(client, spaceId) {
1203
- if (!spaceId) return "";
1204
- try {
1205
- return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
1206
- } catch {
1207
- return "";
1375
+ function commentHeader(comment, reply) {
1376
+ const author = kleur.bold(comment.author || "Unknown");
1377
+ const when = comment.created ? ` · ${kleur.dim(formatDateTime(comment.created))}` : "";
1378
+ const where = comment.anchor ? ` · ${kleur.dim(comment.anchor)}` : "";
1379
+ return `${reply ? `${INDENT}↳ ` : "─ "}${author}${when}${where}`;
1380
+ }
1381
+ function commentBody(comment, reply) {
1382
+ if (!comment.markdown) return [];
1383
+ const body = highlightMarkdown(comment.markdown);
1384
+ if (!reply) return [body];
1385
+ return [body.split("\n").map((line) => line ? `${INDENT}${line}` : line).join("\n")];
1386
+ }
1387
+ function commentTrailer(comment) {
1388
+ return comment.trailer ? ["", comment.trailer] : [];
1389
+ }
1390
+ function attachmentSection(attachments) {
1391
+ if (attachments.length === 0) return [];
1392
+ return [
1393
+ "",
1394
+ kleur.bold("Attachments"),
1395
+ ...attachments.map((a) => `- ${a.filename}`)
1396
+ ];
1397
+ }
1398
+ //#endregion
1399
+ //#region src/commands/bitbucket-pr.ts
1400
+ async function bitbucketPr({ session, term }, arg, options) {
1401
+ const ref = parseRef(arg);
1402
+ const repo = ref.repo ?? resolveRepo(options.repo, session);
1403
+ const detail = await withScopeHint(PULL_REQUEST_SCOPE, () => fetchDetail(session, repo, ref.id));
1404
+ const [comments, files] = await Promise.all([withScopeHint(PULL_REQUEST_SCOPE, () => listComments(session, repo, ref.id)), fetchFiles(session, repo, ref.id)]);
1405
+ if (options.json) {
1406
+ term.json({
1407
+ ...detail,
1408
+ comments: comments.items,
1409
+ files: files?.items ?? null,
1410
+ truncated: {
1411
+ comments: comments.truncated,
1412
+ files: files?.truncated ?? false
1413
+ }
1414
+ });
1415
+ return;
1208
1416
  }
1417
+ const lines = formatPullRequestView(detail, comments, files, Date.now(), options.allComments);
1418
+ await term.page(lines.join("\n"), { pager: options.pager });
1209
1419
  }
1210
- async function fetchComments$1(client, id, names) {
1211
- const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
1212
- return Promise.all(res.results.map(async (c) => ({
1213
- author: await names.resolve(c.version?.authorId),
1214
- created: c.version?.createdAt ?? "",
1215
- body: parseAdfJson(c.body?.atlas_doc_format?.value)
1216
- })));
1420
+ function formatPullRequestView(detail, comments, files, nowMs, allComments = false) {
1421
+ const label = detail.draft ? "DRAFT" : detail.state || "-";
1422
+ return [
1423
+ kleur.bold(detail.title),
1424
+ ...fieldLines([
1425
+ ["State", colorForBitbucketState(label)(label)],
1426
+ ["Author", detail.author],
1427
+ ["Branch", branchLine(detail)],
1428
+ ["Approvals", approvalCount(detail.reviewers)],
1429
+ ["Created", dateWithAge(detail.createdOn, nowMs)],
1430
+ ["Updated", dateWithAge(detail.updatedOn, nowMs)],
1431
+ ["Closed by", detail.closedBy],
1432
+ ["URL", detail.url]
1433
+ ]),
1434
+ ...markdownBody(detail.description),
1435
+ ...reviewerSection(detail.reviewers),
1436
+ ...fileSection(files),
1437
+ ...commentSection(renderedPrComments(comments.items, detail.participants), {
1438
+ allComments,
1439
+ truncated: comments.truncated
1440
+ })
1441
+ ];
1217
1442
  }
1218
- function parseAdfJson(value) {
1219
- if (!value) return null;
1443
+ function reviewerSection(reviewers) {
1444
+ const heading = ["", kleur.bold("Reviewers")];
1445
+ if (reviewers.length === 0) return [...heading, ` ${kleur.dim("None assigned")}`];
1446
+ const width = Math.max(...reviewers.map((r) => r.name.length));
1447
+ return [...heading, ...reviewers.map((r) => ` ${r.name.padEnd(width)} ${reviewerState(r)}`)];
1448
+ }
1449
+ function fileSection(files) {
1450
+ if (!files) return [
1451
+ "",
1452
+ kleur.bold("Files"),
1453
+ ` ${kleur.dim(`Unavailable: the token needs the ${REPOSITORY_SCOPE} scope.`)}`
1454
+ ];
1455
+ if (files.items.length === 0) return [];
1456
+ const rows = files.items.map((file) => ({
1457
+ added: `+${file.added}`,
1458
+ removed: `-${file.removed}`,
1459
+ path: file.path
1460
+ }));
1461
+ const wa = Math.max(...rows.map((r) => r.added.length));
1462
+ const wr = Math.max(...rows.map((r) => r.removed.length));
1463
+ const total = files.total ?? files.items.length;
1464
+ const added = sum(files.items, (f) => f.added);
1465
+ const removed = sum(files.items, (f) => f.removed);
1466
+ return [
1467
+ "",
1468
+ kleur.bold(`Files (${total}, +${added} -${removed})`),
1469
+ ...rows.map((r) => ` ${kleur.green(r.added.padStart(wa))} ${kleur.red(r.removed.padStart(wr))} ${r.path}`),
1470
+ ...moreFilesLine(files)
1471
+ ];
1472
+ }
1473
+ function moreFilesLine(files) {
1474
+ if (!files.truncated) return [];
1475
+ const rest = files.total === void 0 ? void 0 : files.total - files.items.length;
1476
+ return [kleur.dim(rest === void 0 ? " ... more files not shown" : ` ... and ${rest} more`)];
1477
+ }
1478
+ function sum(files, pick) {
1479
+ return files.reduce((running, file) => running + pick(file), 0);
1480
+ }
1481
+ function reviewerState(reviewer) {
1482
+ if (!reviewer.state) return kleur.dim("—");
1483
+ return colorForBitbucketState(reviewer.state)(reviewer.state);
1484
+ }
1485
+ function approvalCount(reviewers) {
1486
+ return `${reviewers.filter((r) => r.state === "APPROVED").length}/${reviewers.length}`;
1487
+ }
1488
+ function branchLine(detail) {
1489
+ if (!detail.sourceBranch || !detail.destinationBranch) return detail.sourceBranch || detail.destinationBranch;
1490
+ return `${detail.sourceBranch} → ${detail.destinationBranch}`;
1491
+ }
1492
+ function parseRef(arg) {
1493
+ const ref = parsePullRequestRef(arg ?? "");
1494
+ if (!ref) throw new Error(`Invalid pull request "${arg ?? ""}". Expected a PR number, e.g. 42, or a Bitbucket pull request URL.`);
1495
+ return ref;
1496
+ }
1497
+ async function fetchFiles(session, repo, id) {
1220
1498
  try {
1221
- return JSON.parse(value);
1222
- } catch {
1223
- return null;
1499
+ return await listChangedFiles(session, repo, id);
1500
+ } catch (err) {
1501
+ if (err instanceof HttpError && (err.status === 401 || err.status === 403)) return null;
1502
+ throw err;
1224
1503
  }
1225
1504
  }
1226
- var UserNames = class {
1227
- client;
1228
- cache = /* @__PURE__ */ new Map();
1229
- constructor(client) {
1230
- this.client = client;
1505
+ async function fetchDetail(session, repo, id) {
1506
+ try {
1507
+ return await getPullRequest(session, repo, id);
1508
+ } catch (err) {
1509
+ if (err instanceof HttpError && err.status === 404) throw new Error(`Pull request #${id} not found in ${repo.workspace}/${repo.repo}.`);
1510
+ throw err;
1231
1511
  }
1232
- async resolve(accountId) {
1233
- if (!accountId) return "";
1234
- const cached = this.cache.get(accountId);
1235
- if (cached !== void 0) return cached;
1236
- const name = await this.fetchDisplayName(accountId) ?? accountId;
1237
- this.cache.set(accountId, name);
1238
- return name;
1512
+ }
1513
+ //#endregion
1514
+ //#region src/util/link.ts
1515
+ const OSC8 = "\x1B]8;;";
1516
+ const BEL = "\x07";
1517
+ function hyperlink(text, url) {
1518
+ if (!kleur.enabled || !url) return text;
1519
+ return `${OSC8}${url}${BEL}${text}${OSC8}${BEL}`;
1520
+ }
1521
+ //#endregion
1522
+ //#region src/commands/search-run.ts
1523
+ function alignedRows(items, nowMs, cells) {
1524
+ const rows = items.map((item) => {
1525
+ const cell = cells(item);
1526
+ return {
1527
+ ...cell,
1528
+ age: relativeTime(cell.timestamp, nowMs),
1529
+ json: { ...item }
1530
+ };
1531
+ });
1532
+ const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
1533
+ const idWidth = width((row) => row.id);
1534
+ const labelWidth = width((row) => row.label);
1535
+ const ageWidth = width((row) => row.age);
1536
+ return rows.map((row) => ({
1537
+ id: row.id,
1538
+ url: row.url,
1539
+ fixedColumns: `${row.id.padEnd(idWidth)} ${row.color(row.label.padEnd(labelWidth))} ${row.age.padEnd(ageWidth)}`,
1540
+ freeText: row.text,
1541
+ json: row.json
1542
+ }));
1543
+ }
1544
+ function formatRows(rows, options) {
1545
+ if (rows.length === 0) return [options.empty];
1546
+ const lines = rows.map((row) => formatRow(row, options.width));
1547
+ return options.footer ? [...lines, options.footer] : lines;
1548
+ }
1549
+ function writeRows(term, rows, options) {
1550
+ if (options.json) term.json(rows.map((r) => r.json));
1551
+ else term.out(formatRows(rows, {
1552
+ ...options,
1553
+ width: term.width
1554
+ }));
1555
+ }
1556
+ const COPY_CONCURRENCY = 5;
1557
+ async function runSearch(term, rows, options, noun, copyOne) {
1558
+ if (!(options.copy && !options.json && rows.length > 0)) {
1559
+ writeRows(term, rows, options);
1560
+ return;
1239
1561
  }
1240
- async fetchDisplayName(accountId) {
1241
- try {
1242
- return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
1243
- } catch {
1244
- return null;
1562
+ if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
1563
+ await copySelected(term, rows, noun, copyOne);
1564
+ }
1565
+ function searchFooter(limit) {
1566
+ return `
1567
+ Showing first ${limit}; refine with flags or raise --limit.`;
1568
+ }
1569
+ async function copySelected(term, rows, noun, copyOne) {
1570
+ if (!term.interactive) throw new Error("--copy requires an interactive terminal.");
1571
+ const selected = await term.ask.pickMany({
1572
+ message: `Select ${noun.plural} to copy:`,
1573
+ choices: rows.map((r) => ({
1574
+ name: formatRow(r, term.width),
1575
+ value: r.id
1576
+ })),
1577
+ pageSize: 20
1578
+ });
1579
+ if (selected.length === 0) {
1580
+ term.out("Nothing selected.");
1581
+ return;
1582
+ }
1583
+ let copied = 0;
1584
+ const failures = [];
1585
+ const queue = [...selected];
1586
+ async function worker() {
1587
+ for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
1588
+ await copyOne(id);
1589
+ copied++;
1590
+ } catch (err) {
1591
+ failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
1245
1592
  }
1246
1593
  }
1247
- };
1594
+ await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
1595
+ const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
1596
+ if (failures.length === 0) term.out(summary);
1597
+ else term.out(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
1598
+ }
1599
+ function formatRow(row, width) {
1600
+ const room = width - stripVTControlCharacters(row.fixedColumns).length - 2;
1601
+ const text = room > 0 ? truncate(row.freeText, room) : "";
1602
+ const columns = hyperlink(row.id, row.url) + row.fixedColumns.slice(row.id.length);
1603
+ return text ? `${columns} ${hyperlink(text, row.url)}` : columns;
1604
+ }
1605
+ function truncate(text, max) {
1606
+ const clean = text.replace(/\s+/g, " ").trim();
1607
+ return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
1608
+ }
1248
1609
  //#endregion
1249
- //#region src/util/html.ts
1250
- const MAX_CODE_POINT = 1114111;
1251
- const NAMED = {
1252
- amp: "&",
1253
- lt: "<",
1254
- gt: ">",
1255
- quot: "\"",
1256
- apos: "'",
1257
- nbsp: " "
1258
- };
1259
- function decodeEntities(text) {
1260
- return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
1261
- if (body[0] === "#") {
1262
- const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
1263
- return isCodePoint(code) ? String.fromCodePoint(code) : match;
1264
- }
1265
- return NAMED[body.toLowerCase()] ?? match;
1610
+ //#region src/commands/bitbucket-prs.ts
1611
+ async function bitbucketPrs({ session, term, profile }, options) {
1612
+ if (options.query && (options.author || options.reviewer)) throw new Error("--query cannot be combined with --author or --reviewer.");
1613
+ const states = pullRequestStates(options);
1614
+ const ref = resolveRepo(options.repo, session);
1615
+ const limit = parseLimit(options.limit);
1616
+ const query = pullRequestQuery({
1617
+ ...await resolvePrincipals(session, profile, options),
1618
+ query: options.query
1619
+ });
1620
+ const prs = await withScopeHint(PULL_REQUEST_SCOPE, () => listPullRequests(session, ref, {
1621
+ limit,
1622
+ states,
1623
+ query
1624
+ }));
1625
+ writeRows(term, pullRequestRows(prs, Date.now()), {
1626
+ json: options.json,
1627
+ empty: emptyMessage(options),
1628
+ footer: prs.length === limit ? searchFooter(limit) : void 0
1266
1629
  });
1267
1630
  }
1268
- function isCodePoint(code) {
1269
- return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
1631
+ function pullRequestStates(options) {
1632
+ if (!options.all) return parsePullRequestStates(options.state);
1633
+ if (options.state) throw new Error("--all cannot be combined with --state.");
1634
+ return allPullRequestStates();
1270
1635
  }
1271
- //#endregion
1272
- //#region src/api/confluence-search.ts
1273
- async function searchPages(client, site, params) {
1274
- const cql = buildCql(params);
1275
- const query = new URLSearchParams({
1276
- cql,
1277
- limit: String(params.limit),
1278
- expand: "space"
1636
+ function pullRequestRows(prs, nowMs) {
1637
+ return alignedRows(prs, nowMs, (pr) => {
1638
+ const label = pr.draft ? "DRAFT" : pr.state || "-";
1639
+ return {
1640
+ id: `#${pr.id}`,
1641
+ url: pr.url,
1642
+ label,
1643
+ color: colorForBitbucketState(label),
1644
+ text: pr.title,
1645
+ timestamp: pr.updatedOn
1646
+ };
1279
1647
  });
1280
- const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
1281
- const serverPageWasFull = results.length === params.limit;
1648
+ }
1649
+ async function resolvePrincipals(session, profile, options) {
1650
+ const mine = isMe(options.author) || isMe(options.reviewer) ? await currentUuid(session, profile) : void 0;
1282
1651
  return {
1283
- pages: results.filter((r) => r.content?.id).map((r) => ({
1284
- id: r.content?.id ?? "",
1285
- space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
1286
- title: decodeEntities(r.content?.title ?? r.title ?? ""),
1287
- updated: r.lastModified ?? "",
1288
- url: r.url ? `${site}/wiki${r.url}` : ""
1289
- })),
1290
- hasMore: serverPageWasFull
1652
+ author: isMe(options.author) ? mine : options.author,
1653
+ reviewer: isMe(options.reviewer) ? mine : options.reviewer
1291
1654
  };
1292
1655
  }
1293
- function buildCql(params) {
1294
- if (params.cql) return params.cql;
1295
- const clauses = ["type = page"];
1296
- if (params.starred) clauses.push("favourite = currentUser()");
1297
- if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
1298
- if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
1299
- return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
1656
+ function isMe(value) {
1657
+ return value?.toLowerCase() === "me";
1300
1658
  }
1301
- function cqlStringLiteral(value) {
1302
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1659
+ async function currentUuid(session, profile) {
1660
+ if (session.uuid) return session.uuid;
1661
+ const uuid = await withScopeHint(ACCOUNT_SCOPE, () => fetchCurrentUserUuid(session));
1662
+ await rememberBitbucketUuid(profile, uuid);
1663
+ return uuid;
1303
1664
  }
1304
- //#endregion
1305
- //#region src/commands/resolve-ref.ts
1306
- async function resolveRef(arg, kind) {
1307
- const raw = arg ?? await input({
1308
- message: kind.message,
1309
- required: true
1310
- });
1311
- const ref = kind.parse(raw);
1312
- if (!ref) throw new Error(kind.notFound(raw));
1313
- return ref;
1665
+ function emptyMessage(options) {
1666
+ return options.author || options.reviewer || options.query || options.state || options.all ? "No matching pull requests." : "No open pull requests.";
1314
1667
  }
1315
1668
  //#endregion
1316
- //#region src/adf/to-markdown-inline.ts
1317
- function renderMedia(node, ctx) {
1318
- const attrs = node.attrs ?? {};
1319
- const alt = attrs.alt ?? "";
1320
- const resolved = ctx.resolveMedia?.(attrs);
1321
- if (resolved) return `![${alt}](${resolved})`;
1322
- return `[embedded media: ${alt || attrs.id || "media"}]`;
1323
- }
1324
- function renderInline(nodes, ctx) {
1325
- return nodes.map((n) => renderInlineNode(n, ctx)).join("");
1669
+ //#region src/api/bitbucket-pipelines.ts
1670
+ const BUILD_NUMBER_SCAN_LIMIT = 1e3;
1671
+ function pipelineStatus(state) {
1672
+ if (!state) return "";
1673
+ if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
1674
+ return state.name ?? "";
1326
1675
  }
1327
- function renderInlineNode(node, ctx) {
1328
- switch (node.type) {
1329
- case "text": return applyMarks(node.text ?? "", node.marks ?? []);
1330
- case "hardBreak": return " \n";
1331
- case "mention": return (typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : "") || "@unknown";
1332
- case "emoji": {
1333
- const text = node.attrs?.["text"];
1334
- if (typeof text === "string" && text.length > 0) return text;
1335
- const short = node.attrs?.["shortName"];
1336
- return typeof short === "string" ? short : "";
1337
- }
1338
- case "date": return formatDate(node.attrs?.["timestamp"]);
1339
- case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
1340
- case "inlineCard": {
1341
- const url = node.attrs?.["url"];
1342
- if (typeof url === "string") return `[${url}](${url})`;
1343
- return "";
1344
- }
1345
- case "media": return renderMedia(node, ctx);
1346
- default: return node.text ?? "";
1676
+ function pipelinesQuery(limit) {
1677
+ return new URLSearchParams({
1678
+ sort: "-created_on",
1679
+ pagelen: String(Math.min(Math.max(limit, 1), 100))
1680
+ }).toString();
1681
+ }
1682
+ function wallClockSeconds(startOn, endOn) {
1683
+ if (!startOn || !endOn) return null;
1684
+ const start = Date.parse(startOn);
1685
+ const end = Date.parse(endOn);
1686
+ if (Number.isNaN(start) || Number.isNaN(end)) return null;
1687
+ return Math.floor((end - start) / 1e3);
1688
+ }
1689
+ function shortHash(hash) {
1690
+ return hash?.slice(0, 7) ?? "";
1691
+ }
1692
+ function pipelineUrl(ref, buildNumber) {
1693
+ return `${BITBUCKET_WEB_ORIGIN}/${ref.workspace}/${ref.repo}/pipelines/results/${buildNumber}`;
1694
+ }
1695
+ function toPipelineSummary(ref, p) {
1696
+ return {
1697
+ buildNumber: p.build_number,
1698
+ status: pipelineStatus(p.state),
1699
+ ref: p.target?.ref_name ?? "",
1700
+ commit: shortHash(p.target?.commit?.hash),
1701
+ durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
1702
+ createdOn: p.created_on ?? "",
1703
+ creator: p.creator?.display_name ?? "",
1704
+ uuid: p.uuid,
1705
+ url: pipelineUrl(ref, p.build_number)
1706
+ };
1707
+ }
1708
+ function toPipelineDetail(ref, p) {
1709
+ return {
1710
+ ...toPipelineSummary(ref, p),
1711
+ repo: p.repository?.full_name ?? "",
1712
+ trigger: p.trigger?.name ?? ""
1713
+ };
1714
+ }
1715
+ function toStepSummary(step) {
1716
+ return {
1717
+ name: step.name ?? "",
1718
+ status: pipelineStatus(step.state),
1719
+ durationSeconds: wallClockSeconds(step.started_on, step.completed_on)
1720
+ };
1721
+ }
1722
+ function pipelinesPath(ref) {
1723
+ return repoPath(ref, "pipelines");
1724
+ }
1725
+ async function listPipelines(client, ref, limit) {
1726
+ return collectPages(client, `${pipelinesPath(ref)}?${pipelinesQuery(limit)}`, limit, (value) => toPipelineSummary(ref, value));
1727
+ }
1728
+ async function getPipeline(client, ref, buildNumber) {
1729
+ const found = await fetchByBuildNumber(client, ref, buildNumber) ?? await findInRecentRuns(client, ref, buildNumber);
1730
+ if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${BUILD_NUMBER_SCAN_LIMIT} most recent runs. It may be too old.`);
1731
+ return toPipelineDetail(ref, found);
1732
+ }
1733
+ async function fetchByBuildNumber(client, ref, buildNumber) {
1734
+ try {
1735
+ return await client.getJson(`${pipelinesPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
1736
+ } catch (err) {
1737
+ if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
1738
+ throw err;
1347
1739
  }
1348
1740
  }
1349
- function applyMarks(text, marks) {
1350
- if (text.length === 0) return text;
1351
- let out = text;
1352
- let href;
1353
- for (const mark of marks) switch (mark.type) {
1354
- case "code":
1355
- out = `\`${out}\``;
1356
- break;
1357
- case "strong":
1358
- out = `**${out}**`;
1359
- break;
1360
- case "em":
1361
- out = `*${out}*`;
1362
- break;
1363
- case "strike":
1364
- out = `~~${out}~~`;
1365
- break;
1366
- case "link": {
1367
- const value = mark.attrs?.["href"];
1368
- if (typeof value === "string") href = value;
1369
- break;
1370
- }
1371
- default: break;
1741
+ async function findInRecentRuns(client, ref, buildNumber) {
1742
+ const first = `${pipelinesPath(ref)}?${pipelinesQuery(100)}`;
1743
+ let scanned = 0;
1744
+ for await (const pipeline of walkPages(client, first)) {
1745
+ if (pipeline.build_number === buildNumber) return pipeline;
1746
+ if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
1372
1747
  }
1373
- if (href) out = `[${out}](${href})`;
1748
+ return null;
1749
+ }
1750
+ async function listSteps(client, ref, pipelineId) {
1751
+ const out = [];
1752
+ const first = `${pipelinesPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
1753
+ for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
1374
1754
  return out;
1375
1755
  }
1376
- function formatDate(timestamp) {
1377
- const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
1378
- if (Number.isNaN(ms)) return "";
1379
- return new Date(ms).toISOString().slice(0, 10);
1756
+ //#endregion
1757
+ //#region src/commands/bitbucket.ts
1758
+ async function bitbucketPipelines({ session, term }, options) {
1759
+ const ref = resolveRepo(options.repo, session);
1760
+ const limit = parseLimit(options.limit);
1761
+ writeRows(term, pipelineRows(await withScopeHint(PIPELINE_SCOPE, () => listPipelines(session, ref, limit)), Date.now()), {
1762
+ json: options.json,
1763
+ empty: "No pipelines found."
1764
+ });
1765
+ }
1766
+ async function bitbucketPipeline({ session, term }, arg, options) {
1767
+ const buildNumber = parseBuildNumber(arg);
1768
+ const ref = resolveRepo(options.repo, session);
1769
+ const detail = await withScopeHint(PIPELINE_SCOPE, () => getPipeline(session, ref, buildNumber));
1770
+ const steps = await withScopeHint(PIPELINE_SCOPE, () => listSteps(session, ref, detail.uuid));
1771
+ term.out(formatPipelineDetail(detail, steps, Date.now()));
1772
+ }
1773
+ function pipelineRows(pipelines, nowMs) {
1774
+ return alignedRows(pipelines, nowMs, (p) => ({
1775
+ id: `#${p.buildNumber}`,
1776
+ url: p.url,
1777
+ label: p.status || "-",
1778
+ color: colorForBitbucketState(p.status),
1779
+ text: `${refWithCommit(p)} · ${formatDuration(p.durationSeconds)}`,
1780
+ timestamp: p.createdOn
1781
+ }));
1782
+ }
1783
+ function formatStepRows(steps) {
1784
+ const rows = steps.map((s) => ({
1785
+ name: s.name || "-",
1786
+ status: s.status || "-",
1787
+ dur: formatDuration(s.durationSeconds)
1788
+ }));
1789
+ const wn = Math.max(...rows.map((r) => r.name.length));
1790
+ const ws = Math.max(...rows.map((r) => r.status.length));
1791
+ return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
1792
+ }
1793
+ function formatPipelineDetail(detail, steps, nowMs) {
1794
+ const by = detail.creator ? ` by ${detail.creator}` : "";
1795
+ return [
1796
+ `Pipeline #${detail.buildNumber} ${detail.status || "-"}`,
1797
+ ...detail.repo ? [`Repo: ${detail.repo}`] : [],
1798
+ `Ref: ${refWithCommit(detail)}`,
1799
+ ...detail.trigger ? [`Trigger: ${detail.trigger}`] : [],
1800
+ `Duration: ${formatDuration(detail.durationSeconds)}`,
1801
+ `Created: ${relativeTime(detail.createdOn, nowMs)}${by}`,
1802
+ ...steps.length > 0 ? [
1803
+ "",
1804
+ "Steps:",
1805
+ ...formatStepRows(steps)
1806
+ ] : []
1807
+ ];
1808
+ }
1809
+ function refWithCommit(pipeline) {
1810
+ if (!pipeline.ref) return pipeline.commit || "-";
1811
+ return pipeline.commit ? `${pipeline.ref} (${pipeline.commit})` : pipeline.ref;
1812
+ }
1813
+ function parseBuildNumber(arg) {
1814
+ const raw = (arg ?? "").replace(/^#/, "").trim();
1815
+ if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
1816
+ return Number.parseInt(raw, 10);
1817
+ }
1818
+ //#endregion
1819
+ //#region src/cli/bitbucket.ts
1820
+ function registerBitbucket(bitbucket) {
1821
+ bitbucket.description("Bitbucket commands");
1822
+ bitbucket.command("login").description("Store Bitbucket workspace and API token").action(bareAction(bitbucketLogin));
1823
+ bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(bareAction(bitbucketLogout));
1824
+ bitbucket.command("status").description("Show the current Bitbucket login").action(bareAction(bitbucketStatus));
1825
+ 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));
1826
+ 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));
1827
+ bitbucket.command("pr <id>").description("Show one pull request, its reviewers, files, and comments").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").option("--json", "output the pull request as JSON").action(bitbucketAction(bitbucketPr));
1828
+ 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));
1829
+ return bitbucket;
1830
+ }
1831
+ //#endregion
1832
+ //#region src/adf/from-markdown.ts
1833
+ function mediaNode(source, alt) {
1834
+ const attrs = { ...source };
1835
+ if (alt) attrs["alt"] = alt;
1836
+ return {
1837
+ type: "mediaSingle",
1838
+ attrs: { layout: "center" },
1839
+ content: [{
1840
+ type: "media",
1841
+ attrs
1842
+ }]
1843
+ };
1844
+ }
1845
+ function imageHrefs(md) {
1846
+ const hrefs = /* @__PURE__ */ new Set();
1847
+ marked.walkTokens(marked.lexer(md), (token) => {
1848
+ if (token.type === "image") hrefs.add(token.href);
1849
+ });
1850
+ return [...hrefs];
1851
+ }
1852
+ function markdownToAdf(md, options = {}) {
1853
+ const ctx = { resolveImage: options.resolveImage ?? externalMedia };
1854
+ return {
1855
+ type: "doc",
1856
+ version: 1,
1857
+ content: blocks(marked.lexer(md), ctx)
1858
+ };
1859
+ }
1860
+ function blocks(tokens, ctx) {
1861
+ const out = [];
1862
+ for (const token of tokens) out.push(...block(token, ctx));
1863
+ return out;
1864
+ }
1865
+ function block(token, ctx) {
1866
+ switch (token.type) {
1867
+ case "space": return [];
1868
+ case "heading": return [{
1869
+ type: "heading",
1870
+ attrs: { level: clampLevel(token.depth) },
1871
+ content: inline(token.tokens ?? [], ctx)
1872
+ }];
1873
+ case "paragraph": return paragraphsSplitAtImages(token.tokens ?? [], ctx);
1874
+ case "text": return paragraphsSplitAtImages(token.tokens ?? [textToken(token.text)], ctx);
1875
+ case "list": return [list(token, ctx)];
1876
+ case "code": return [codeBlock(token)];
1877
+ case "blockquote": return [{
1878
+ type: "blockquote",
1879
+ content: blocks(token.tokens ?? [], ctx)
1880
+ }];
1881
+ case "table": return [table(token, ctx)];
1882
+ case "hr": return [{ type: "rule" }];
1883
+ default: return [];
1884
+ }
1885
+ }
1886
+ function paragraphsSplitAtImages(tokens, ctx) {
1887
+ const out = [];
1888
+ let buffer = [];
1889
+ const flush = () => {
1890
+ if (buffer.length === 0) return;
1891
+ const nodes = inline(buffer, ctx);
1892
+ if (nodes.length > 0) out.push({
1893
+ type: "paragraph",
1894
+ content: nodes
1895
+ });
1896
+ buffer = [];
1897
+ };
1898
+ for (const token of tokens) if (token.type === "image") {
1899
+ flush();
1900
+ const image = token;
1901
+ const node = ctx.resolveImage(image.href, image.text ?? "");
1902
+ if (node) out.push(node);
1903
+ } else buffer.push(token);
1904
+ flush();
1905
+ return out;
1906
+ }
1907
+ function list(token, ctx) {
1908
+ if (token.items.length > 0 && token.items.every((i) => i.task)) return {
1909
+ type: "taskList",
1910
+ attrs: { localId: randomUUID() },
1911
+ content: token.items.map((item) => ({
1912
+ type: "taskItem",
1913
+ attrs: {
1914
+ localId: randomUUID(),
1915
+ state: item.checked ? "DONE" : "TODO"
1916
+ },
1917
+ content: inline(taskItemInline(item), ctx)
1918
+ }))
1919
+ };
1920
+ const node = {
1921
+ type: token.ordered ? "orderedList" : "bulletList",
1922
+ content: token.items.map((item) => listItem(item, ctx))
1923
+ };
1924
+ const start = Number(token.start);
1925
+ if (token.ordered && Number.isFinite(start) && start !== 1) node.attrs = { order: start };
1926
+ return node;
1927
+ }
1928
+ function listItem(item, ctx) {
1929
+ const children = blocks(item.tokens.filter((t) => t.type !== "checkbox"), ctx);
1930
+ return {
1931
+ type: "listItem",
1932
+ content: children.length > 0 ? children : [emptyParagraph()]
1933
+ };
1934
+ }
1935
+ function emptyParagraph() {
1936
+ return {
1937
+ type: "paragraph",
1938
+ content: []
1939
+ };
1940
+ }
1941
+ function taskItemInline(item) {
1942
+ const first = item.tokens.find((t) => t.type === "text");
1943
+ if (first && "tokens" in first && first.tokens) return first.tokens;
1944
+ return [textToken(item.text)];
1945
+ }
1946
+ function codeBlock(token) {
1947
+ return {
1948
+ type: "codeBlock",
1949
+ attrs: token.lang ? { language: token.lang } : {},
1950
+ content: token.text.length > 0 ? [{
1951
+ type: "text",
1952
+ text: token.text
1953
+ }] : []
1954
+ };
1955
+ }
1956
+ function table(token, ctx) {
1957
+ return {
1958
+ type: "table",
1959
+ content: [{
1960
+ type: "tableRow",
1961
+ content: token.header.map((cell) => tableCell(cell, "tableHeader", ctx))
1962
+ }, ...token.rows.map((row) => ({
1963
+ type: "tableRow",
1964
+ content: row.map((cell) => tableCell(cell, "tableCell", ctx))
1965
+ }))]
1966
+ };
1380
1967
  }
1381
- //#endregion
1382
- //#region src/adf/to-markdown.ts
1383
- function adfToMarkdown(doc, options = {}) {
1384
- if (!doc) return "";
1385
- const ctx = { resolveMedia: options.resolveMedia };
1386
- return (doc.type === "doc" ? renderBlocks(doc.content ?? [], ctx, "") : renderBlock(doc, ctx, "")).trim();
1968
+ function tableCell(cell, type, ctx) {
1969
+ return {
1970
+ type,
1971
+ content: [{
1972
+ type: "paragraph",
1973
+ content: inline(cell.tokens, ctx)
1974
+ }]
1975
+ };
1387
1976
  }
1388
- function renderBlocks(nodes, ctx, indent) {
1389
- return nodes.map((n) => renderBlock(n, ctx, indent)).filter((s) => s.length > 0).join("\n\n");
1977
+ function inline(tokens, ctx, marks = []) {
1978
+ const out = [];
1979
+ for (const token of tokens) out.push(...inlineNode(token, ctx, marks));
1980
+ return out;
1390
1981
  }
1391
- function renderBlock(node, ctx, indent) {
1392
- switch (node.type) {
1393
- case "paragraph": return indent + renderInline(node.content ?? [], ctx);
1394
- case "heading": {
1395
- const level = clampLevel(node.attrs?.["level"]);
1396
- return `${"#".repeat(level)} ${renderInline(node.content ?? [], ctx)}`;
1982
+ function inlineNode(token, ctx, marks) {
1983
+ switch (token.type) {
1984
+ case "text":
1985
+ case "escape": {
1986
+ const t = token;
1987
+ if ("tokens" in t && t.tokens?.length) return inline(t.tokens, ctx, marks);
1988
+ return textNode(t.text, marks);
1397
1989
  }
1398
- case "bulletList": return renderList(node, ctx, indent, "bullet");
1399
- case "orderedList": return renderList(node, ctx, indent, "ordered");
1400
- case "taskList": return renderTaskList(node, ctx, indent);
1401
- case "decisionList": return renderDecisionList(node, ctx, indent);
1402
- case "codeBlock": return renderCodeBlock(node, indent);
1403
- case "blockquote": return prefixLines(renderBlocks(node.content ?? [], ctx, ""), `${indent}> `);
1404
- case "panel": return renderPanel(node, ctx, indent);
1405
- case "rule": return `${indent}---`;
1406
- case "table": return renderTable(node, ctx);
1407
- case "mediaSingle":
1408
- case "mediaGroup": return renderBlocks(node.content ?? [], ctx, indent);
1409
- case "media": return indent + renderMedia(node, ctx);
1410
- case "expand":
1411
- case "nestedExpand": return renderExpand(node, ctx, indent);
1412
- default:
1413
- if (node.content?.length) return renderBlocks(node.content, ctx, indent);
1414
- return node.text ? indent + node.text : "";
1990
+ case "strong": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strong" }));
1991
+ case "em": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "em" }));
1992
+ case "del": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strike" }));
1993
+ case "codespan": return textNode(token.text, withUniqueMark(marks, { type: "code" }));
1994
+ case "link": {
1995
+ const link = token;
1996
+ return inline(link.tokens, ctx, withUniqueMark(marks, {
1997
+ type: "link",
1998
+ attrs: { href: link.href }
1999
+ }));
2000
+ }
2001
+ case "br": return [{ type: "hardBreak" }];
2002
+ case "html": return textNode(token.text, marks);
2003
+ default: return "text" in token && token.text ? textNode(token.text, marks) : [];
1415
2004
  }
1416
2005
  }
1417
- function renderList(node, ctx, indent, kind) {
1418
- const start = kind === "ordered" ? toNumber(node.attrs?.["order"]) ?? 1 : 0;
1419
- return (node.content ?? []).filter((n) => n.type === "listItem").map((item, i) => {
1420
- return renderListItem(item, ctx, indent, kind === "ordered" ? `${start + i}. ` : "- ");
1421
- }).join("\n");
1422
- }
1423
- function renderListItem(item, ctx, indent, marker) {
1424
- const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
1425
- return (item.content ?? []).map((block, i) => i === 0 ? `${indent}${marker}${renderBlock(block, ctx, "")}` : renderBlock(block, ctx, continuationIndent)).join("\n");
2006
+ function textNode(text, marks) {
2007
+ if (text.length === 0) return [];
2008
+ const node = {
2009
+ type: "text",
2010
+ text
2011
+ };
2012
+ if (marks.length > 0) node.marks = marks;
2013
+ return [node];
1426
2014
  }
1427
- function renderTaskList(node, ctx, indent) {
1428
- return (node.content ?? []).filter((n) => n.type === "taskItem").map((item) => {
1429
- return `${indent}- [${item.attrs?.["state"] === "DONE" ? "x" : " "}] ${renderInline(item.content ?? [], ctx)}`;
1430
- }).join("\n");
2015
+ function withUniqueMark(marks, mark) {
2016
+ return [...marks.filter((m) => m.type !== mark.type), mark];
1431
2017
  }
1432
- function renderDecisionList(node, ctx, indent) {
1433
- return (node.content ?? []).filter((n) => n.type === "decisionItem").map((item) => `${indent}- (decision) ${renderInline(item.content ?? [], ctx)}`).join("\n");
2018
+ function externalMedia(href, alt) {
2019
+ return mediaNode({
2020
+ type: "external",
2021
+ url: href
2022
+ }, alt);
1434
2023
  }
1435
- function renderCodeBlock(node, indent) {
1436
- const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
1437
- const code = (node.content ?? []).map((n) => n.text ?? "").join("");
1438
- const fence = "```";
1439
- return prefixLines(`${fence}${lang}\n${code}\n${fence}`, indent);
2024
+ function textToken(text) {
2025
+ return {
2026
+ type: "text",
2027
+ raw: text,
2028
+ text,
2029
+ escaped: false
2030
+ };
1440
2031
  }
1441
- const PANEL_LABELS = {
1442
- info: "Info",
1443
- note: "Note",
1444
- warning: "Warning",
1445
- success: "Success",
1446
- error: "Error"
1447
- };
1448
- function renderPanel(node, ctx, indent) {
1449
- return prefixLines(`**${PANEL_LABELS[typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info"] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
2032
+ function clampLevel(value) {
2033
+ return Math.min(6, Math.max(1, Math.trunc(value) || 1));
1450
2034
  }
1451
- function renderExpand(node, ctx, indent) {
1452
- return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
2035
+ //#endregion
2036
+ //#region src/api/confluence-attachments.ts
2037
+ const UNKNOWN_SIZE = -1;
2038
+ async function listAttachments(client, id) {
2039
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
2040
+ filename: a.title ?? a.id,
2041
+ fileId: mediaNodeId(a),
2042
+ size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
2043
+ }));
1453
2044
  }
1454
- function renderTable(node, ctx) {
1455
- const rows = (node.content ?? []).filter((n) => n.type === "tableRow");
1456
- if (rows.length === 0) return "";
1457
- const grid = rows.map((row) => (row.content ?? []).map((cell) => renderCellOnOneLine(cell, ctx)));
1458
- const cols = Math.max(...grid.map((r) => r.length));
1459
- const pad = (r) => {
1460
- const copy = [...r];
1461
- while (copy.length < cols) copy.push("");
1462
- return copy;
1463
- };
1464
- const header = pad(grid[0] ?? []);
1465
- const lines = [`| ${header.join(" | ")} |`, `| ${header.map(() => "---").join(" | ")} |`];
1466
- for (const row of grid.slice(1)) lines.push(`| ${pad(row).join(" | ")} |`);
1467
- return lines.join("\n");
2045
+ async function uploadAttachment(client, pageId, filename, bytes) {
2046
+ return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
1468
2047
  }
1469
- function renderCellOnOneLine(cell, ctx) {
1470
- return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
2048
+ async function fileIdByListing(client, pageId, filename) {
2049
+ const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
2050
+ if (match) return match.fileId;
2051
+ throw new Error(`Upload of "${filename}" did not return a fileId.`);
1471
2052
  }
1472
- function prefixLines(text, prefix) {
1473
- return text.split("\n").map((line) => line.length > 0 ? prefix + line : prefix.trimEnd()).join("\n");
2053
+ async function fetchAttachments(client, id) {
2054
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
2055
+ mediaId: mediaNodeId(a),
2056
+ filename: a.title ?? a.id,
2057
+ url: withWikiContextPath(a.downloadLink ?? "")
2058
+ }));
1474
2059
  }
1475
- function clampLevel(value) {
1476
- const n = toNumber(value) ?? 1;
1477
- return Math.min(6, Math.max(1, n));
2060
+ function mediaNodeId(a) {
2061
+ return a.fileId ?? a.id;
1478
2062
  }
1479
- function toNumber(value) {
1480
- return typeof value === "number" ? value : void 0;
2063
+ function withWikiContextPath(link) {
2064
+ if (link.startsWith("http") || link.startsWith("/wiki")) return link;
2065
+ return `/wiki${link}`;
1481
2066
  }
1482
2067
  //#endregion
1483
- //#region src/markdown/highlight.ts
1484
- const HEADING = /^#{1,6} /;
1485
- const QUOTE = /^(\s*)>( |$)/;
1486
- const LIST = /^(\s*)(-|\d+\.) (\[[ x]\] )?/;
1487
- const RULE = /^\s*---$/;
1488
- const TABLE_ROW = /^\|.*\|$/;
1489
- const TABLE_SEPARATOR = /^\|( --- \|)+$/;
1490
- const CELL_DIVIDER = /(?<!\\)\|/;
1491
- const DETAILS_OPEN = /^(\s*)<details><summary>(.*)<\/summary>$/;
1492
- const DETAILS_CLOSE = /^\s*<\/details>$/;
1493
- const FENCE_OPEN = /^((?:\s|> |- |\d+\. )*)```(\S*)$/;
1494
- const FENCE = "```";
1495
- const INLINE = /(`[^`]+`)|\[([^\]]+)\]\(((?:[^()]|\([^()]*\))+)\)|\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(?!\*)(.+?)(?<!\*)\*(?!\*)|~~(.+?)~~/g;
1496
- const TOKEN_STYLES = {
1497
- keyword: kleur.magenta,
1498
- built_in: kleur.magenta,
1499
- literal: kleur.magenta,
1500
- string: kleur.green,
1501
- regexp: kleur.green,
1502
- addition: kleur.green,
1503
- number: kleur.yellow,
1504
- symbol: kleur.yellow,
1505
- meta: kleur.yellow,
1506
- attr: kleur.yellow,
1507
- comment: kleur.dim,
1508
- quote: kleur.dim,
1509
- deletion: kleur.dim,
1510
- title: kleur.cyan,
1511
- type: kleur.cyan,
1512
- section: kleur.cyan
1513
- };
1514
- const lowlight = createLowlight(common);
1515
- function highlightMarkdown(md) {
1516
- const lines = md.split("\n");
1517
- const out = [];
1518
- for (let i = 0; i < lines.length; i++) {
1519
- const line = lines[i] ?? "";
1520
- const fence = line.match(FENCE_OPEN);
1521
- if (!fence) {
1522
- out.push(highlightLine(line, lines[i + 1]));
1523
- continue;
1524
- }
1525
- const [, prefix = "", lang = ""] = fence;
1526
- const styledPrefix = prefix.replace(/\S+/g, (marker) => kleur.dim(marker));
1527
- out.push(`${styledPrefix}${kleur.dim(`${FENCE}${lang}`)}`);
1528
- const code = [];
1529
- const prefixed = [];
1530
- let j = i + 1;
1531
- for (; j < lines.length; j++) {
1532
- const raw = lines[j] ?? "";
1533
- const hasPrefix = raw.startsWith(prefix) || raw === prefix.trimEnd();
1534
- const body = raw.startsWith(prefix) ? raw.slice(prefix.length) : hasPrefix ? "" : raw;
1535
- if (body === FENCE) break;
1536
- code.push(body);
1537
- prefixed.push(hasPrefix);
1538
- }
1539
- if (code.length > 0) highlightCode(code.join("\n"), lang).split("\n").forEach((codeLine, k) => {
1540
- const lead = prefixed[k] ? styledPrefix : "";
1541
- out.push(codeLine ? `${lead}${codeLine}` : lead.trimEnd());
1542
- });
1543
- if (j < lines.length) {
1544
- const lead = (lines[j] ?? "").startsWith(prefix) ? styledPrefix : "";
1545
- out.push(`${lead}${kleur.dim(FENCE)}`);
1546
- }
1547
- i = j;
1548
- }
1549
- return out.join("\n");
2068
+ //#region src/api/confluence-pages.ts
2069
+ async function fetchPage(client, site, id) {
2070
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
2071
+ const names = new UserNames(client);
2072
+ const [spaceKey, attachments, comments, author] = await Promise.all([
2073
+ fetchSpaceKey(client, page.spaceId),
2074
+ fetchAttachments(client, id),
2075
+ fetchComments$1(client, id, names),
2076
+ names.resolve(page.version?.authorId ?? page.authorId)
2077
+ ]);
2078
+ const webui = page._links?.webui ?? "";
2079
+ return {
2080
+ id: page.id,
2081
+ title: page.title,
2082
+ spaceKey,
2083
+ version: page.version?.number ?? 0,
2084
+ author,
2085
+ createdAt: page.createdAt ?? "",
2086
+ updatedAt: page.version?.createdAt ?? "",
2087
+ url: webui ? `${site}/wiki${webui}` : "",
2088
+ body: parseAdfJson(page.body?.atlas_doc_format?.value),
2089
+ attachments,
2090
+ comments
2091
+ };
1550
2092
  }
1551
- function highlightCode(code, lang) {
1552
- if (!lang || !lowlight.registered(lang)) return styleEachLine(code, kleur.yellow);
1553
- return renderTree(lowlight.highlight(lang, code), (text) => text);
2093
+ async function fetchPageState(client, id) {
2094
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
2095
+ return {
2096
+ version: page.version?.number ?? 0,
2097
+ title: page.title,
2098
+ body: parseAdfJson(page.body?.atlas_doc_format?.value)
2099
+ };
1554
2100
  }
1555
- function renderTree(node, style) {
1556
- if (node.type === "text") return styleEachLine(node.value, style);
1557
- if (node.type !== "root" && node.type !== "element") return "";
1558
- const own = node.type === "element" ? tokenStyle(node.properties["className"]) : void 0;
1559
- const next = own ? (text) => style(own(text)) : style;
1560
- return node.children.map((child) => renderTree(child, next)).join("");
2101
+ async function updatePage(client, id, params) {
2102
+ return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
2103
+ id,
2104
+ status: "current",
2105
+ title: params.title,
2106
+ body: {
2107
+ representation: "atlas_doc_format",
2108
+ value: JSON.stringify(params.body)
2109
+ },
2110
+ version: {
2111
+ number: params.nextVersion,
2112
+ message: params.message
2113
+ }
2114
+ })).version?.number ?? params.nextVersion;
1561
2115
  }
1562
- function tokenStyle(className) {
1563
- if (!Array.isArray(className)) return void 0;
1564
- for (const name of className) {
1565
- if (typeof name !== "string" || !name.startsWith("hljs-")) continue;
1566
- const style = TOKEN_STYLES[name.slice(5)];
1567
- if (style) return style;
2116
+ async function fetchSpaceKey(client, spaceId) {
2117
+ if (!spaceId) return "";
2118
+ try {
2119
+ return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
2120
+ } catch {
2121
+ return "";
1568
2122
  }
1569
2123
  }
1570
- function styleEachLine(text, style) {
1571
- return text.split("\n").map((line) => line ? style(line) : line).join("\n");
2124
+ async function fetchComments$1(client, id, names) {
2125
+ const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
2126
+ return Promise.all(res.results.map(async (c) => ({
2127
+ author: await names.resolve(c.version?.authorId),
2128
+ created: c.version?.createdAt ?? "",
2129
+ body: parseAdfJson(c.body?.atlas_doc_format?.value)
2130
+ })));
1572
2131
  }
1573
- function highlightLine(line, nextLine) {
1574
- const quote = line.match(QUOTE);
1575
- if (quote) {
1576
- const [whole, indent = ""] = quote;
1577
- const rest = line.slice(whole.length);
1578
- const gap = whole.endsWith(" ") ? " " : "";
1579
- return `${indent}${kleur.dim(">")}${gap}${highlightLine(rest, stripPrefix(nextLine, whole))}`;
2132
+ function parseAdfJson(value) {
2133
+ if (!value) return null;
2134
+ try {
2135
+ return JSON.parse(value);
2136
+ } catch {
2137
+ return null;
1580
2138
  }
1581
- const list = line.match(LIST);
1582
- if (list) {
1583
- const [whole, indent = "", marker = "", checkbox] = list;
1584
- const rest = line.slice(whole.length);
1585
- const box = checkbox ? `${kleur.bold(checkbox.trim())} ` : "";
1586
- return `${indent}${kleur.dim(marker)} ${box}${highlightLine(rest, void 0)}`;
2139
+ }
2140
+ var UserNames = class {
2141
+ client;
2142
+ cache = /* @__PURE__ */ new Map();
2143
+ constructor(client) {
2144
+ this.client = client;
1587
2145
  }
1588
- if (HEADING.test(line)) return kleur.bold().cyan(line);
1589
- if (RULE.test(line)) return kleur.dim(line);
1590
- if (TABLE_SEPARATOR.test(line)) return kleur.dim(line);
1591
- if (TABLE_ROW.test(line)) return tableRow(line, nextLine !== void 0 && TABLE_SEPARATOR.test(nextLine));
1592
- const details = line.match(DETAILS_OPEN);
1593
- if (details) {
1594
- const [, indent = "", title = ""] = details;
1595
- return `${indent}${kleur.dim("<details><summary>")}${highlightInline(title)}${kleur.dim("</summary>")}`;
2146
+ async resolve(accountId) {
2147
+ if (!accountId) return "";
2148
+ const cached = this.cache.get(accountId);
2149
+ if (cached !== void 0) return cached;
2150
+ const name = await this.fetchDisplayName(accountId) ?? accountId;
2151
+ this.cache.set(accountId, name);
2152
+ return name;
1596
2153
  }
1597
- if (DETAILS_CLOSE.test(line)) return kleur.dim(line);
1598
- return highlightInline(line);
1599
- }
1600
- function stripPrefix(line, prefix) {
1601
- return line?.startsWith(prefix) ? line.slice(prefix.length) : void 0;
1602
- }
1603
- function tableRow(line, header) {
1604
- const styled = line.split(CELL_DIVIDER).slice(1, -1).map((cell) => header ? kleur.bold(highlightInline(cell)) : highlightInline(cell));
1605
- const pipe = kleur.dim("|");
1606
- return `${pipe}${styled.join(pipe)}${pipe}`;
1607
- }
1608
- function highlightInline(text) {
1609
- return text.replace(INLINE, (match, code, linkText, url, boldItalic, bold, italic, strike) => {
1610
- if (code !== void 0) return kleur.yellow(code);
1611
- if (linkText !== void 0 && url !== void 0) return link(linkText, url);
1612
- if (boldItalic !== void 0) return kleur.bold().italic(match);
1613
- if (bold !== void 0) return kleur.bold(`**${highlightInline(bold)}**`);
1614
- if (italic !== void 0) return kleur.italic(`*${highlightInline(italic)}*`);
1615
- if (strike !== void 0) return kleur.strikethrough(`~~${highlightInline(strike)}~~`);
1616
- return match;
2154
+ async fetchDisplayName(accountId) {
2155
+ try {
2156
+ return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
2157
+ } catch {
2158
+ return null;
2159
+ }
2160
+ }
2161
+ };
2162
+ //#endregion
2163
+ //#region src/util/html.ts
2164
+ const MAX_CODE_POINT = 1114111;
2165
+ const NAMED = {
2166
+ amp: "&",
2167
+ lt: "<",
2168
+ gt: ">",
2169
+ quot: "\"",
2170
+ apos: "'",
2171
+ nbsp: " "
2172
+ };
2173
+ function decodeEntities(text) {
2174
+ return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
2175
+ if (body[0] === "#") {
2176
+ const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
2177
+ return isCodePoint(code) ? String.fromCodePoint(code) : match;
2178
+ }
2179
+ return NAMED[body.toLowerCase()] ?? match;
1617
2180
  });
1618
2181
  }
1619
- function link(text, url) {
1620
- const open = kleur.dim("[");
1621
- const middle = kleur.dim("](");
1622
- return `${open}${highlightInline(text)}${middle}${kleur.dim().underline(url)}${kleur.dim(")")}`;
2182
+ function isCodePoint(code) {
2183
+ return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
1623
2184
  }
1624
2185
  //#endregion
1625
- //#region src/commands/view.ts
1626
- function fieldLines(pairs) {
1627
- const kept = pairs.filter(([, value]) => value !== "");
1628
- const width = Math.max(...kept.map(([label]) => label.length)) + 1;
1629
- return kept.map(([label, value]) => `${kleur.dim(`${label}:`.padEnd(width))} ${value}`);
1630
- }
1631
- function dateWithAge(iso, nowMs) {
1632
- if (!iso) return "";
1633
- return `${formatDateTime(iso).slice(0, 10)} (${relativeTime(iso, nowMs)})`;
1634
- }
1635
- function bodyLines(body) {
1636
- const md = adfToMarkdown(body);
1637
- return md ? ["", highlightMarkdown(md)] : [];
2186
+ //#region src/api/confluence-search.ts
2187
+ async function searchPages(client, site, params) {
2188
+ const cql = buildCql(params);
2189
+ const query = new URLSearchParams({
2190
+ cql,
2191
+ limit: String(params.limit),
2192
+ expand: "space"
2193
+ });
2194
+ const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
2195
+ const serverPageWasFull = results.length === params.limit;
2196
+ return {
2197
+ pages: results.filter((r) => r.content?.id).map((r) => ({
2198
+ id: r.content?.id ?? "",
2199
+ space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
2200
+ title: decodeEntities(r.content?.title ?? r.title ?? ""),
2201
+ updated: r.lastModified ?? "",
2202
+ url: r.url ? `${site}/wiki${r.url}` : ""
2203
+ })),
2204
+ hasMore: serverPageWasFull
2205
+ };
1638
2206
  }
1639
- function commentSection(comments, allComments) {
1640
- if (comments.length === 0) return [];
1641
- const visible = allComments ? comments : comments.slice(-5);
1642
- const heading = comments.length - visible.length > 0 ? `Comments (${comments.length}, showing last ${visible.length} — --all-comments for all)` : `Comments (${comments.length})`;
1643
- const lines = ["", kleur.bold(heading)];
1644
- for (const comment of visible) {
1645
- const body = adfToMarkdown(comment.body);
1646
- lines.push("", commentHeader(comment), ...body ? [highlightMarkdown(body)] : []);
1647
- }
1648
- return lines;
2207
+ function buildCql(params) {
2208
+ if (params.cql) return params.cql;
2209
+ const clauses = ["type = page"];
2210
+ if (params.starred) clauses.push("favourite = currentUser()");
2211
+ if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
2212
+ if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
2213
+ return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
1649
2214
  }
1650
- function commentHeader(comment) {
1651
- return `─ ${kleur.bold(comment.author || "Unknown")}${comment.created ? ` · ${kleur.dim(formatDateTime(comment.created))}` : ""}`;
2215
+ function cqlStringLiteral(value) {
2216
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1652
2217
  }
1653
- function attachmentSection(attachments) {
1654
- if (attachments.length === 0) return [];
1655
- return [
1656
- "",
1657
- kleur.bold("Attachments"),
1658
- ...attachments.map((a) => `- ${a.filename}`)
1659
- ];
2218
+ //#endregion
2219
+ //#region src/commands/resolve-ref.ts
2220
+ async function resolveRef(ask, arg, kind) {
2221
+ const raw = arg ?? await ask.text({
2222
+ message: kind.message,
2223
+ flag: kind.flag,
2224
+ required: true
2225
+ });
2226
+ const ref = kind.parse(raw);
2227
+ if (!ref) throw new Error(kind.notFound(raw));
2228
+ return ref;
1660
2229
  }
1661
2230
  //#endregion
1662
2231
  //#region src/api/attachments.ts
@@ -1732,9 +2301,10 @@ function scalarString(value) {
1732
2301
  }
1733
2302
  function frontmatter(fields) {
1734
2303
  const lines = ["---"];
1735
- for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) if (value.length === 0) lines.push(`${key}: []`);
1736
- else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
1737
- else if (typeof value === "number") lines.push(`${key}: ${value}`);
2304
+ for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) {
2305
+ if (value.length === 0) lines.push(`${key}: []`);
2306
+ else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
2307
+ } else if (typeof value === "number") lines.push(`${key}: ${value}`);
1738
2308
  else lines.push(`${key}: ${quote(value)}`);
1739
2309
  lines.push("---");
1740
2310
  return lines.join("\n");
@@ -1780,21 +2350,23 @@ function leadingH1(lines) {
1780
2350
  }
1781
2351
  function commentsSection(comments) {
1782
2352
  if (comments.length === 0) return "";
2353
+ const blocks = comments.map((c) => {
2354
+ const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
2355
+ return c.body ? `${heading}\n\n${c.body}` : heading;
2356
+ });
1783
2357
  return [
1784
2358
  COMMENTS_HEADING,
1785
2359
  "",
1786
- comments.map((c) => {
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")
2360
+ blocks.join("\n\n")
1790
2361
  ].join("\n");
1791
2362
  }
1792
2363
  function attachmentsSection(attachments) {
1793
2364
  if (attachments.length === 0) return "";
2365
+ const items = attachments.map((a) => `- [${a.filename}](${a.relativePath})`);
1794
2366
  return [
1795
2367
  ATTACHMENTS_HEADING,
1796
2368
  "",
1797
- ...attachments.map((a) => `- [${a.filename}](${a.relativePath})`)
2369
+ ...items
1798
2370
  ].join("\n");
1799
2371
  }
1800
2372
  //#endregion
@@ -1859,7 +2431,6 @@ function planCopy(source, out) {
1859
2431
  return {
1860
2432
  ...content,
1861
2433
  filePath: target.filePath,
1862
- assetsDir: target.assetsDir,
1863
2434
  downloads: planDownloads(attachments, target)
1864
2435
  };
1865
2436
  }
@@ -1905,21 +2476,18 @@ function numberedName(name, suffix) {
1905
2476
  }
1906
2477
  //#endregion
1907
2478
  //#region src/copy/run.ts
1908
- async function runCopy(plan, fetchBytes) {
1909
- const landed = await download(plan, fetchBytes);
1910
- await mkdir(dirname(plan.filePath), { recursive: true });
1911
- await writeFile(plan.filePath, renderCopy(plan, landed), "utf8");
1912
- console.log(wroteLine(plan.filePath, landed.length));
1913
- }
1914
- async function download(plan, fetchBytes) {
1915
- if (plan.downloads.length === 0) return [];
1916
- await mkdir(plan.assetsDir, { recursive: true });
2479
+ async function runCopy(env, plan) {
2480
+ const landed = await download(env, plan);
2481
+ await env.files.writeText(plan.filePath, renderCopy(plan, landed));
2482
+ env.term.out(wroteLine(plan.filePath, landed.length));
2483
+ }
2484
+ async function download({ term, files, session }, plan) {
1917
2485
  const landed = [];
1918
2486
  for (const { path, ...attachment } of plan.downloads) try {
1919
- await writeFile(path, await fetchBytes(attachment.url));
2487
+ await files.writeBytes(path, await session.getBinary(attachment.url));
1920
2488
  landed.push(attachment);
1921
2489
  } catch (err) {
1922
- console.warn(` ! could not download ${attachment.filename}: ${err.message}`);
2490
+ term.err(` ! could not download ${attachment.filename}: ${err.message}`);
1923
2491
  }
1924
2492
  return landed;
1925
2493
  }
@@ -2136,80 +2704,31 @@ function withUploadedId(node, ids) {
2136
2704
  }
2137
2705
  //#endregion
2138
2706
  //#region src/update/run.ts
2139
- async function runPlan(plan, options, push) {
2707
+ async function runPlan(term, plan, options, push) {
2140
2708
  if (options.dryRun) {
2141
- for (const line of formatPlan(plan)) console.log(line);
2709
+ term.out(formatPlan(plan));
2142
2710
  return;
2143
2711
  }
2144
2712
  const verdict = plan.verdict;
2145
2713
  if (verdict.kind === "refuse") throw new Error(verdict.message);
2146
2714
  if (verdict.kind === "confirm") {
2147
- if (!await confirm({
2715
+ if (!await term.ask.confirm({
2148
2716
  message: verdict.message,
2717
+ flag: "--force",
2149
2718
  default: false
2150
2719
  })) {
2151
- console.log("Aborted.");
2720
+ term.out("Aborted.");
2152
2721
  return;
2153
2722
  }
2154
2723
  }
2155
2724
  await push();
2156
2725
  }
2157
2726
  //#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
2727
  //#region src/commands/confluence.ts
2209
- async function confluenceView(arg, options) {
2210
- const auth = await requireAuth();
2211
- const id = await resolveRef(arg, PAGE_REF);
2212
- await printPaged(formatPageView(await fetchPage(new AtlassianClient(auth), auth.site, id), Date.now(), options.allComments ?? false).join("\n"), { pager: options.pager });
2728
+ async function confluenceView({ session, term }, arg, options) {
2729
+ const id = await resolveRef(term.ask, arg, PAGE_REF);
2730
+ const lines = formatPageView(await fetchPage(session, session.site, id), Date.now(), options.allComments ?? false);
2731
+ await term.page(lines.join("\n"), { pager: options.pager });
2213
2732
  }
2214
2733
  function formatPageView(page, nowMs, allComments) {
2215
2734
  return [
@@ -2224,41 +2743,39 @@ function formatPageView(page, nowMs, allComments) {
2224
2743
  ["URL", page.url]
2225
2744
  ]),
2226
2745
  ...bodyLines(page.body),
2227
- ...commentSection(page.comments, allComments),
2746
+ ...commentSection(renderedComments(page.comments), { allComments }),
2228
2747
  ...attachmentSection(page.attachments)
2229
2748
  ];
2230
2749
  }
2231
- async function confluenceCopy(arg, options) {
2232
- const auth = await requireAuth();
2233
- const id = await resolveRef(arg, PAGE_REF);
2234
- await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
2750
+ async function confluenceCopy(env, arg, options) {
2751
+ await copyPage(env, await resolveRef(env.term.ask, arg, PAGE_REF), options.out);
2235
2752
  }
2236
- async function confluenceUpdate(arg, options) {
2237
- const file = arg ?? await input({
2753
+ async function confluenceUpdate({ session, term, files }, arg, options) {
2754
+ const file = arg ?? await term.ask.text({
2238
2755
  message: "Path to the page Markdown file:",
2756
+ flag: "[file]",
2239
2757
  required: true
2240
2758
  });
2241
- const src = parsePageSource(await readFile(file, "utf8"));
2242
- const client = new AtlassianClient(await requireAuth());
2243
- const state = await fetchPageState(client, src.id);
2244
- const plan = planPageUpdate(src, state, await listAttachments(client, src.id), await statLocalImages(dirname(resolve(file)), src.body), options);
2245
- await runPlan(plan, options, async () => {
2759
+ const src = parsePageSource(await files.readText(file));
2760
+ const state = await fetchPageState(session, src.id);
2761
+ const plan = planPageUpdate(src, state, await listAttachments(session, src.id), await statLocalImages(files, dirname(resolve(file)), src.body), options);
2762
+ await runPlan(term, plan, options, async () => {
2246
2763
  const ids = /* @__PURE__ */ new Map();
2247
2764
  for (const upload of plan.uploads) {
2248
- console.log(`Uploading ${upload.filename} ...`);
2249
- const bytes = await readFile(upload.path);
2250
- ids.set(upload.href, await uploadAttachment(client, src.id, upload.filename, bytes));
2765
+ term.err(`Uploading ${upload.filename} ...`);
2766
+ const bytes = await files.readBytes(upload.path);
2767
+ ids.set(upload.href, await uploadAttachment(session, src.id, upload.filename, bytes));
2251
2768
  }
2252
- const version = await updatePage(client, src.id, {
2769
+ const version = await updatePage(session, src.id, {
2253
2770
  title: plan.headline.next,
2254
2771
  nextVersion: state.version + 1,
2255
2772
  body: withUploadedIds(plan.body, ids),
2256
2773
  message: options.message ?? "Updated via atlass"
2257
2774
  });
2258
- console.log(`Updated page ${src.id} to version ${version}.`);
2775
+ term.out(`Updated page ${src.id} to version ${version}.`);
2259
2776
  });
2260
2777
  }
2261
- async function statLocalImages(dir, md) {
2778
+ async function statLocalImages(files, dir, md) {
2262
2779
  const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
2263
2780
  return Promise.all(hrefs.map(async (href) => {
2264
2781
  const path = isAbsolute(href) ? href : resolve(dir, href);
@@ -2266,47 +2783,46 @@ async function statLocalImages(dir, md) {
2266
2783
  href,
2267
2784
  path,
2268
2785
  filename: basename(path),
2269
- ...await fileSize(path)
2786
+ ...await fileSize(files, path)
2270
2787
  };
2271
2788
  }));
2272
2789
  }
2273
- async function fileSize(path) {
2790
+ async function fileSize(files, path) {
2274
2791
  try {
2275
- return { size: (await stat(path)).size };
2792
+ return { size: await files.size(path) };
2276
2793
  } catch {
2277
2794
  return {};
2278
2795
  }
2279
2796
  }
2280
- async function confluenceSearch(query, options) {
2797
+ async function confluenceSearch(env, query, options) {
2281
2798
  if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
2282
- await listPages({
2799
+ await listPages(env, {
2283
2800
  text: query,
2284
2801
  space: options.space,
2285
2802
  cql: options.cql
2286
2803
  }, "No matching pages.", options);
2287
2804
  }
2288
- async function confluenceList(options) {
2289
- await listPages({
2805
+ async function confluenceList(env, options) {
2806
+ await listPages(env, {
2290
2807
  starred: true,
2291
2808
  space: options.space
2292
2809
  }, options.space ? `No starred pages in ${options.space}.` : "No starred pages.", options);
2293
2810
  }
2294
- async function listPages(filter, empty, options) {
2811
+ async function listPages(env, filter, empty, options) {
2812
+ const { session, term } = env;
2295
2813
  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
2814
  const limit = parseLimit(options.limit);
2299
- const { pages, hasMore } = await searchPages(client, auth.site, {
2815
+ const { pages, hasMore } = await searchPages(session, session.site, {
2300
2816
  ...filter,
2301
2817
  limit
2302
2818
  });
2303
- await runSearch(formatPageRows(pages, Date.now()), {
2819
+ await runSearch(term, formatPageRows(pages, Date.now()), {
2304
2820
  json: options.json,
2305
2821
  copy: options.copy,
2306
2822
  out: options.out,
2307
2823
  empty,
2308
2824
  footer: hasMore ? searchFooter(limit) : void 0
2309
- }, PAGE_NOUN, (id) => copyPage(client, auth.site, id, options.out));
2825
+ }, PAGE_NOUN, (id) => copyPage(env, id, options.out));
2310
2826
  }
2311
2827
  function formatPageRows(pages, nowMs) {
2312
2828
  return alignedRows(pages, nowMs, (p) => ({
@@ -2334,12 +2850,13 @@ const PAGE_NOUN = {
2334
2850
  singular: "page",
2335
2851
  plural: "pages"
2336
2852
  };
2337
- async function copyPage(client, site, id, out) {
2338
- console.log(`Fetching page ${id} ...`);
2339
- await runCopy(planPageCopy(await fetchPage(client, site, id), out), (url) => client.getBinary(url));
2853
+ async function copyPage(env, id, out) {
2854
+ env.term.err(`Fetching page ${id} ...`);
2855
+ await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
2340
2856
  }
2341
2857
  const PAGE_REF = {
2342
2858
  message: "Confluence page id or URL:",
2859
+ flag: "[page]",
2343
2860
  parse: parsePageId,
2344
2861
  notFound: (raw) => `Could not find a page id in "${raw}".`
2345
2862
  };
@@ -2347,11 +2864,11 @@ const PAGE_REF = {
2347
2864
  //#region src/cli/confluence.ts
2348
2865
  function registerConfluence(confluence) {
2349
2866
  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(run(confluenceView));
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(run(confluenceCopy));
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(run(confluenceUpdate));
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(run(confluenceList));
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(run(confluenceSearch));
2867
+ 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));
2868
+ 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));
2869
+ 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));
2870
+ 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));
2871
+ 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
2872
  return confluence;
2356
2873
  }
2357
2874
  //#endregion
@@ -2462,10 +2979,10 @@ function projectSearchQuery(query, startAt) {
2462
2979
  }
2463
2980
  //#endregion
2464
2981
  //#region src/commands/jira-create-resolve.ts
2465
- async function resolveProject(client, site, arg, strict) {
2982
+ async function resolveProject(ask, client, site, arg, strict) {
2466
2983
  if (arg) return arg.toUpperCase();
2467
2984
  if (strict) throw new Error("A project key is required, e.g. `jira create BSC Bug --summary ...`.");
2468
- return search({
2985
+ return ask.searchPick({
2469
2986
  message: "Project:",
2470
2987
  source: async (term) => (await listProjects(client, site, term)).map((p) => ({
2471
2988
  name: `${p.key} ${p.name}`,
@@ -2474,7 +2991,7 @@ async function resolveProject(client, site, arg, strict) {
2474
2991
  pageSize: 15
2475
2992
  });
2476
2993
  }
2477
- async function resolveType(project, types, arg, strict) {
2994
+ async function resolveType(ask, project, types, arg, strict) {
2478
2995
  if (types.length === 0) throw new Error(`You cannot create issues in ${project}.`);
2479
2996
  if (arg) {
2480
2997
  const found = matchType(types, arg);
@@ -2483,7 +3000,7 @@ async function resolveType(project, types, arg, strict) {
2483
3000
  throw new Error(`${project} has no issue type "${arg}"; available: ${names}.`);
2484
3001
  }
2485
3002
  if (strict) throw new Error(`An issue type is required; ${project} has: ${types.map((t) => t.name).join(", ")}.`);
2486
- return select({
3003
+ return ask.pick({
2487
3004
  message: "Issue type:",
2488
3005
  choices: types.map((t) => ({
2489
3006
  name: t.name,
@@ -2548,7 +3065,7 @@ function matchCascading(field, value) {
2548
3065
  }
2549
3066
  //#endregion
2550
3067
  //#region src/create/encode.ts
2551
- const SERVER_FILLED = new Set([
3068
+ const SERVER_FILLED = /* @__PURE__ */ new Set([
2552
3069
  "project",
2553
3070
  "issuetype",
2554
3071
  "reporter"
@@ -2628,7 +3145,7 @@ async function encodeField(field, values, resolveUser) {
2628
3145
  }
2629
3146
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
2630
3147
  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([
3148
+ const NAMED_TYPES = /* @__PURE__ */ new Set([
2632
3149
  "priority",
2633
3150
  "version",
2634
3151
  "component",
@@ -2809,16 +3326,16 @@ function formatIssueTypeRows(types) {
2809
3326
  //#endregion
2810
3327
  //#region src/create/prompt.ts
2811
3328
  const PROMPT_SOURCE = "prompt";
2812
- async function walkFields(meta, deps) {
3329
+ async function walkFields(ask, meta, deps) {
2813
3330
  const firstPass = meta.filter((f) => f.required && !SERVER_FILLED.has(f.fieldId));
2814
3331
  const inputs = [];
2815
3332
  for (const field of firstPass) {
2816
- const answer = await promptField(field, deps, true);
3333
+ const answer = await promptField(ask, field, deps, true);
2817
3334
  if (answer) inputs.push(answer);
2818
3335
  }
2819
3336
  const optional = meta.filter((f) => !firstPass.includes(f) && !SERVER_FILLED.has(f.fieldId));
2820
3337
  if (optional.length === 0) return inputs;
2821
- const chosen = await checkbox({
3338
+ const chosen = await ask.pickMany({
2822
3339
  message: "Set any optional fields?",
2823
3340
  choices: optional.map((f) => ({
2824
3341
  name: `${f.name} (${fieldTypeLabel(f.schema)})`,
@@ -2827,13 +3344,13 @@ async function walkFields(meta, deps) {
2827
3344
  pageSize: 15
2828
3345
  });
2829
3346
  for (const field of optional.filter((f) => chosen.includes(f.fieldId))) {
2830
- const answer = await promptField(field, deps, false);
3347
+ const answer = await promptField(ask, field, deps, false);
2831
3348
  if (answer) inputs.push(answer);
2832
3349
  }
2833
3350
  return inputs;
2834
3351
  }
2835
- async function promptField(field, deps, required) {
2836
- const answer = await askValues(field, deps, required);
3352
+ async function promptField(ask, field, deps, required) {
3353
+ const answer = await askValues(ask, field, deps, required);
2837
3354
  if (answer.values.length === 0) return void 0;
2838
3355
  return {
2839
3356
  name: field.fieldId,
@@ -2846,14 +3363,14 @@ const SKIP = {
2846
3363
  name: "(skip)",
2847
3364
  value: ""
2848
3365
  };
2849
- async function askValues(field, deps, required) {
3366
+ async function askValues(ask, field, deps, required) {
2850
3367
  const { schema } = field;
2851
3368
  const message = `${field.name}:`;
2852
3369
  const options = field.allowedValues ?? [];
2853
- if (schema.type === "option-with-child") return askCascading(field, required);
3370
+ if (schema.type === "option-with-child") return askCascading(ask, field, required);
2854
3371
  if (options.length > 0 && schema.type === "array") {
2855
3372
  const defaults = new Set(defaultIds(field));
2856
- return { values: await checkbox({
3373
+ return { values: await ask.pickMany({
2857
3374
  message,
2858
3375
  choices: options.map((v) => ({
2859
3376
  ...choice(v),
@@ -2865,7 +3382,7 @@ async function askValues(field, deps, required) {
2865
3382
  }
2866
3383
  if (options.length > 0) {
2867
3384
  const choices = options.map(choice);
2868
- const picked = await select({
3385
+ const picked = await ask.pick({
2869
3386
  message,
2870
3387
  choices: required ? choices : [SKIP, ...choices],
2871
3388
  default: defaultIds(field)[0],
@@ -2873,9 +3390,9 @@ async function askValues(field, deps, required) {
2873
3390
  });
2874
3391
  return { values: picked ? [picked] : [] };
2875
3392
  }
2876
- if (schema.type === "user") return askUser(field, deps, required);
3393
+ if (schema.type === "user") return askUser(ask, field, deps, required);
2877
3394
  if (isMultiLine(schema)) {
2878
- const text = await editor({
3395
+ const text = await ask.edit({
2879
3396
  message: `${field.name} (opens your editor, Markdown):`,
2880
3397
  postfix: ".md",
2881
3398
  default: primitiveDefault(field),
@@ -2884,7 +3401,7 @@ async function askValues(field, deps, required) {
2884
3401
  return { values: text.trim() ? [text] : [] };
2885
3402
  }
2886
3403
  const hint = inputHint(field);
2887
- const text = await input({
3404
+ const text = await ask.text({
2888
3405
  message: hint ? `${field.name} (${hint}):` : message,
2889
3406
  default: primitiveDefault(field),
2890
3407
  validate: async (v) => {
@@ -2922,10 +3439,10 @@ function primitiveDefault(field) {
2922
3439
  if (!field.hasDefaultValue || d == null) return void 0;
2923
3440
  return typeof d === "string" || typeof d === "number" ? String(d) : void 0;
2924
3441
  }
2925
- async function askCascading(field, required) {
3442
+ async function askCascading(ask, field, required) {
2926
3443
  const options = field.allowedValues ?? [];
2927
3444
  const parentChoices = options.map(choice);
2928
- const parentId = await select({
3445
+ const parentId = await ask.pick({
2929
3446
  message: `${field.name}:`,
2930
3447
  choices: required ? parentChoices : [SKIP, ...parentChoices],
2931
3448
  pageSize: 15
@@ -2934,7 +3451,7 @@ async function askCascading(field, required) {
2934
3451
  const parent = options.find((v) => v.id === parentId);
2935
3452
  const children = parent?.children ?? [];
2936
3453
  if (children.length === 0) return { values: [parentId] };
2937
- const childId = await select({
3454
+ const childId = await ask.pick({
2938
3455
  message: `${field.name} > ${allowedLabel(parent)}:`,
2939
3456
  choices: [{
2940
3457
  name: "(none)",
@@ -2952,10 +3469,10 @@ const UNASSIGNED = {
2952
3469
  name: "(unassigned)",
2953
3470
  value: ""
2954
3471
  };
2955
- async function askUser(field, deps, required) {
3472
+ async function askUser(ask, field, deps, required) {
2956
3473
  const fixed = required ? [ME] : [ME, UNASSIGNED];
2957
3474
  const seen = /* @__PURE__ */ new Map();
2958
- const picked = await search({
3475
+ const picked = await ask.searchPick({
2959
3476
  message: `${field.name} (type to search):`,
2960
3477
  source: async (term) => {
2961
3478
  if (!term) return fixed;
@@ -2997,7 +3514,7 @@ function summarizeText(text) {
2997
3514
  }
2998
3515
  //#endregion
2999
3516
  //#region src/commands/jira-create.ts
3000
- async function flagInputs(options) {
3517
+ async function flagInputs(files, options) {
3001
3518
  const inputs = [];
3002
3519
  const one = (name, value, source) => {
3003
3520
  if (value !== void 0) inputs.push({
@@ -3010,7 +3527,7 @@ async function flagInputs(options) {
3010
3527
  one("description", options.description, "--description");
3011
3528
  if (options.descriptionFile !== void 0) {
3012
3529
  if (options.description !== void 0) throw new Error("--description and --description-file cannot be used together.");
3013
- one("description", await readFile(options.descriptionFile, "utf8"), "--description-file");
3530
+ one("description", await files.readText(options.descriptionFile), "--description-file");
3014
3531
  }
3015
3532
  one("assignee", options.assignee, "--assignee");
3016
3533
  one("priority", options.priority, "--priority");
@@ -3037,17 +3554,16 @@ function parseFieldFlag(raw) {
3037
3554
  source: "--field"
3038
3555
  };
3039
3556
  }
3040
- async function jiraCreate(projectArg, typeArg, options) {
3041
- const inputs = await flagInputs(options);
3042
- const strict = inputs.length > 0 || options.input === false || !process.stdin.isTTY;
3043
- const auth = await requireAuth();
3044
- const client = new AtlassianClient(auth);
3045
- const project = await resolveProject(client, auth.site, projectArg, strict);
3046
- const type = await resolveType(project, await fetchCreateIssueTypes(client, project), typeArg, strict);
3047
- const meta = await fetchCreateFields(client, project, type.id);
3048
- const resolveUser = userResolver(client, project);
3049
- if (!strict) inputs.push(...await walkFields(meta, {
3050
- searchUsers: (query) => searchAssignableUsers(client, project, query),
3557
+ async function jiraCreate({ session, term, files }, projectArg, typeArg, options) {
3558
+ const inputs = await flagInputs(files, options);
3559
+ const strict = inputs.length > 0 || !term.interactive;
3560
+ const project = await resolveProject(term.ask, session, session.site, projectArg, strict);
3561
+ const types = await fetchCreateIssueTypes(session, project);
3562
+ const type = await resolveType(term.ask, project, types, typeArg, strict);
3563
+ const meta = await fetchCreateFields(session, project, type.id);
3564
+ const resolveUser = userResolver(session, project);
3565
+ if (!strict) inputs.push(...await walkFields(term.ask, meta, {
3566
+ searchUsers: (query) => searchAssignableUsers(session, project, query),
3051
3567
  validate: async (field, value) => {
3052
3568
  return (await encodeCreate([field], [{
3053
3569
  name: field.fieldId,
@@ -3065,23 +3581,23 @@ async function jiraCreate(projectArg, typeArg, options) {
3065
3581
  ...result.fields
3066
3582
  };
3067
3583
  if (options.dryRun) {
3068
- console.log(JSON.stringify({ fields }, null, 2));
3584
+ term.json({ fields });
3069
3585
  return;
3070
3586
  }
3071
3587
  if (!strict) {
3072
- for (const line of formatReview(meta, inputs)) console.log(line);
3073
- if (!await confirm({
3588
+ term.out(formatReview(meta, inputs));
3589
+ if (!await term.ask.confirm({
3074
3590
  message: `Create ${project} ${type.name}?`,
3075
3591
  default: true
3076
3592
  })) {
3077
- console.log("Aborted.");
3593
+ term.out("Aborted.");
3078
3594
  return;
3079
3595
  }
3080
3596
  }
3081
3597
  try {
3082
- const created = await createIssue(client, auth.site, fields);
3083
- if (options.json) console.log(JSON.stringify(created, null, 2));
3084
- else console.log(`Created ${created.key} ${created.url}`);
3598
+ const created = await createIssue(session, session.site, fields);
3599
+ if (options.json) term.json(created);
3600
+ else term.out(`Created ${created.key} ${created.url}`);
3085
3601
  } catch (err) {
3086
3602
  throw describeRejection(err, meta);
3087
3603
  }
@@ -3107,19 +3623,18 @@ function describeRejection(err, meta) {
3107
3623
  ...perField
3108
3624
  ].join("\n"));
3109
3625
  }
3110
- async function jiraFields(projectArg, typeArg, options) {
3111
- const client = new AtlassianClient(await requireAuth());
3626
+ async function jiraFields({ session, term }, projectArg, typeArg, options) {
3112
3627
  const project = projectArg.toUpperCase();
3113
- const types = await fetchCreateIssueTypes(client, project);
3628
+ const types = await fetchCreateIssueTypes(session, project);
3114
3629
  if (!typeArg) {
3115
- if (options.json) console.log(JSON.stringify(types, null, 2));
3116
- else if (types.length === 0) console.log(`You cannot create issues in ${project}.`);
3117
- else for (const line of formatIssueTypeRows(types)) console.log(line);
3630
+ if (options.json) term.json(types);
3631
+ else if (types.length === 0) term.out(`You cannot create issues in ${project}.`);
3632
+ else term.out(formatIssueTypeRows(types));
3118
3633
  return;
3119
3634
  }
3120
- const fields = await fetchCreateFields(client, project, (await resolveType(project, types, typeArg, true)).id);
3121
- if (options.json) console.log(JSON.stringify(fields, null, 2));
3122
- else for (const line of formatFieldRows(fields)) console.log(line);
3635
+ const fields = await fetchCreateFields(session, project, (await resolveType(term.ask, project, types, typeArg, true)).id);
3636
+ if (options.json) term.json(fields);
3637
+ else term.out(formatFieldRows(fields));
3123
3638
  }
3124
3639
  //#endregion
3125
3640
  //#region src/api/jira-search.ts
@@ -3295,47 +3810,34 @@ function dedupeAndSortStatuses(statuses) {
3295
3810
  }
3296
3811
  //#endregion
3297
3812
  //#region src/commands/jira.ts
3298
- async function jiraProjects(query, options) {
3299
- const auth = await requireAuth();
3300
- const projects = await listProjects(new AtlassianClient(auth), auth.site, query);
3301
- if (options.json) {
3302
- console.log(JSON.stringify(projects, null, 2));
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);
3813
+ async function jiraProjects({ session, term }, query, options) {
3814
+ const projects = await listProjects(session, session.site, query);
3815
+ if (options.json) term.json(projects);
3816
+ else if (projects.length === 0) term.out("No matching projects.");
3817
+ else term.out(formatProjectRows(projects));
3310
3818
  }
3311
3819
  function formatProjectRows(projects) {
3312
3820
  const width = Math.max(...projects.map((p) => p.key.length));
3313
3821
  return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
3314
3822
  }
3315
- async function jiraStatuses(query, options) {
3316
- let statuses = await listStatuses(new AtlassianClient(await requireAuth()), options.project);
3823
+ async function jiraStatuses({ session, term }, query, options) {
3824
+ let statuses = await listStatuses(session, options.project);
3317
3825
  if (query) {
3318
3826
  const needle = query.toLowerCase();
3319
3827
  statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
3320
3828
  }
3321
- if (options.json) {
3322
- console.log(JSON.stringify(statuses, null, 2));
3323
- return;
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);
3829
+ if (options.json) term.json(statuses);
3830
+ else if (statuses.length === 0) term.out("No matching statuses.");
3831
+ else term.out(formatStatusRows(statuses));
3330
3832
  }
3331
3833
  function formatStatusRows(statuses) {
3332
3834
  const width = Math.max(...statuses.map((s) => s.name.length));
3333
3835
  return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
3334
3836
  }
3335
- async function jiraView(arg, options) {
3336
- const auth = await requireAuth();
3337
- const key = await resolveRef(arg, ISSUE_REF);
3338
- await printPaged(formatIssueView(await fetchIssue(new AtlassianClient(auth), auth.site, key), Date.now(), options.allComments ?? false).join("\n"), { pager: options.pager });
3837
+ async function jiraView({ session, term }, arg, options) {
3838
+ const key = await resolveRef(term.ask, arg, ISSUE_REF);
3839
+ const lines = formatIssueView(await fetchIssue(session, session.site, key), Date.now(), options.allComments ?? false);
3840
+ await term.page(lines.join("\n"), { pager: options.pager });
3339
3841
  }
3340
3842
  const CATEGORY_COLORS = {
3341
3843
  new: kleur.cyan,
@@ -3361,50 +3863,48 @@ function formatIssueView(issue, nowMs, allComments) {
3361
3863
  ["URL", issue.url]
3362
3864
  ]),
3363
3865
  ...bodyLines(issue.description),
3364
- ...commentSection(issue.comments, allComments),
3866
+ ...commentSection(renderedComments(issue.comments), { allComments }),
3365
3867
  ...attachmentSection(issue.attachments)
3366
3868
  ];
3367
3869
  }
3368
- async function jiraCopy(arg, options) {
3369
- const auth = await requireAuth();
3370
- const key = await resolveRef(arg, ISSUE_REF);
3371
- await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
3870
+ async function jiraCopy(env, arg, options) {
3871
+ await copyIssue(env, await resolveRef(env.term.ask, arg, ISSUE_REF), options.out);
3372
3872
  }
3373
- async function jiraUpdate(arg, options) {
3374
- const src = parseIssueSource(await readFile(arg ?? await input({
3873
+ async function jiraUpdate({ session, term, files }, arg, options) {
3874
+ const file = arg ?? await term.ask.text({
3375
3875
  message: "Path to the issue Markdown file:",
3876
+ flag: "[file]",
3376
3877
  required: true
3377
- }), "utf8"));
3378
- const auth = await requireAuth();
3379
- const client = new AtlassianClient(auth);
3380
- const plan = planIssueUpdate(src, await fetchIssue(client, auth.site, src.key), options);
3381
- await runPlan(plan, options, async () => {
3878
+ });
3879
+ const src = parseIssueSource(await files.readText(file));
3880
+ const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
3881
+ await runPlan(term, plan, options, async () => {
3382
3882
  const { current, next } = plan.headline;
3383
- await updateIssue(client, src.key, {
3883
+ await updateIssue(session, src.key, {
3384
3884
  description: plan.body,
3385
3885
  summary: next !== current ? next : void 0
3386
3886
  });
3387
- console.log(`Updated ${src.key}.`);
3887
+ term.out(`Updated ${src.key}.`);
3388
3888
  });
3389
3889
  }
3390
- async function copyIssue(client, site, key, out) {
3391
- console.log(`Fetching ${key} ...`);
3392
- await runCopy(planIssueCopy(await fetchIssue(client, site, key), out), (url) => client.getBinary(url));
3890
+ async function copyIssue(env, key, out) {
3891
+ env.term.err(`Fetching ${key} ...`);
3892
+ await runCopy(env, planIssueCopy(await fetchIssue(env.session, env.session.site, key), out));
3393
3893
  }
3394
3894
  const ISSUE_REF = {
3395
3895
  message: "Jira issue key or URL:",
3896
+ flag: "[issue]",
3396
3897
  parse: parseIssueKey,
3397
3898
  notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
3398
3899
  };
3399
3900
  //#endregion
3400
3901
  //#region src/commands/jira-search.ts
3401
- async function jiraSearch(query, options) {
3902
+ async function jiraSearch(env, query, options) {
3903
+ const { session, term } = env;
3402
3904
  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
3905
  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
3906
  const limit = parseLimit(options.limit);
3407
- const issues = await searchIssues(client, auth.site, {
3907
+ const issues = await searchIssues(session, session.site, {
3408
3908
  text: query,
3409
3909
  project: options.project,
3410
3910
  assignee: options.assignee,
@@ -3412,29 +3912,28 @@ async function jiraSearch(query, options) {
3412
3912
  jql: options.jql,
3413
3913
  limit
3414
3914
  });
3415
- await runSearch(formatIssueRows(issues, Date.now()), {
3915
+ await runSearch(term, formatIssueRows(issues, Date.now()), {
3416
3916
  json: options.json,
3417
3917
  copy: options.copy,
3418
3918
  out: options.out,
3419
3919
  empty: "No matching issues.",
3420
3920
  footer: issues.length === limit ? searchFooter(limit) : void 0
3421
- }, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
3921
+ }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
3422
3922
  }
3423
- async function jiraList(options) {
3923
+ async function jiraList(env, options) {
3924
+ const { session, term } = env;
3424
3925
  if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
3425
- const auth = await requireAuth();
3426
- const client = new AtlassianClient(auth);
3427
- const { issues, truncated } = await listAssignedIssues(client, auth.site, {
3926
+ const { issues, truncated } = await listAssignedIssues(session, session.site, {
3428
3927
  all: options.all,
3429
3928
  project: options.project
3430
3929
  });
3431
- await runSearch(formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
3930
+ await runSearch(term, formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
3432
3931
  json: options.json,
3433
3932
  copy: options.copy,
3434
3933
  out: options.out,
3435
3934
  empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
3436
3935
  footer: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
3437
- }, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
3936
+ }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
3438
3937
  }
3439
3938
  function formatIssueRows(issues, nowMs) {
3440
3939
  return alignedRows(issues, nowMs, (i) => ({
@@ -3457,20 +3956,20 @@ function collect(value, previous = []) {
3457
3956
  }
3458
3957
  function registerJira(jira) {
3459
3958
  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(run(jiraProjects));
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(run(jiraStatuses));
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("--no-input", "never prompt; fail if a required field is missing").option("--dry-run", "print the resolved payload instead of creating").option("--json", "print the created issue as JSON").action(run(jiraCreate));
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(run(jiraFields));
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(run(jiraView));
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(run(jiraCopy));
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(run(jiraUpdate));
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(run(jiraList));
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(run(jiraSearch));
3959
+ jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(jiraAction(jiraProjects));
3960
+ 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));
3961
+ 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));
3962
+ 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));
3963
+ 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));
3964
+ 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));
3965
+ 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));
3966
+ 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));
3967
+ 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
3968
  return jira;
3470
3969
  }
3471
3970
  //#endregion
3472
3971
  //#region package.json
3473
- var version = "1.12.0";
3972
+ var version = "1.14.0";
3474
3973
  //#endregion
3475
3974
  //#region src/cli/build.ts
3476
3975
  const PRODUCTS = {
@@ -3478,16 +3977,22 @@ const PRODUCTS = {
3478
3977
  confluence: registerConfluence,
3479
3978
  bitbucket: registerBitbucket
3480
3979
  };
3980
+ const NO_INPUT = "never prompt; fail if a required value is missing";
3981
+ function withNoInput(command) {
3982
+ command.option("--no-input", NO_INPUT);
3983
+ for (const sub of command.commands) withNoInput(sub);
3984
+ return command;
3985
+ }
3481
3986
  function buildAtlass() {
3482
3987
  const program = new Command().name("atlass").description("Copy Jira issues and Confluence pages to Markdown.").version(version);
3483
3988
  registerAuth(program.command("auth"));
3484
3989
  registerJira(program.command("jira"));
3485
3990
  registerConfluence(program.command("confluence").alias("conf"));
3486
3991
  registerBitbucket(program.command("bitbucket").alias("bb"));
3487
- return program;
3992
+ return withNoInput(program);
3488
3993
  }
3489
3994
  function buildStandalone(product) {
3490
- return PRODUCTS[product](new Command().name(product).version(version));
3995
+ return withNoInput(PRODUCTS[product](new Command().name(product).version(version)));
3491
3996
  }
3492
3997
  //#endregion
3493
3998
  export { buildStandalone as n, fail as r, buildAtlass as t };