atlass 1.4.0 → 1.6.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.
Files changed (3) hide show
  1. package/README.md +75 -2
  2. package/dist/cli.mjs +431 -44
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -50,6 +50,36 @@ atlass auth logout # remove config and delete the token from the keyring
50
50
 
51
51
  Only one account is supported at a time.
52
52
 
53
+ ### Bitbucket
54
+
55
+ Bitbucket Cloud is supported for viewing pipeline results. It uses the same
56
+ Atlassian account email but a separate, Bitbucket-scoped API token (Atlassian
57
+ tokens are scoped per product at creation, so a Jira token cannot be reused).
58
+
59
+ ```bash
60
+ atlass bitbucket login
61
+ ```
62
+
63
+ You are prompted for:
64
+
65
+ - workspace, e.g. `acme` (from `bitbucket.org/acme/<repo>`)
66
+ - default repo slug (optional)
67
+ - API token, created with the `read:pipeline` and workspace-read scopes at
68
+ `https://id.atlassian.com/manage-profile/security/api-tokens` (select Bitbucket
69
+ as the app)
70
+
71
+ The login is verified against the workspace before anything is saved. The
72
+ workspace and optional default repo are stored in the `bitbucket` block of
73
+ `config.json`; the token is stored in the OS keyring under a separate entry.
74
+
75
+ ```bash
76
+ atlass bitbucket status # show workspace, default repo, and token presence
77
+ atlass bitbucket logout # remove the Bitbucket config block and token
78
+ ```
79
+
80
+ `bitbucket login`/`logout` are independent of `auth login`/`logout`: logging out
81
+ of one leaves the other intact.
82
+
53
83
  ## Usage
54
84
 
55
85
  ### Copy a Jira issue
@@ -176,6 +206,23 @@ A discovery aid for the `--project` filter above: it fetches every project
176
206
  list. An optional query filters by key or name server-side. `--json` emits
177
207
  `{ key, name, id, type, url }` per project.
178
208
 
209
+ ### List Jira statuses
210
+
211
+ ```bash
212
+ atlass jira statuses # every status on the site
213
+ atlass jira statuses progress # filter by name
214
+ atlass jira statuses --project PROJ # statuses used by one project
215
+ atlass jira statuses --json # machine output
216
+ ```
217
+
218
+ A discovery aid for the `--status` filter above. Without `--project` it lists
219
+ every status on the site; with `--project` it lists the statuses that project's
220
+ issue types use (flattened to one list). An optional query filters by name
221
+ (case-insensitive substring). Statuses are collapsed by name and category, then
222
+ ordered by workflow lifecycle (To Do, then In Progress, then Done) and name.
223
+ Prints an aligned `Name Category` list; `--json` emits
224
+ `{ name, id, category, categoryKey }` per status.
225
+
179
226
  ### Search Confluence pages
180
227
 
181
228
  ```bash
@@ -202,6 +249,32 @@ atlass confluence search --space DOCS --copy
202
249
 
203
250
  Copying continues on failure and reports a summary at the end.
204
251
 
252
+ ### View Bitbucket pipelines
253
+
254
+ ```bash
255
+ atlass bitbucket pipelines # recent runs for the default repo
256
+ atlass bitbucket pipelines --repo acme/web # a specific workspace/repo
257
+ atlass bitbucket pipelines --repo web # bare slug, under the config workspace
258
+ atlass bitbucket pipelines --limit 50 --json
259
+ ```
260
+
261
+ Lists recent pipeline runs, newest first, one per line
262
+ (`#build status ref duration age creator`). Status shows the result
263
+ (`SUCCESSFUL`/`FAILED`/...) once a run completes, else its state
264
+ (`IN_PROGRESS`/`PENDING`/...). The repo comes from `--repo` (a `workspace/slug`
265
+ or a bare slug under the configured workspace), falling back to the configured
266
+ default repo. `--limit` defaults to 25, max 100. `--json` emits the full mapped
267
+ objects.
268
+
269
+ ```bash
270
+ atlass bitbucket pipeline 124 # one run and its steps
271
+ atlass bitbucket pipeline 124 --repo acme/web
272
+ ```
273
+
274
+ Shows a run summary (status, ref, commit, trigger, duration, creator) and its
275
+ step list (name, status, duration). The build number you pass is resolved to the
276
+ run directly, falling back to a bounded scan of recent runs if needed.
277
+
205
278
  ### Output location
206
279
 
207
280
  By default files are written to the current directory, named after the issue
@@ -285,8 +358,8 @@ pnpm dev # build in watch mode
285
358
  ### Layout
286
359
 
287
360
  - `src/cli.ts` command wiring (commander)
288
- - `src/commands/` `auth`, `jira`, `confluence` command handlers
289
- - `src/api/` fetch client, Jira and Confluence endpoints, attachment up/download
361
+ - `src/commands/` `auth`, `jira`, `confluence`, `bitbucket` command handlers
362
+ - `src/api/` fetch client, Jira, Confluence and Bitbucket endpoints, attachment up/download
290
363
  - `src/adf/` ADF to Markdown and Markdown to ADF converters (unit tested)
291
364
  - `src/markdown/` frontmatter, comments, attachments, media resolver, update source
292
365
  - `src/config.ts`, `src/credentials.ts` config file and keyring
package/dist/cli.mjs CHANGED
@@ -8,7 +8,7 @@ import { Entry } from "@napi-rs/keyring";
8
8
  import { marked } from "marked";
9
9
  import { randomUUID } from "node:crypto";
10
10
  //#region package.json
11
- var version = "1.4.0";
11
+ var version = "1.6.0";
12
12
  //#endregion
13
13
  //#region src/api/client.ts
14
14
  var AtlassianClient = class {
@@ -76,20 +76,21 @@ var AtlassianClient = class {
76
76
  return new Uint8Array(await res.arrayBuffer());
77
77
  }
78
78
  };
79
- function httpError(status, path, body = "") {
80
- if (status === 401 || status === 403) return /* @__PURE__ */ new Error("Authentication failed (401/403). Run `atlass auth login` to update your token.");
81
- if (status === 404) return /* @__PURE__ */ new Error(`Not found (404): ${path}`);
82
- if (status === 409) {
83
- const detail = extractError(body);
84
- return /* @__PURE__ */ new Error(`Conflict (409): ${detail || "the page changed on the server"}`);
85
- }
86
- if (status === 413) return /* @__PURE__ */ new Error("Payload too large (413): the page or an attachment exceeds the size limit.");
87
- if (status === 400) {
88
- const detail = extractError(body);
89
- return /* @__PURE__ */ new Error(`Bad request (400): ${detail || path}`);
79
+ var HttpError = class extends Error {
80
+ status;
81
+ constructor(status, message) {
82
+ super(message);
83
+ this.status = status;
84
+ this.name = "HttpError";
90
85
  }
91
- const detail = extractError(body);
92
- return /* @__PURE__ */ new Error(`Request failed (${status}): ${detail || path}`);
86
+ };
87
+ function httpError(status, path, body = "") {
88
+ if (status === 401 || status === 403) return new HttpError(status, "Authentication failed (401/403). Run `atlass auth login` to update your token.");
89
+ if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
90
+ if (status === 409) return new HttpError(status, `Conflict (409): ${extractError(body) || "the page changed on the server"}`);
91
+ if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
92
+ if (status === 400) return new HttpError(status, `Bad request (400): ${extractError(body) || path}`);
93
+ return new HttpError(status, `Request failed (${status}): ${extractError(body) || path}`);
93
94
  }
94
95
  function extractError(body) {
95
96
  if (!body) return "";
@@ -97,6 +98,7 @@ function extractError(body) {
97
98
  const json = JSON.parse(body);
98
99
  if (json.errorMessages?.length) return json.errorMessages.join("; ");
99
100
  if (json.message) return json.message;
101
+ if (json.error?.message) return json.error.message;
100
102
  } catch {}
101
103
  return body.slice(0, 300);
102
104
  }
@@ -112,10 +114,10 @@ async function readConfig() {
112
114
  try {
113
115
  const raw = await readFile(configPath(), "utf8");
114
116
  const parsed = JSON.parse(raw);
115
- if (!parsed.site || !parsed.email) return null;
116
117
  return {
117
118
  site: parsed.site,
118
- email: parsed.email
119
+ email: parsed.email,
120
+ bitbucket: parsed.bitbucket
119
121
  };
120
122
  } catch {
121
123
  return null;
@@ -136,8 +138,9 @@ function normalizeSite(input) {
136
138
  //#endregion
137
139
  //#region src/credentials.ts
138
140
  const SERVICE = "atlass";
139
- function entry(email) {
140
- return new Entry(SERVICE, email);
141
+ const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
142
+ function entry(key) {
143
+ return new Entry(SERVICE, key);
141
144
  }
142
145
  function saveToken(email, token) {
143
146
  entry(email).setPassword(token);
@@ -150,16 +153,44 @@ function deleteToken(email) {
150
153
  entry(email).deleteCredential();
151
154
  } catch {}
152
155
  }
156
+ function bitbucketKey(email) {
157
+ return `${email}:bitbucket`;
158
+ }
159
+ function saveBitbucketToken(email, token) {
160
+ entry(bitbucketKey(email)).setPassword(token);
161
+ }
162
+ function readBitbucketToken(email) {
163
+ return entry(bitbucketKey(email)).getPassword();
164
+ }
165
+ function deleteBitbucketToken(email) {
166
+ try {
167
+ entry(bitbucketKey(email)).deleteCredential();
168
+ } catch {}
169
+ }
153
170
  async function requireAuth() {
154
171
  const config = await readConfig();
155
- if (!config) throw new Error("Not logged in. Run `atlass auth login` first.");
172
+ if (!config || !config.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
156
173
  const token = readToken(config.email);
157
174
  if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
158
175
  return {
159
- ...config,
176
+ site: config.site,
177
+ email: config.email,
160
178
  token
161
179
  };
162
180
  }
181
+ async function requireBitbucketAuth() {
182
+ const config = await readConfig();
183
+ if (!config || !config.email || !config.bitbucket?.workspace) throw new Error("Not logged in to Bitbucket. Run `atlass bitbucket login` first.");
184
+ const token = readBitbucketToken(config.email);
185
+ if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
186
+ return {
187
+ site: BITBUCKET_ORIGIN,
188
+ email: config.email,
189
+ token,
190
+ workspace: config.bitbucket.workspace,
191
+ defaultRepo: config.bitbucket.defaultRepo
192
+ };
193
+ }
163
194
  //#endregion
164
195
  //#region src/commands/auth.ts
165
196
  async function login() {
@@ -181,6 +212,7 @@ async function login() {
181
212
  token
182
213
  }).getJson("/rest/api/3/myself");
183
214
  await writeConfig({
215
+ ...await readConfig() ?? {},
184
216
  site,
185
217
  email
186
218
  });
@@ -189,13 +221,17 @@ async function login() {
189
221
  }
190
222
  async function logout() {
191
223
  const config = await readConfig();
192
- if (config) deleteToken(config.email);
193
- await clearConfig();
224
+ if (config?.email) deleteToken(config.email);
225
+ if (config?.bitbucket && config.email) await writeConfig({
226
+ email: config.email,
227
+ bitbucket: config.bitbucket
228
+ });
229
+ else await clearConfig();
194
230
  console.log("Logged out. Credentials removed.");
195
231
  }
196
232
  async function status() {
197
233
  const config = await readConfig();
198
- if (!config) {
234
+ if (!config || !config.site || !config.email) {
199
235
  console.log("Not logged in. Run `atlass auth login`.");
200
236
  return;
201
237
  }
@@ -205,6 +241,313 @@ async function status() {
205
241
  console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
206
242
  }
207
243
  //#endregion
244
+ //#region src/api/bitbucket.ts
245
+ function pipelineStatus(state) {
246
+ if (!state) return "";
247
+ if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
248
+ return state.name ?? "";
249
+ }
250
+ function pipelinesQuery(limit) {
251
+ return new URLSearchParams({
252
+ sort: "-created_on",
253
+ pagelen: String(Math.min(Math.max(limit, 1), 100))
254
+ }).toString();
255
+ }
256
+ function elapsedSeconds(startOn, endOn) {
257
+ if (!startOn || !endOn) return null;
258
+ const start = Date.parse(startOn);
259
+ const end = Date.parse(endOn);
260
+ if (Number.isNaN(start) || Number.isNaN(end)) return null;
261
+ return Math.floor((end - start) / 1e3);
262
+ }
263
+ const MAX_SCAN = 1e3;
264
+ function repoPath(ref) {
265
+ return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/pipelines`;
266
+ }
267
+ function toPath(url) {
268
+ const u = new URL(url);
269
+ return u.pathname + u.search;
270
+ }
271
+ async function* paginate(client, firstPath) {
272
+ let path = firstPath;
273
+ while (path) {
274
+ const page = await client.getJson(path);
275
+ for (const value of page.values ?? []) yield value;
276
+ path = page.next ? toPath(page.next) : null;
277
+ }
278
+ }
279
+ function mapPipeline(p) {
280
+ return {
281
+ buildNumber: p.build_number,
282
+ status: pipelineStatus(p.state),
283
+ ref: p.target?.ref_name ?? "",
284
+ commit: p.target?.commit?.hash?.slice(0, 7) ?? "",
285
+ durationSeconds: elapsedSeconds(p.created_on, p.completed_on),
286
+ createdOn: p.created_on ?? "",
287
+ creator: p.creator?.display_name ?? "",
288
+ uuid: p.uuid
289
+ };
290
+ }
291
+ function mapDetail(p) {
292
+ return {
293
+ ...mapPipeline(p),
294
+ repo: p.repository?.full_name ?? "",
295
+ trigger: p.trigger?.name ?? ""
296
+ };
297
+ }
298
+ async function listPipelines(client, ref, limit) {
299
+ const out = [];
300
+ const first = `${repoPath(ref)}?${pipelinesQuery(limit)}`;
301
+ for await (const value of paginate(client, first)) {
302
+ out.push(mapPipeline(value));
303
+ if (out.length >= limit) break;
304
+ }
305
+ return out;
306
+ }
307
+ async function getPipeline(client, ref, buildNumber) {
308
+ const base = repoPath(ref);
309
+ try {
310
+ return mapDetail(await client.getJson(`${base}/${encodeURIComponent(String(buildNumber))}`));
311
+ } catch (err) {
312
+ if (!(err instanceof HttpError) || err.status !== 400 && err.status !== 404) throw err;
313
+ }
314
+ const found = await scanForBuild(client, ref, buildNumber);
315
+ if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${MAX_SCAN} most recent runs. It may be too old.`);
316
+ return mapDetail(found);
317
+ }
318
+ async function scanForBuild(client, ref, buildNumber) {
319
+ const first = `${repoPath(ref)}?${pipelinesQuery(100)}`;
320
+ let scanned = 0;
321
+ for await (const pipeline of paginate(client, first)) {
322
+ if (pipeline.build_number === buildNumber) return pipeline;
323
+ if (++scanned >= MAX_SCAN) break;
324
+ }
325
+ return null;
326
+ }
327
+ async function listSteps(client, ref, pipelineId) {
328
+ const out = [];
329
+ const first = `${repoPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
330
+ for await (const step of paginate(client, first)) out.push({
331
+ name: step.name ?? "",
332
+ status: pipelineStatus(step.state),
333
+ durationSeconds: elapsedSeconds(step.started_on, step.completed_on)
334
+ });
335
+ return out;
336
+ }
337
+ //#endregion
338
+ //#region src/util/format.ts
339
+ function formatDuration(seconds) {
340
+ if (seconds == null) return "-";
341
+ if (seconds < 60) return `${seconds}s`;
342
+ const m = Math.floor(seconds / 60);
343
+ const s = seconds % 60;
344
+ if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
345
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
346
+ }
347
+ const MINUTE = 60;
348
+ const HOUR = 60 * MINUTE;
349
+ const DAY = 24 * HOUR;
350
+ function relativeTime(iso, nowMs) {
351
+ if (!iso) return "-";
352
+ const then = Date.parse(iso);
353
+ if (Number.isNaN(then)) return "-";
354
+ const secs = Math.floor((nowMs - then) / 1e3);
355
+ if (secs < MINUTE) return "just now";
356
+ if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
357
+ if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
358
+ const days = Math.floor(secs / DAY);
359
+ if (days < 45) return `${days}d ago`;
360
+ if (days < 365) return `${Math.floor(days / 30)}mo ago`;
361
+ return `${Math.floor(days / 365)}y ago`;
362
+ }
363
+ //#endregion
364
+ //#region src/util/parse.ts
365
+ function parseIssueKey(input) {
366
+ const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
367
+ return match ? match[0] : null;
368
+ }
369
+ function parsePageId(input) {
370
+ const trimmed = input.trim();
371
+ if (/^\d+$/.test(trimmed)) return trimmed;
372
+ const fromPath = trimmed.match(/\/pages\/(\d+)/);
373
+ if (fromPath) return fromPath[1];
374
+ const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
375
+ if (fromQuery) return fromQuery[1];
376
+ return null;
377
+ }
378
+ function resolveRepo(flag, config) {
379
+ if (flag) {
380
+ if (flag.includes("/")) {
381
+ const parts = flag.split("/");
382
+ const [workspace, repo] = parts;
383
+ if (parts.length !== 2 || !workspace || !repo) throw new Error(`Invalid --repo "${flag}". Expected "workspace/slug" or "slug".`);
384
+ return {
385
+ workspace,
386
+ repo
387
+ };
388
+ }
389
+ if (!config.workspace) throw new Error(`No Bitbucket workspace configured. Pass --repo workspace/slug or run \`atlass bitbucket login\`.`);
390
+ return {
391
+ workspace: config.workspace,
392
+ repo: flag
393
+ };
394
+ }
395
+ if (config.workspace && config.defaultRepo) return {
396
+ workspace: config.workspace,
397
+ repo: config.defaultRepo
398
+ };
399
+ throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
400
+ }
401
+ function parseLimit(value) {
402
+ if (!value) return 25;
403
+ const n = Number.parseInt(value, 10);
404
+ if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
405
+ return Math.min(n, 100);
406
+ }
407
+ //#endregion
408
+ //#region src/commands/bitbucket.ts
409
+ async function bitbucketLogin() {
410
+ const existing = await readConfig() ?? {};
411
+ const email = existing.email ?? await input({
412
+ message: "Account email:",
413
+ required: true
414
+ });
415
+ const workspace = (await input({
416
+ message: "Bitbucket workspace (e.g. acme):",
417
+ required: true
418
+ })).trim();
419
+ const defaultRepo = (await input({ message: "Default repo slug (optional):" })).trim() || void 0;
420
+ const token = await password({
421
+ message: "Bitbucket API token (needs read:pipeline + workspace read scopes):",
422
+ mask: true
423
+ });
424
+ const ws = await verifyWorkspace(new AtlassianClient({
425
+ site: BITBUCKET_ORIGIN,
426
+ email,
427
+ token
428
+ }), workspace);
429
+ await writeConfig({
430
+ ...existing,
431
+ email,
432
+ bitbucket: {
433
+ workspace,
434
+ ...defaultRepo ? { defaultRepo } : {}
435
+ }
436
+ });
437
+ saveBitbucketToken(email, token);
438
+ console.log(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
439
+ }
440
+ async function bitbucketLogout() {
441
+ const config = await readConfig();
442
+ if (config?.email) deleteBitbucketToken(config.email);
443
+ if (config?.site && config.email) await writeConfig({
444
+ site: config.site,
445
+ email: config.email
446
+ });
447
+ else await clearConfig();
448
+ console.log("Logged out of Bitbucket. Credentials removed.");
449
+ }
450
+ async function bitbucketStatus() {
451
+ const config = await readConfig();
452
+ if (!config?.email || !config.bitbucket?.workspace) {
453
+ console.log("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
454
+ return;
455
+ }
456
+ const hasToken = readBitbucketToken(config.email) !== null;
457
+ console.log(`Workspace: ${config.bitbucket.workspace}`);
458
+ console.log(`Email: ${config.email}`);
459
+ console.log(`Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`);
460
+ console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`);
461
+ }
462
+ async function bitbucketPipelines(options) {
463
+ const auth = await requireBitbucketAuth();
464
+ const ref = resolveRepo(options.repo, auth);
465
+ const client = new AtlassianClient(auth);
466
+ const limit = parseLimit(options.limit);
467
+ const pipelines = await withScopeHint(() => listPipelines(client, ref, limit));
468
+ if (options.json) {
469
+ console.log(JSON.stringify(pipelines, null, 2));
470
+ return;
471
+ }
472
+ if (pipelines.length === 0) {
473
+ console.log("No pipelines found.");
474
+ return;
475
+ }
476
+ for (const line of formatPipelineRows(pipelines, Date.now())) console.log(line);
477
+ }
478
+ async function bitbucketPipeline(arg, options) {
479
+ const buildNumber = parseBuildNumber(arg);
480
+ const auth = await requireBitbucketAuth();
481
+ const ref = resolveRepo(options.repo, auth);
482
+ const client = new AtlassianClient(auth);
483
+ const detail = await withScopeHint(() => getPipeline(client, ref, buildNumber));
484
+ printPipelineDetail(detail, await withScopeHint(() => listSteps(client, ref, detail.uuid)), Date.now());
485
+ }
486
+ function formatPipelineRows(pipelines, nowMs) {
487
+ const rows = pipelines.map((p) => ({
488
+ num: `#${p.buildNumber}`,
489
+ status: p.status || "-",
490
+ ref: p.ref || p.commit || "-",
491
+ dur: formatDuration(p.durationSeconds),
492
+ age: relativeTime(p.createdOn, nowMs),
493
+ creator: p.creator || "-"
494
+ }));
495
+ const width = (sel) => Math.max(...rows.map((r) => sel(r).length));
496
+ const wn = width((r) => r.num);
497
+ const ws = width((r) => r.status);
498
+ const wr = width((r) => r.ref);
499
+ const wd = width((r) => r.dur);
500
+ const wa = width((r) => r.age);
501
+ return rows.map((r) => `${r.num.padEnd(wn)} ${r.status.padEnd(ws)} ${r.ref.padEnd(wr)} ${r.dur.padEnd(wd)} ${r.age.padEnd(wa)} ${r.creator}`);
502
+ }
503
+ function formatStepRows(steps) {
504
+ const rows = steps.map((s) => ({
505
+ name: s.name || "-",
506
+ status: s.status || "-",
507
+ dur: formatDuration(s.durationSeconds)
508
+ }));
509
+ const wn = Math.max(...rows.map((r) => r.name.length));
510
+ const ws = Math.max(...rows.map((r) => r.status.length));
511
+ return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
512
+ }
513
+ function printPipelineDetail(detail, steps, nowMs) {
514
+ console.log(`Pipeline #${detail.buildNumber} ${detail.status || "-"}`);
515
+ if (detail.repo) console.log(`Repo: ${detail.repo}`);
516
+ const refLine = detail.ref ? detail.commit ? `${detail.ref} (${detail.commit})` : detail.ref : detail.commit || "-";
517
+ console.log(`Ref: ${refLine}`);
518
+ if (detail.trigger) console.log(`Trigger: ${detail.trigger}`);
519
+ console.log(`Duration: ${formatDuration(detail.durationSeconds)}`);
520
+ const by = detail.creator ? ` by ${detail.creator}` : "";
521
+ console.log(`Created: ${relativeTime(detail.createdOn, nowMs)}${by}`);
522
+ if (steps.length > 0) {
523
+ console.log("");
524
+ console.log("Steps:");
525
+ for (const line of formatStepRows(steps)) console.log(line);
526
+ }
527
+ }
528
+ async function verifyWorkspace(client, workspace) {
529
+ try {
530
+ return await client.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
531
+ } catch (err) {
532
+ 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.`);
533
+ if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
534
+ throw err;
535
+ }
536
+ }
537
+ function parseBuildNumber(arg) {
538
+ const raw = (arg ?? "").replace(/^#/, "").trim();
539
+ if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
540
+ return Number.parseInt(raw, 10);
541
+ }
542
+ async function withScopeHint(fn) {
543
+ try {
544
+ return await fn();
545
+ } catch (err) {
546
+ if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error("Bitbucket rejected the request (401/403). Check the token has the read:pipeline:bitbucket scope, or run `atlass bitbucket login` to update it.");
547
+ throw err;
548
+ }
549
+ }
550
+ //#endregion
208
551
  //#region src/adf/from-markdown.ts
209
552
  function markdownToAdf(md, options = {}) {
210
553
  const ctx = { resolveImage: options.resolveImage ?? defaultResolveImage };
@@ -947,27 +1290,6 @@ function slugify(title) {
947
1290
  return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "") || "page";
948
1291
  }
949
1292
  //#endregion
950
- //#region src/util/parse.ts
951
- function parseIssueKey(input) {
952
- const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
953
- return match ? match[0] : null;
954
- }
955
- function parsePageId(input) {
956
- const trimmed = input.trim();
957
- if (/^\d+$/.test(trimmed)) return trimmed;
958
- const fromPath = trimmed.match(/\/pages\/(\d+)/);
959
- if (fromPath) return fromPath[1];
960
- const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
961
- if (fromQuery) return fromQuery[1];
962
- return null;
963
- }
964
- function parseLimit(value) {
965
- if (!value) return 25;
966
- const n = Number.parseInt(value, 10);
967
- if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
968
- return Math.min(n, 100);
969
- }
970
- //#endregion
971
1293
  //#region src/commands/search-run.ts
972
1294
  const COPY_CONCURRENCY = 5;
973
1295
  async function runSearch(rows, options, noun, copyOne) {
@@ -1338,6 +1660,44 @@ function projectSearchQuery(query, startAt) {
1338
1660
  if (query) params.set("query", query);
1339
1661
  return params.toString();
1340
1662
  }
1663
+ async function listStatuses(client, project) {
1664
+ return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await client.getJson("/rest/api/3/status")).map(toStatusSummary));
1665
+ }
1666
+ async function fetchProjectStatuses(client, project) {
1667
+ let groups;
1668
+ try {
1669
+ groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
1670
+ } catch (err) {
1671
+ if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
1672
+ throw err;
1673
+ }
1674
+ return groups.flatMap((g) => g.statuses ?? []);
1675
+ }
1676
+ function toStatusSummary(s) {
1677
+ return {
1678
+ name: s.name,
1679
+ id: s.id,
1680
+ category: s.statusCategory?.name ?? "",
1681
+ categoryKey: s.statusCategory?.key ?? ""
1682
+ };
1683
+ }
1684
+ const CATEGORY_ORDER = {
1685
+ new: 0,
1686
+ indeterminate: 1,
1687
+ done: 2
1688
+ };
1689
+ const CATEGORY_LAST = Object.keys(CATEGORY_ORDER).length;
1690
+ function dedupeAndSortStatuses(statuses) {
1691
+ const byNameCategory = /* @__PURE__ */ new Map();
1692
+ for (const s of statuses) {
1693
+ const key = `${s.name}\0${s.categoryKey}`;
1694
+ if (!byNameCategory.has(key)) byNameCategory.set(key, s);
1695
+ }
1696
+ return [...byNameCategory.values()].sort((a, b) => {
1697
+ const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? CATEGORY_LAST;
1698
+ return rank(a) - rank(b) || a.name.localeCompare(b.name);
1699
+ });
1700
+ }
1341
1701
  function jqlValue(value) {
1342
1702
  return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1343
1703
  }
@@ -1367,6 +1727,26 @@ function formatProjectRows(projects) {
1367
1727
  const width = Math.max(...projects.map((p) => p.key.length));
1368
1728
  return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
1369
1729
  }
1730
+ async function jiraStatuses(query, options) {
1731
+ let statuses = await listStatuses(new AtlassianClient(await requireAuth()), options.project);
1732
+ if (query) {
1733
+ const needle = query.toLowerCase();
1734
+ statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
1735
+ }
1736
+ if (options.json) {
1737
+ console.log(JSON.stringify(statuses, null, 2));
1738
+ return;
1739
+ }
1740
+ if (statuses.length === 0) {
1741
+ console.log("No matching statuses.");
1742
+ return;
1743
+ }
1744
+ for (const line of formatStatusRows(statuses)) console.log(line);
1745
+ }
1746
+ function formatStatusRows(statuses) {
1747
+ const width = Math.max(...statuses.map((s) => s.name.length));
1748
+ return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
1749
+ }
1370
1750
  async function jiraCopy(arg, options) {
1371
1751
  const auth = await requireAuth();
1372
1752
  const key = await resolveKey(arg);
@@ -1518,6 +1898,7 @@ auth.command("logout").description("Remove stored credentials").action(run(logou
1518
1898
  auth.command("status").description("Show the current login").action(run(status));
1519
1899
  const jira = program.command("jira").description("Jira commands");
1520
1900
  jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(run(jiraProjects));
1901
+ 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));
1521
1902
  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));
1522
1903
  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));
1523
1904
  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));
@@ -1525,6 +1906,12 @@ const confluence = program.command("confluence").description("Confluence command
1525
1906
  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));
1526
1907
  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));
1527
1908
  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));
1909
+ const bitbucket = program.command("bitbucket").description("Bitbucket commands");
1910
+ bitbucket.command("login").description("Store Bitbucket workspace and API token").action(run(bitbucketLogin));
1911
+ bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(run(bitbucketLogout));
1912
+ bitbucket.command("status").description("Show the current Bitbucket login").action(run(bitbucketStatus));
1913
+ 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));
1914
+ 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));
1528
1915
  program.parseAsync().catch(fail);
1529
1916
  function run(fn) {
1530
1917
  return async (...args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI to copy Jira issues and Confluence pages to Markdown, and update Confluence pages.",
5
5
  "license": "MIT",
6
6
  "repository": {