atlass 1.5.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 +58 -2
  2. package/dist/cli.mjs +358 -31
  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
@@ -219,6 +249,32 @@ atlass confluence search --space DOCS --copy
219
249
 
220
250
  Copying continues on failure and reports a summary at the end.
221
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
+
222
278
  ### Output location
223
279
 
224
280
  By default files are written to the current directory, named after the issue
@@ -302,8 +358,8 @@ pnpm dev # build in watch mode
302
358
  ### Layout
303
359
 
304
360
  - `src/cli.ts` command wiring (commander)
305
- - `src/commands/` `auth`, `jira`, `confluence` command handlers
306
- - `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
307
363
  - `src/adf/` ADF to Markdown and Markdown to ADF converters (unit tested)
308
364
  - `src/markdown/` frontmatter, comments, attachments, media resolver, update source
309
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.5.0";
11
+ var version = "1.6.0";
12
12
  //#endregion
13
13
  //#region src/api/client.ts
14
14
  var AtlassianClient = class {
@@ -98,6 +98,7 @@ function extractError(body) {
98
98
  const json = JSON.parse(body);
99
99
  if (json.errorMessages?.length) return json.errorMessages.join("; ");
100
100
  if (json.message) return json.message;
101
+ if (json.error?.message) return json.error.message;
101
102
  } catch {}
102
103
  return body.slice(0, 300);
103
104
  }
@@ -113,10 +114,10 @@ async function readConfig() {
113
114
  try {
114
115
  const raw = await readFile(configPath(), "utf8");
115
116
  const parsed = JSON.parse(raw);
116
- if (!parsed.site || !parsed.email) return null;
117
117
  return {
118
118
  site: parsed.site,
119
- email: parsed.email
119
+ email: parsed.email,
120
+ bitbucket: parsed.bitbucket
120
121
  };
121
122
  } catch {
122
123
  return null;
@@ -137,8 +138,9 @@ function normalizeSite(input) {
137
138
  //#endregion
138
139
  //#region src/credentials.ts
139
140
  const SERVICE = "atlass";
140
- function entry(email) {
141
- return new Entry(SERVICE, email);
141
+ const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
142
+ function entry(key) {
143
+ return new Entry(SERVICE, key);
142
144
  }
143
145
  function saveToken(email, token) {
144
146
  entry(email).setPassword(token);
@@ -151,16 +153,44 @@ function deleteToken(email) {
151
153
  entry(email).deleteCredential();
152
154
  } catch {}
153
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
+ }
154
170
  async function requireAuth() {
155
171
  const config = await readConfig();
156
- 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.");
157
173
  const token = readToken(config.email);
158
174
  if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
159
175
  return {
160
- ...config,
176
+ site: config.site,
177
+ email: config.email,
161
178
  token
162
179
  };
163
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
+ }
164
194
  //#endregion
165
195
  //#region src/commands/auth.ts
166
196
  async function login() {
@@ -182,6 +212,7 @@ async function login() {
182
212
  token
183
213
  }).getJson("/rest/api/3/myself");
184
214
  await writeConfig({
215
+ ...await readConfig() ?? {},
185
216
  site,
186
217
  email
187
218
  });
@@ -190,13 +221,17 @@ async function login() {
190
221
  }
191
222
  async function logout() {
192
223
  const config = await readConfig();
193
- if (config) deleteToken(config.email);
194
- 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();
195
230
  console.log("Logged out. Credentials removed.");
196
231
  }
197
232
  async function status() {
198
233
  const config = await readConfig();
199
- if (!config) {
234
+ if (!config || !config.site || !config.email) {
200
235
  console.log("Not logged in. Run `atlass auth login`.");
201
236
  return;
202
237
  }
@@ -206,6 +241,313 @@ async function status() {
206
241
  console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
207
242
  }
208
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
209
551
  //#region src/adf/from-markdown.ts
210
552
  function markdownToAdf(md, options = {}) {
211
553
  const ctx = { resolveImage: options.resolveImage ?? defaultResolveImage };
@@ -948,27 +1290,6 @@ function slugify(title) {
948
1290
  return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "") || "page";
949
1291
  }
950
1292
  //#endregion
951
- //#region src/util/parse.ts
952
- function parseIssueKey(input) {
953
- const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
954
- return match ? match[0] : null;
955
- }
956
- function parsePageId(input) {
957
- const trimmed = input.trim();
958
- if (/^\d+$/.test(trimmed)) return trimmed;
959
- const fromPath = trimmed.match(/\/pages\/(\d+)/);
960
- if (fromPath) return fromPath[1];
961
- const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
962
- if (fromQuery) return fromQuery[1];
963
- return null;
964
- }
965
- function parseLimit(value) {
966
- if (!value) return 25;
967
- const n = Number.parseInt(value, 10);
968
- if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
969
- return Math.min(n, 100);
970
- }
971
- //#endregion
972
1293
  //#region src/commands/search-run.ts
973
1294
  const COPY_CONCURRENCY = 5;
974
1295
  async function runSearch(rows, options, noun, copyOne) {
@@ -1585,6 +1906,12 @@ const confluence = program.command("confluence").description("Confluence command
1585
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));
1586
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));
1587
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));
1588
1915
  program.parseAsync().catch(fail);
1589
1916
  function run(fn) {
1590
1917
  return async (...args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "1.5.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": {