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.
- package/README.md +58 -2
- package/dist/cli.mjs +358 -31
- 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
|
|
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.
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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) => {
|