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.
- package/README.md +75 -2
- package/dist/cli.mjs +431 -44
- 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
|
|
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.
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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) => {
|