atlass 1.7.0 → 1.9.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 +17 -0
- package/dist/bitbucket.d.mts +1 -0
- package/dist/bitbucket.mjs +6 -0
- package/dist/build-DhED0Zw_.mjs +2419 -0
- package/dist/cli.mjs +2 -2290
- package/dist/confluence.d.mts +1 -0
- package/dist/confluence.mjs +6 -0
- package/dist/jira.d.mts +1 -0
- package/dist/jira.mjs +6 -0
- package/package.json +9 -3
|
@@ -0,0 +1,2419 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { checkbox, confirm, input, password } from "@inquirer/prompts";
|
|
3
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { Entry } from "@napi-rs/keyring";
|
|
7
|
+
import { marked } from "marked";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { stripVTControlCharacters } from "node:util";
|
|
10
|
+
import kleur from "kleur";
|
|
11
|
+
import { common, createLowlight } from "lowlight";
|
|
12
|
+
//#region src/cli/run.ts
|
|
13
|
+
const SIGINT_EXIT_CODE = 130;
|
|
14
|
+
function run(fn) {
|
|
15
|
+
return async (...args) => {
|
|
16
|
+
try {
|
|
17
|
+
await fn(...args);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
fail(err);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function fail(err) {
|
|
24
|
+
if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
|
|
25
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
function isPromptInterrupt(err) {
|
|
29
|
+
return err instanceof Error && err.name === "ExitPromptError";
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/api/client.ts
|
|
33
|
+
const DISABLE_XSRF_CHECK = { "X-Atlassian-Token": "nocheck" };
|
|
34
|
+
var AtlassianClient = class {
|
|
35
|
+
site;
|
|
36
|
+
authHeader;
|
|
37
|
+
constructor(auth) {
|
|
38
|
+
this.site = auth.site;
|
|
39
|
+
const basic = Buffer.from(`${auth.email}:${auth.token}`).toString("base64");
|
|
40
|
+
this.authHeader = `Basic ${basic}`;
|
|
41
|
+
}
|
|
42
|
+
async request(path, init) {
|
|
43
|
+
const res = await fetch(`${this.site}${path}`, {
|
|
44
|
+
method: init.method,
|
|
45
|
+
body: init.body,
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: this.authHeader,
|
|
48
|
+
...init.headers
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const body = await res.text().catch(() => "");
|
|
53
|
+
throw httpError(res.status, path, body);
|
|
54
|
+
}
|
|
55
|
+
return res;
|
|
56
|
+
}
|
|
57
|
+
async getJson(path) {
|
|
58
|
+
return (await this.request(path, { headers: { Accept: "application/json" } })).json();
|
|
59
|
+
}
|
|
60
|
+
async putJson(path, body) {
|
|
61
|
+
return (await this.request(path, {
|
|
62
|
+
method: "PUT",
|
|
63
|
+
headers: {
|
|
64
|
+
Accept: "application/json",
|
|
65
|
+
"Content-Type": "application/json"
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify(body)
|
|
68
|
+
})).json();
|
|
69
|
+
}
|
|
70
|
+
async putNoContent(path, body) {
|
|
71
|
+
await this.request(path, {
|
|
72
|
+
method: "PUT",
|
|
73
|
+
headers: {
|
|
74
|
+
Accept: "application/json",
|
|
75
|
+
"Content-Type": "application/json"
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(body)
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async postMultipart(path, filename, bytes) {
|
|
81
|
+
const form = new FormData();
|
|
82
|
+
const blob = new Blob([bytes]);
|
|
83
|
+
form.append("file", blob, filename);
|
|
84
|
+
return (await this.request(path, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
Accept: "application/json",
|
|
88
|
+
...DISABLE_XSRF_CHECK
|
|
89
|
+
},
|
|
90
|
+
body: form
|
|
91
|
+
})).json();
|
|
92
|
+
}
|
|
93
|
+
async getBinary(urlOrPath) {
|
|
94
|
+
const path = urlOrPath.startsWith("http") ? pathAndQuery(urlOrPath) : urlOrPath;
|
|
95
|
+
const res = await this.request(path, { headers: { Accept: "*/*" } });
|
|
96
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
function pathAndQuery(absoluteUrl) {
|
|
100
|
+
const u = new URL(absoluteUrl);
|
|
101
|
+
return u.pathname + u.search;
|
|
102
|
+
}
|
|
103
|
+
var HttpError = class extends Error {
|
|
104
|
+
status;
|
|
105
|
+
constructor(status, message) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.status = status;
|
|
108
|
+
this.name = "HttpError";
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
function httpError(status, path, body = "") {
|
|
112
|
+
if (status === 401 || status === 403) return new HttpError(status, "Authentication failed (401/403). Run `atlass auth login` to update your token.");
|
|
113
|
+
if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
|
|
114
|
+
if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
|
|
115
|
+
if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
|
|
116
|
+
if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`);
|
|
117
|
+
return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`);
|
|
118
|
+
}
|
|
119
|
+
function extractErrorMessage(body) {
|
|
120
|
+
if (!body) return "";
|
|
121
|
+
const json = tryParseJson(body);
|
|
122
|
+
if (json?.errorMessages?.length) return json.errorMessages.join("; ");
|
|
123
|
+
if (json?.message) return json.message;
|
|
124
|
+
if (json?.error?.message) return json.error.message;
|
|
125
|
+
return body.slice(0, 300);
|
|
126
|
+
}
|
|
127
|
+
function tryParseJson(text) {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(text);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/config.ts
|
|
136
|
+
function configDir() {
|
|
137
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "atlass");
|
|
138
|
+
}
|
|
139
|
+
function configPath() {
|
|
140
|
+
return join(configDir(), "config.json");
|
|
141
|
+
}
|
|
142
|
+
async function readConfig() {
|
|
143
|
+
try {
|
|
144
|
+
const raw = await readFile(configPath(), "utf8");
|
|
145
|
+
const parsed = JSON.parse(raw);
|
|
146
|
+
return {
|
|
147
|
+
site: parsed.site,
|
|
148
|
+
email: parsed.email,
|
|
149
|
+
bitbucket: parsed.bitbucket
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function writeConfig(config) {
|
|
156
|
+
await mkdir(configDir(), { recursive: true });
|
|
157
|
+
await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
158
|
+
}
|
|
159
|
+
async function clearConfig() {
|
|
160
|
+
await rm(configPath(), { force: true });
|
|
161
|
+
}
|
|
162
|
+
function siteOrigin(input) {
|
|
163
|
+
let value = input.trim();
|
|
164
|
+
if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
|
|
165
|
+
return new URL(value).origin;
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/credentials.ts
|
|
169
|
+
const SERVICE = "atlass";
|
|
170
|
+
const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
|
|
171
|
+
function entry(key) {
|
|
172
|
+
return new Entry(SERVICE, key);
|
|
173
|
+
}
|
|
174
|
+
function deleteEntryIfPresent(key) {
|
|
175
|
+
try {
|
|
176
|
+
entry(key).deleteCredential();
|
|
177
|
+
} catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function saveToken(email, token) {
|
|
182
|
+
entry(email).setPassword(token);
|
|
183
|
+
}
|
|
184
|
+
function readToken(email) {
|
|
185
|
+
return entry(email).getPassword();
|
|
186
|
+
}
|
|
187
|
+
function deleteToken(email) {
|
|
188
|
+
deleteEntryIfPresent(email);
|
|
189
|
+
}
|
|
190
|
+
function bitbucketKey(email) {
|
|
191
|
+
return `${email}:bitbucket`;
|
|
192
|
+
}
|
|
193
|
+
function saveBitbucketToken(email, token) {
|
|
194
|
+
entry(bitbucketKey(email)).setPassword(token);
|
|
195
|
+
}
|
|
196
|
+
function readBitbucketToken(email) {
|
|
197
|
+
return entry(bitbucketKey(email)).getPassword();
|
|
198
|
+
}
|
|
199
|
+
function deleteBitbucketToken(email) {
|
|
200
|
+
deleteEntryIfPresent(bitbucketKey(email));
|
|
201
|
+
}
|
|
202
|
+
async function requireAuth() {
|
|
203
|
+
const config = await readConfig();
|
|
204
|
+
if (!config || !config.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
|
|
205
|
+
const token = readToken(config.email);
|
|
206
|
+
if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
|
|
207
|
+
return {
|
|
208
|
+
site: config.site,
|
|
209
|
+
email: config.email,
|
|
210
|
+
token
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
async function requireBitbucketAuth() {
|
|
214
|
+
const config = await readConfig();
|
|
215
|
+
if (!config || !config.email || !config.bitbucket?.workspace) throw new Error("Not logged in to Bitbucket. Run `atlass bitbucket login` first.");
|
|
216
|
+
const token = readBitbucketToken(config.email);
|
|
217
|
+
if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
|
|
218
|
+
return {
|
|
219
|
+
site: BITBUCKET_ORIGIN,
|
|
220
|
+
email: config.email,
|
|
221
|
+
token,
|
|
222
|
+
workspace: config.bitbucket.workspace,
|
|
223
|
+
defaultRepo: config.bitbucket.defaultRepo
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/commands/auth.ts
|
|
228
|
+
async function login() {
|
|
229
|
+
const site = siteOrigin(await input({
|
|
230
|
+
message: "Atlassian site (e.g. acme.atlassian.net):",
|
|
231
|
+
required: true
|
|
232
|
+
}));
|
|
233
|
+
const email = await input({
|
|
234
|
+
message: "Account email:",
|
|
235
|
+
required: true
|
|
236
|
+
});
|
|
237
|
+
const token = await password({
|
|
238
|
+
message: "API token (from id.atlassian.com/manage-profile/security/api-tokens):",
|
|
239
|
+
mask: true
|
|
240
|
+
});
|
|
241
|
+
const me = await new AtlassianClient({
|
|
242
|
+
site,
|
|
243
|
+
email,
|
|
244
|
+
token
|
|
245
|
+
}).getJson("/rest/api/3/myself");
|
|
246
|
+
await writeConfig({
|
|
247
|
+
...await readConfig() ?? {},
|
|
248
|
+
site,
|
|
249
|
+
email
|
|
250
|
+
});
|
|
251
|
+
saveToken(email, token);
|
|
252
|
+
console.log(`Logged in as ${me.displayName} on ${site}.`);
|
|
253
|
+
}
|
|
254
|
+
async function logout() {
|
|
255
|
+
const config = await readConfig();
|
|
256
|
+
if (config?.email) deleteToken(config.email);
|
|
257
|
+
const bitbucketLogin = config?.bitbucket && config.email ? {
|
|
258
|
+
email: config.email,
|
|
259
|
+
bitbucket: config.bitbucket
|
|
260
|
+
} : null;
|
|
261
|
+
if (bitbucketLogin) await writeConfig(bitbucketLogin);
|
|
262
|
+
else await clearConfig();
|
|
263
|
+
console.log("Logged out. Credentials removed.");
|
|
264
|
+
}
|
|
265
|
+
async function status() {
|
|
266
|
+
const config = await readConfig();
|
|
267
|
+
if (!config || !config.site || !config.email) {
|
|
268
|
+
console.log("Not logged in. Run `atlass auth login`.");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const hasToken = readToken(config.email) !== null;
|
|
272
|
+
console.log(`Site: ${config.site}`);
|
|
273
|
+
console.log(`Email: ${config.email}`);
|
|
274
|
+
console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/cli/auth.ts
|
|
278
|
+
function registerAuth(auth) {
|
|
279
|
+
auth.description("Manage Atlassian credentials");
|
|
280
|
+
auth.command("login").description("Store site, email, and API token").action(run(login));
|
|
281
|
+
auth.command("logout").description("Remove stored credentials").action(run(logout));
|
|
282
|
+
auth.command("status").description("Show the current login").action(run(status));
|
|
283
|
+
return auth;
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/api/bitbucket.ts
|
|
287
|
+
const BITBUCKET_MAX_PAGELEN = 100;
|
|
288
|
+
const BUILD_NUMBER_SCAN_LIMIT = 1e3;
|
|
289
|
+
function pipelineStatus(state) {
|
|
290
|
+
if (!state) return "";
|
|
291
|
+
if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
|
|
292
|
+
return state.name ?? "";
|
|
293
|
+
}
|
|
294
|
+
function pipelinesQuery(limit) {
|
|
295
|
+
return new URLSearchParams({
|
|
296
|
+
sort: "-created_on",
|
|
297
|
+
pagelen: String(Math.min(Math.max(limit, 1), BITBUCKET_MAX_PAGELEN))
|
|
298
|
+
}).toString();
|
|
299
|
+
}
|
|
300
|
+
function wallClockSeconds(startOn, endOn) {
|
|
301
|
+
if (!startOn || !endOn) return null;
|
|
302
|
+
const start = Date.parse(startOn);
|
|
303
|
+
const end = Date.parse(endOn);
|
|
304
|
+
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
|
305
|
+
return Math.floor((end - start) / 1e3);
|
|
306
|
+
}
|
|
307
|
+
function shortHash(hash) {
|
|
308
|
+
return hash?.slice(0, 7) ?? "";
|
|
309
|
+
}
|
|
310
|
+
function toPipelineSummary(p) {
|
|
311
|
+
return {
|
|
312
|
+
buildNumber: p.build_number,
|
|
313
|
+
status: pipelineStatus(p.state),
|
|
314
|
+
ref: p.target?.ref_name ?? "",
|
|
315
|
+
commit: shortHash(p.target?.commit?.hash),
|
|
316
|
+
durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
|
|
317
|
+
createdOn: p.created_on ?? "",
|
|
318
|
+
creator: p.creator?.display_name ?? "",
|
|
319
|
+
uuid: p.uuid
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function toPipelineDetail(p) {
|
|
323
|
+
return {
|
|
324
|
+
...toPipelineSummary(p),
|
|
325
|
+
repo: p.repository?.full_name ?? "",
|
|
326
|
+
trigger: p.trigger?.name ?? ""
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function toStepSummary(step) {
|
|
330
|
+
return {
|
|
331
|
+
name: step.name ?? "",
|
|
332
|
+
status: pipelineStatus(step.state),
|
|
333
|
+
durationSeconds: wallClockSeconds(step.started_on, step.completed_on)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function repoPath(ref) {
|
|
337
|
+
return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/pipelines`;
|
|
338
|
+
}
|
|
339
|
+
async function* walkPages(client, firstPath) {
|
|
340
|
+
let path = firstPath;
|
|
341
|
+
while (path) {
|
|
342
|
+
const page = await client.getJson(path);
|
|
343
|
+
for (const value of page.values ?? []) yield value;
|
|
344
|
+
path = page.next ? pathAndQuery(page.next) : null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function listPipelines(client, ref, limit) {
|
|
348
|
+
const out = [];
|
|
349
|
+
const first = `${repoPath(ref)}?${pipelinesQuery(limit)}`;
|
|
350
|
+
for await (const value of walkPages(client, first)) {
|
|
351
|
+
out.push(toPipelineSummary(value));
|
|
352
|
+
if (out.length >= limit) break;
|
|
353
|
+
}
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
async function getPipeline(client, ref, buildNumber) {
|
|
357
|
+
const found = await fetchByBuildNumber(client, ref, buildNumber) ?? await findInRecentRuns(client, ref, buildNumber);
|
|
358
|
+
if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${BUILD_NUMBER_SCAN_LIMIT} most recent runs. It may be too old.`);
|
|
359
|
+
return toPipelineDetail(found);
|
|
360
|
+
}
|
|
361
|
+
async function fetchByBuildNumber(client, ref, buildNumber) {
|
|
362
|
+
try {
|
|
363
|
+
return await client.getJson(`${repoPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
|
|
366
|
+
throw err;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function findInRecentRuns(client, ref, buildNumber) {
|
|
370
|
+
const first = `${repoPath(ref)}?${pipelinesQuery(BITBUCKET_MAX_PAGELEN)}`;
|
|
371
|
+
let scanned = 0;
|
|
372
|
+
for await (const pipeline of walkPages(client, first)) {
|
|
373
|
+
if (pipeline.build_number === buildNumber) return pipeline;
|
|
374
|
+
if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
async function listSteps(client, ref, pipelineId) {
|
|
379
|
+
const out = [];
|
|
380
|
+
const first = `${repoPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
|
|
381
|
+
for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/util/format.ts
|
|
386
|
+
function formatDuration(seconds) {
|
|
387
|
+
if (seconds == null) return "-";
|
|
388
|
+
if (seconds < 60) return `${seconds}s`;
|
|
389
|
+
const m = Math.floor(seconds / 60);
|
|
390
|
+
const s = seconds % 60;
|
|
391
|
+
if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
|
|
392
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
393
|
+
}
|
|
394
|
+
function formatDateTime(iso) {
|
|
395
|
+
const date = new Date(iso);
|
|
396
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
397
|
+
return date.toISOString().replace("T", " ").slice(0, 16);
|
|
398
|
+
}
|
|
399
|
+
const MINUTE = 60;
|
|
400
|
+
const HOUR = 60 * MINUTE;
|
|
401
|
+
const DAY = 24 * HOUR;
|
|
402
|
+
function relativeTime(iso, nowMs) {
|
|
403
|
+
if (!iso) return "-";
|
|
404
|
+
const then = Date.parse(iso);
|
|
405
|
+
if (Number.isNaN(then)) return "-";
|
|
406
|
+
const secs = Math.floor((nowMs - then) / 1e3);
|
|
407
|
+
if (secs < MINUTE) return "just now";
|
|
408
|
+
if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
|
|
409
|
+
if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
|
|
410
|
+
const days = Math.floor(secs / DAY);
|
|
411
|
+
if (days < 45) return `${days}d ago`;
|
|
412
|
+
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
|
413
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
414
|
+
}
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/util/parse.ts
|
|
417
|
+
function parseIssueKey(input) {
|
|
418
|
+
const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
|
|
419
|
+
return match ? match[0] : null;
|
|
420
|
+
}
|
|
421
|
+
function parsePageId(input) {
|
|
422
|
+
const trimmed = input.trim();
|
|
423
|
+
if (/^\d+$/.test(trimmed)) return trimmed;
|
|
424
|
+
const fromPath = trimmed.match(/\/pages\/(\d+)/);
|
|
425
|
+
if (fromPath) return fromPath[1];
|
|
426
|
+
const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
|
|
427
|
+
if (fromQuery) return fromQuery[1];
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
function isExternalHref(href) {
|
|
431
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
|
|
432
|
+
}
|
|
433
|
+
function resolveRepo(flag, config) {
|
|
434
|
+
if (flag) {
|
|
435
|
+
if (flag.includes("/")) {
|
|
436
|
+
const parts = flag.split("/");
|
|
437
|
+
const [workspace, repo] = parts;
|
|
438
|
+
if (parts.length !== 2 || !workspace || !repo) throw new Error(`Invalid --repo "${flag}". Expected "workspace/slug" or "slug".`);
|
|
439
|
+
return {
|
|
440
|
+
workspace,
|
|
441
|
+
repo
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (!config.workspace) throw new Error(`No Bitbucket workspace configured. Pass --repo workspace/slug or run \`atlass bitbucket login\`.`);
|
|
445
|
+
return {
|
|
446
|
+
workspace: config.workspace,
|
|
447
|
+
repo: flag
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (config.workspace && config.defaultRepo) return {
|
|
451
|
+
workspace: config.workspace,
|
|
452
|
+
repo: config.defaultRepo
|
|
453
|
+
};
|
|
454
|
+
throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
|
|
455
|
+
}
|
|
456
|
+
const DEFAULT_LIMIT = 25;
|
|
457
|
+
const MAX_LIMIT = 100;
|
|
458
|
+
function parseLimit(value) {
|
|
459
|
+
if (!value) return DEFAULT_LIMIT;
|
|
460
|
+
const n = Number.parseInt(value, 10);
|
|
461
|
+
if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
|
|
462
|
+
return Math.min(n, MAX_LIMIT);
|
|
463
|
+
}
|
|
464
|
+
//#endregion
|
|
465
|
+
//#region src/commands/bitbucket.ts
|
|
466
|
+
async function bitbucketLogin() {
|
|
467
|
+
const existing = await readConfig() ?? {};
|
|
468
|
+
const email = existing.email ?? await input({
|
|
469
|
+
message: "Account email:",
|
|
470
|
+
required: true
|
|
471
|
+
});
|
|
472
|
+
const workspace = (await input({
|
|
473
|
+
message: "Bitbucket workspace (e.g. acme):",
|
|
474
|
+
required: true
|
|
475
|
+
})).trim();
|
|
476
|
+
const defaultRepo = (await input({ message: "Default repo slug (optional):" })).trim() || void 0;
|
|
477
|
+
const token = await password({
|
|
478
|
+
message: "Bitbucket API token (needs read:pipeline + workspace read scopes):",
|
|
479
|
+
mask: true
|
|
480
|
+
});
|
|
481
|
+
const ws = await verifyWorkspace(new AtlassianClient({
|
|
482
|
+
site: BITBUCKET_ORIGIN,
|
|
483
|
+
email,
|
|
484
|
+
token
|
|
485
|
+
}), workspace);
|
|
486
|
+
await writeConfig({
|
|
487
|
+
...existing,
|
|
488
|
+
email,
|
|
489
|
+
bitbucket: {
|
|
490
|
+
workspace,
|
|
491
|
+
...defaultRepo ? { defaultRepo } : {}
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
saveBitbucketToken(email, token);
|
|
495
|
+
console.log(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
|
|
496
|
+
}
|
|
497
|
+
async function bitbucketLogout() {
|
|
498
|
+
const config = await readConfig();
|
|
499
|
+
if (config?.email) deleteBitbucketToken(config.email);
|
|
500
|
+
const jiraLogin = config?.site && config.email ? {
|
|
501
|
+
site: config.site,
|
|
502
|
+
email: config.email
|
|
503
|
+
} : null;
|
|
504
|
+
if (jiraLogin) await writeConfig(jiraLogin);
|
|
505
|
+
else await clearConfig();
|
|
506
|
+
console.log("Logged out of Bitbucket. Credentials removed.");
|
|
507
|
+
}
|
|
508
|
+
async function bitbucketStatus() {
|
|
509
|
+
const config = await readConfig();
|
|
510
|
+
if (!config?.email || !config.bitbucket?.workspace) {
|
|
511
|
+
console.log("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const hasToken = readBitbucketToken(config.email) !== null;
|
|
515
|
+
console.log(`Workspace: ${config.bitbucket.workspace}`);
|
|
516
|
+
console.log(`Email: ${config.email}`);
|
|
517
|
+
console.log(`Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`);
|
|
518
|
+
console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`);
|
|
519
|
+
}
|
|
520
|
+
async function bitbucketPipelines(options) {
|
|
521
|
+
const auth = await requireBitbucketAuth();
|
|
522
|
+
const ref = resolveRepo(options.repo, auth);
|
|
523
|
+
const client = new AtlassianClient(auth);
|
|
524
|
+
const limit = parseLimit(options.limit);
|
|
525
|
+
const pipelines = await withScopeHint(() => listPipelines(client, ref, limit));
|
|
526
|
+
if (options.json) {
|
|
527
|
+
console.log(JSON.stringify(pipelines, null, 2));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (pipelines.length === 0) {
|
|
531
|
+
console.log("No pipelines found.");
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
for (const line of formatPipelineRows(pipelines, Date.now())) console.log(line);
|
|
535
|
+
}
|
|
536
|
+
async function bitbucketPipeline(arg, options) {
|
|
537
|
+
const buildNumber = parseBuildNumber(arg);
|
|
538
|
+
const auth = await requireBitbucketAuth();
|
|
539
|
+
const ref = resolveRepo(options.repo, auth);
|
|
540
|
+
const client = new AtlassianClient(auth);
|
|
541
|
+
const detail = await withScopeHint(() => getPipeline(client, ref, buildNumber));
|
|
542
|
+
printPipelineDetail(detail, await withScopeHint(() => listSteps(client, ref, detail.uuid)), Date.now());
|
|
543
|
+
}
|
|
544
|
+
function formatPipelineRows(pipelines, nowMs) {
|
|
545
|
+
const rows = pipelines.map((p) => ({
|
|
546
|
+
num: `#${p.buildNumber}`,
|
|
547
|
+
status: p.status || "-",
|
|
548
|
+
ref: p.ref || p.commit || "-",
|
|
549
|
+
dur: formatDuration(p.durationSeconds),
|
|
550
|
+
age: relativeTime(p.createdOn, nowMs),
|
|
551
|
+
creator: p.creator || "-"
|
|
552
|
+
}));
|
|
553
|
+
const width = (sel) => Math.max(...rows.map((r) => sel(r).length));
|
|
554
|
+
const wn = width((r) => r.num);
|
|
555
|
+
const ws = width((r) => r.status);
|
|
556
|
+
const wr = width((r) => r.ref);
|
|
557
|
+
const wd = width((r) => r.dur);
|
|
558
|
+
const wa = width((r) => r.age);
|
|
559
|
+
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}`);
|
|
560
|
+
}
|
|
561
|
+
function formatStepRows(steps) {
|
|
562
|
+
const rows = steps.map((s) => ({
|
|
563
|
+
name: s.name || "-",
|
|
564
|
+
status: s.status || "-",
|
|
565
|
+
dur: formatDuration(s.durationSeconds)
|
|
566
|
+
}));
|
|
567
|
+
const wn = Math.max(...rows.map((r) => r.name.length));
|
|
568
|
+
const ws = Math.max(...rows.map((r) => r.status.length));
|
|
569
|
+
return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
|
|
570
|
+
}
|
|
571
|
+
function printPipelineDetail(detail, steps, nowMs) {
|
|
572
|
+
console.log(`Pipeline #${detail.buildNumber} ${detail.status || "-"}`);
|
|
573
|
+
if (detail.repo) console.log(`Repo: ${detail.repo}`);
|
|
574
|
+
console.log(`Ref: ${refWithCommit(detail)}`);
|
|
575
|
+
if (detail.trigger) console.log(`Trigger: ${detail.trigger}`);
|
|
576
|
+
console.log(`Duration: ${formatDuration(detail.durationSeconds)}`);
|
|
577
|
+
const by = detail.creator ? ` by ${detail.creator}` : "";
|
|
578
|
+
console.log(`Created: ${relativeTime(detail.createdOn, nowMs)}${by}`);
|
|
579
|
+
if (steps.length > 0) {
|
|
580
|
+
console.log("");
|
|
581
|
+
console.log("Steps:");
|
|
582
|
+
for (const line of formatStepRows(steps)) console.log(line);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function refWithCommit(detail) {
|
|
586
|
+
if (!detail.ref) return detail.commit || "-";
|
|
587
|
+
return detail.commit ? `${detail.ref} (${detail.commit})` : detail.ref;
|
|
588
|
+
}
|
|
589
|
+
async function verifyWorkspace(client, workspace) {
|
|
590
|
+
try {
|
|
591
|
+
return await client.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
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.`);
|
|
594
|
+
if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
|
|
595
|
+
throw err;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function parseBuildNumber(arg) {
|
|
599
|
+
const raw = (arg ?? "").replace(/^#/, "").trim();
|
|
600
|
+
if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
|
|
601
|
+
return Number.parseInt(raw, 10);
|
|
602
|
+
}
|
|
603
|
+
async function withScopeHint(fn) {
|
|
604
|
+
try {
|
|
605
|
+
return await fn();
|
|
606
|
+
} catch (err) {
|
|
607
|
+
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.");
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
//#endregion
|
|
612
|
+
//#region src/cli/bitbucket.ts
|
|
613
|
+
function registerBitbucket(bitbucket) {
|
|
614
|
+
bitbucket.description("Bitbucket commands");
|
|
615
|
+
bitbucket.command("login").description("Store Bitbucket workspace and API token").action(run(bitbucketLogin));
|
|
616
|
+
bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(run(bitbucketLogout));
|
|
617
|
+
bitbucket.command("status").description("Show the current Bitbucket login").action(run(bitbucketStatus));
|
|
618
|
+
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));
|
|
619
|
+
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));
|
|
620
|
+
return bitbucket;
|
|
621
|
+
}
|
|
622
|
+
//#endregion
|
|
623
|
+
//#region src/adf/from-markdown.ts
|
|
624
|
+
function mediaNode(source, alt) {
|
|
625
|
+
const attrs = { ...source };
|
|
626
|
+
if (alt) attrs["alt"] = alt;
|
|
627
|
+
return {
|
|
628
|
+
type: "mediaSingle",
|
|
629
|
+
attrs: { layout: "center" },
|
|
630
|
+
content: [{
|
|
631
|
+
type: "media",
|
|
632
|
+
attrs
|
|
633
|
+
}]
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function imageHrefs(md) {
|
|
637
|
+
const hrefs = /* @__PURE__ */ new Set();
|
|
638
|
+
marked.walkTokens(marked.lexer(md), (token) => {
|
|
639
|
+
if (token.type === "image") hrefs.add(token.href);
|
|
640
|
+
});
|
|
641
|
+
return [...hrefs];
|
|
642
|
+
}
|
|
643
|
+
function markdownToAdf(md, options = {}) {
|
|
644
|
+
const ctx = { resolveImage: options.resolveImage ?? externalMedia };
|
|
645
|
+
return {
|
|
646
|
+
type: "doc",
|
|
647
|
+
version: 1,
|
|
648
|
+
content: blocks(marked.lexer(md), ctx)
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function blocks(tokens, ctx) {
|
|
652
|
+
const out = [];
|
|
653
|
+
for (const token of tokens) out.push(...block(token, ctx));
|
|
654
|
+
return out;
|
|
655
|
+
}
|
|
656
|
+
function block(token, ctx) {
|
|
657
|
+
switch (token.type) {
|
|
658
|
+
case "space": return [];
|
|
659
|
+
case "heading": return [{
|
|
660
|
+
type: "heading",
|
|
661
|
+
attrs: { level: clampLevel$1(token.depth) },
|
|
662
|
+
content: inline(token.tokens ?? [], ctx)
|
|
663
|
+
}];
|
|
664
|
+
case "paragraph": return paragraphsSplitAtImages(token.tokens ?? [], ctx);
|
|
665
|
+
case "text": return paragraphsSplitAtImages(token.tokens ?? [textToken(token.text)], ctx);
|
|
666
|
+
case "list": return [list(token, ctx)];
|
|
667
|
+
case "code": return [codeBlock(token)];
|
|
668
|
+
case "blockquote": return [{
|
|
669
|
+
type: "blockquote",
|
|
670
|
+
content: blocks(token.tokens ?? [], ctx)
|
|
671
|
+
}];
|
|
672
|
+
case "table": return [table(token, ctx)];
|
|
673
|
+
case "hr": return [{ type: "rule" }];
|
|
674
|
+
default: return [];
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function paragraphsSplitAtImages(tokens, ctx) {
|
|
678
|
+
const out = [];
|
|
679
|
+
let buffer = [];
|
|
680
|
+
const flush = () => {
|
|
681
|
+
if (buffer.length === 0) return;
|
|
682
|
+
const nodes = inline(buffer, ctx);
|
|
683
|
+
if (nodes.length > 0) out.push({
|
|
684
|
+
type: "paragraph",
|
|
685
|
+
content: nodes
|
|
686
|
+
});
|
|
687
|
+
buffer = [];
|
|
688
|
+
};
|
|
689
|
+
for (const token of tokens) if (token.type === "image") {
|
|
690
|
+
flush();
|
|
691
|
+
const image = token;
|
|
692
|
+
const node = ctx.resolveImage(image.href, image.text ?? "");
|
|
693
|
+
if (node) out.push(node);
|
|
694
|
+
} else buffer.push(token);
|
|
695
|
+
flush();
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
function list(token, ctx) {
|
|
699
|
+
if (token.items.length > 0 && token.items.every((i) => i.task)) return {
|
|
700
|
+
type: "taskList",
|
|
701
|
+
attrs: { localId: randomUUID() },
|
|
702
|
+
content: token.items.map((item) => ({
|
|
703
|
+
type: "taskItem",
|
|
704
|
+
attrs: {
|
|
705
|
+
localId: randomUUID(),
|
|
706
|
+
state: item.checked ? "DONE" : "TODO"
|
|
707
|
+
},
|
|
708
|
+
content: inline(taskItemInline(item), ctx)
|
|
709
|
+
}))
|
|
710
|
+
};
|
|
711
|
+
const node = {
|
|
712
|
+
type: token.ordered ? "orderedList" : "bulletList",
|
|
713
|
+
content: token.items.map((item) => listItem(item, ctx))
|
|
714
|
+
};
|
|
715
|
+
const start = Number(token.start);
|
|
716
|
+
if (token.ordered && Number.isFinite(start) && start !== 1) node.attrs = { order: start };
|
|
717
|
+
return node;
|
|
718
|
+
}
|
|
719
|
+
function listItem(item, ctx) {
|
|
720
|
+
const children = blocks(item.tokens.filter((t) => t.type !== "checkbox"), ctx);
|
|
721
|
+
return {
|
|
722
|
+
type: "listItem",
|
|
723
|
+
content: children.length > 0 ? children : [emptyParagraph()]
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
function emptyParagraph() {
|
|
727
|
+
return {
|
|
728
|
+
type: "paragraph",
|
|
729
|
+
content: []
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
function taskItemInline(item) {
|
|
733
|
+
const first = item.tokens.find((t) => t.type === "text");
|
|
734
|
+
if (first && "tokens" in first && first.tokens) return first.tokens;
|
|
735
|
+
return [textToken(item.text)];
|
|
736
|
+
}
|
|
737
|
+
function codeBlock(token) {
|
|
738
|
+
return {
|
|
739
|
+
type: "codeBlock",
|
|
740
|
+
attrs: token.lang ? { language: token.lang } : {},
|
|
741
|
+
content: token.text.length > 0 ? [{
|
|
742
|
+
type: "text",
|
|
743
|
+
text: token.text
|
|
744
|
+
}] : []
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
function table(token, ctx) {
|
|
748
|
+
return {
|
|
749
|
+
type: "table",
|
|
750
|
+
content: [{
|
|
751
|
+
type: "tableRow",
|
|
752
|
+
content: token.header.map((cell) => tableCell(cell, "tableHeader", ctx))
|
|
753
|
+
}, ...token.rows.map((row) => ({
|
|
754
|
+
type: "tableRow",
|
|
755
|
+
content: row.map((cell) => tableCell(cell, "tableCell", ctx))
|
|
756
|
+
}))]
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
function tableCell(cell, type, ctx) {
|
|
760
|
+
return {
|
|
761
|
+
type,
|
|
762
|
+
content: [{
|
|
763
|
+
type: "paragraph",
|
|
764
|
+
content: inline(cell.tokens, ctx)
|
|
765
|
+
}]
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
function inline(tokens, ctx, marks = []) {
|
|
769
|
+
const out = [];
|
|
770
|
+
for (const token of tokens) out.push(...inlineNode(token, ctx, marks));
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
function inlineNode(token, ctx, marks) {
|
|
774
|
+
switch (token.type) {
|
|
775
|
+
case "text":
|
|
776
|
+
case "escape": {
|
|
777
|
+
const t = token;
|
|
778
|
+
if ("tokens" in t && t.tokens?.length) return inline(t.tokens, ctx, marks);
|
|
779
|
+
return textNode(t.text, marks);
|
|
780
|
+
}
|
|
781
|
+
case "strong": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strong" }));
|
|
782
|
+
case "em": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "em" }));
|
|
783
|
+
case "del": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strike" }));
|
|
784
|
+
case "codespan": return textNode(token.text, withUniqueMark(marks, { type: "code" }));
|
|
785
|
+
case "link": {
|
|
786
|
+
const link = token;
|
|
787
|
+
return inline(link.tokens, ctx, withUniqueMark(marks, {
|
|
788
|
+
type: "link",
|
|
789
|
+
attrs: { href: link.href }
|
|
790
|
+
}));
|
|
791
|
+
}
|
|
792
|
+
case "br": return [{ type: "hardBreak" }];
|
|
793
|
+
case "html": return textNode(token.text, marks);
|
|
794
|
+
default: return "text" in token && token.text ? textNode(token.text, marks) : [];
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function textNode(text, marks) {
|
|
798
|
+
if (text.length === 0) return [];
|
|
799
|
+
const node = {
|
|
800
|
+
type: "text",
|
|
801
|
+
text
|
|
802
|
+
};
|
|
803
|
+
if (marks.length > 0) node.marks = marks;
|
|
804
|
+
return [node];
|
|
805
|
+
}
|
|
806
|
+
function withUniqueMark(marks, mark) {
|
|
807
|
+
return [...marks.filter((m) => m.type !== mark.type), mark];
|
|
808
|
+
}
|
|
809
|
+
function externalMedia(href, alt) {
|
|
810
|
+
return mediaNode({
|
|
811
|
+
type: "external",
|
|
812
|
+
url: href
|
|
813
|
+
}, alt);
|
|
814
|
+
}
|
|
815
|
+
function textToken(text) {
|
|
816
|
+
return {
|
|
817
|
+
type: "text",
|
|
818
|
+
raw: text,
|
|
819
|
+
text,
|
|
820
|
+
escaped: false
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
function clampLevel$1(value) {
|
|
824
|
+
return Math.min(6, Math.max(1, Math.trunc(value) || 1));
|
|
825
|
+
}
|
|
826
|
+
//#endregion
|
|
827
|
+
//#region src/util/html.ts
|
|
828
|
+
const MAX_CODE_POINT = 1114111;
|
|
829
|
+
const NAMED = {
|
|
830
|
+
amp: "&",
|
|
831
|
+
lt: "<",
|
|
832
|
+
gt: ">",
|
|
833
|
+
quot: "\"",
|
|
834
|
+
apos: "'",
|
|
835
|
+
nbsp: " "
|
|
836
|
+
};
|
|
837
|
+
function decodeEntities(text) {
|
|
838
|
+
return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
|
|
839
|
+
if (body[0] === "#") {
|
|
840
|
+
const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
|
|
841
|
+
return isCodePoint(code) ? String.fromCodePoint(code) : match;
|
|
842
|
+
}
|
|
843
|
+
return NAMED[body.toLowerCase()] ?? match;
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
function isCodePoint(code) {
|
|
847
|
+
return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/api/confluence.ts
|
|
851
|
+
async function fetchPage(client, site, id) {
|
|
852
|
+
const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
|
|
853
|
+
const names = new UserNames(client);
|
|
854
|
+
const [spaceKey, attachments, comments, author] = await Promise.all([
|
|
855
|
+
fetchSpaceKey(client, page.spaceId),
|
|
856
|
+
fetchAttachments(client, id),
|
|
857
|
+
fetchComments$1(client, id, names),
|
|
858
|
+
names.resolve(page.version?.authorId ?? page.authorId)
|
|
859
|
+
]);
|
|
860
|
+
const webui = page._links?.webui ?? "";
|
|
861
|
+
return {
|
|
862
|
+
id: page.id,
|
|
863
|
+
title: page.title,
|
|
864
|
+
spaceKey,
|
|
865
|
+
version: page.version?.number ?? 0,
|
|
866
|
+
author,
|
|
867
|
+
createdAt: page.createdAt ?? "",
|
|
868
|
+
updatedAt: page.version?.createdAt ?? "",
|
|
869
|
+
url: webui ? `${site}/wiki${webui}` : "",
|
|
870
|
+
body: parseAdfJson(page.body?.atlas_doc_format?.value),
|
|
871
|
+
attachments,
|
|
872
|
+
comments
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
async function fetchPageState(client, id) {
|
|
876
|
+
const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
|
|
877
|
+
return {
|
|
878
|
+
version: page.version?.number ?? 0,
|
|
879
|
+
title: page.title,
|
|
880
|
+
body: parseAdfJson(page.body?.atlas_doc_format?.value)
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
const UNKNOWN_SIZE = -1;
|
|
884
|
+
async function listAttachments(client, id) {
|
|
885
|
+
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
|
|
886
|
+
filename: a.title ?? a.id,
|
|
887
|
+
fileId: mediaNodeId(a),
|
|
888
|
+
size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
|
|
889
|
+
}));
|
|
890
|
+
}
|
|
891
|
+
async function uploadAttachment(client, pageId, filename, bytes) {
|
|
892
|
+
return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
|
|
893
|
+
}
|
|
894
|
+
async function fileIdByListing(client, pageId, filename) {
|
|
895
|
+
const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
|
|
896
|
+
if (match) return match.fileId;
|
|
897
|
+
throw new Error(`Upload of "${filename}" did not return a fileId.`);
|
|
898
|
+
}
|
|
899
|
+
async function updatePage(client, id, params) {
|
|
900
|
+
return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
|
|
901
|
+
id,
|
|
902
|
+
status: "current",
|
|
903
|
+
title: params.title,
|
|
904
|
+
body: {
|
|
905
|
+
representation: "atlas_doc_format",
|
|
906
|
+
value: JSON.stringify(params.body)
|
|
907
|
+
},
|
|
908
|
+
version: {
|
|
909
|
+
number: params.nextVersion,
|
|
910
|
+
message: params.message
|
|
911
|
+
}
|
|
912
|
+
})).version?.number ?? params.nextVersion;
|
|
913
|
+
}
|
|
914
|
+
async function searchPages(client, site, params) {
|
|
915
|
+
const cql = buildCql(params);
|
|
916
|
+
const query = new URLSearchParams({
|
|
917
|
+
cql,
|
|
918
|
+
limit: String(params.limit),
|
|
919
|
+
expand: "space"
|
|
920
|
+
});
|
|
921
|
+
const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
|
|
922
|
+
const serverPageWasFull = results.length === params.limit;
|
|
923
|
+
return {
|
|
924
|
+
pages: results.filter((r) => r.content?.id).map((r) => ({
|
|
925
|
+
id: r.content?.id ?? "",
|
|
926
|
+
space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
|
|
927
|
+
title: decodeEntities(r.content?.title ?? r.title ?? ""),
|
|
928
|
+
url: r.url ? `${site}/wiki${r.url}` : ""
|
|
929
|
+
})),
|
|
930
|
+
hasMore: serverPageWasFull
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
function buildCql(params) {
|
|
934
|
+
if (params.cql) return params.cql;
|
|
935
|
+
const clauses = ["type = page"];
|
|
936
|
+
if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
|
|
937
|
+
if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
|
|
938
|
+
return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
|
|
939
|
+
}
|
|
940
|
+
function cqlStringLiteral(value) {
|
|
941
|
+
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
942
|
+
}
|
|
943
|
+
async function fetchSpaceKey(client, spaceId) {
|
|
944
|
+
if (!spaceId) return "";
|
|
945
|
+
try {
|
|
946
|
+
return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
|
|
947
|
+
} catch {
|
|
948
|
+
return "";
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
async function fetchAttachments(client, id) {
|
|
952
|
+
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
|
|
953
|
+
mediaId: mediaNodeId(a),
|
|
954
|
+
filename: a.title ?? a.id,
|
|
955
|
+
url: withWikiContextPath(a.downloadLink ?? "")
|
|
956
|
+
}));
|
|
957
|
+
}
|
|
958
|
+
function mediaNodeId(a) {
|
|
959
|
+
return a.fileId ?? a.id;
|
|
960
|
+
}
|
|
961
|
+
async function fetchComments$1(client, id, names) {
|
|
962
|
+
const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
|
|
963
|
+
return Promise.all(res.results.map(async (c) => ({
|
|
964
|
+
author: await names.resolve(c.version?.authorId),
|
|
965
|
+
created: c.version?.createdAt ?? "",
|
|
966
|
+
body: parseAdfJson(c.body?.atlas_doc_format?.value)
|
|
967
|
+
})));
|
|
968
|
+
}
|
|
969
|
+
function withWikiContextPath(link) {
|
|
970
|
+
if (link.startsWith("http") || link.startsWith("/wiki")) return link;
|
|
971
|
+
return `/wiki${link}`;
|
|
972
|
+
}
|
|
973
|
+
function parseAdfJson(value) {
|
|
974
|
+
if (!value) return null;
|
|
975
|
+
try {
|
|
976
|
+
return JSON.parse(value);
|
|
977
|
+
} catch {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
var UserNames = class {
|
|
982
|
+
client;
|
|
983
|
+
cache = /* @__PURE__ */ new Map();
|
|
984
|
+
constructor(client) {
|
|
985
|
+
this.client = client;
|
|
986
|
+
}
|
|
987
|
+
async resolve(accountId) {
|
|
988
|
+
if (!accountId) return "";
|
|
989
|
+
const cached = this.cache.get(accountId);
|
|
990
|
+
if (cached !== void 0) return cached;
|
|
991
|
+
const name = await this.fetchDisplayName(accountId) ?? accountId;
|
|
992
|
+
this.cache.set(accountId, name);
|
|
993
|
+
return name;
|
|
994
|
+
}
|
|
995
|
+
async fetchDisplayName(accountId) {
|
|
996
|
+
try {
|
|
997
|
+
return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
|
|
998
|
+
} catch {
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
//#endregion
|
|
1004
|
+
//#region src/commands/resolve-ref.ts
|
|
1005
|
+
async function resolveRef(arg, kind) {
|
|
1006
|
+
const raw = arg ?? await input({
|
|
1007
|
+
message: kind.message,
|
|
1008
|
+
required: true
|
|
1009
|
+
});
|
|
1010
|
+
const ref = kind.parse(raw);
|
|
1011
|
+
if (!ref) throw new Error(kind.notFound(raw));
|
|
1012
|
+
return ref;
|
|
1013
|
+
}
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region src/commands/search-run.ts
|
|
1016
|
+
const COPY_CONCURRENCY = 5;
|
|
1017
|
+
async function runSearch(rows, options, noun, copyOne) {
|
|
1018
|
+
if (options.json) {
|
|
1019
|
+
console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (rows.length === 0) {
|
|
1023
|
+
console.log(options.empty);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (options.copy) {
|
|
1027
|
+
if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
|
|
1028
|
+
await copySelected(rows, noun, copyOne);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
for (const row of rows) console.log(formatRow(row));
|
|
1032
|
+
if (options.footer) console.log(options.footer);
|
|
1033
|
+
}
|
|
1034
|
+
function searchFooter(limit) {
|
|
1035
|
+
return `
|
|
1036
|
+
Showing first ${limit}; refine with flags or raise --limit.`;
|
|
1037
|
+
}
|
|
1038
|
+
async function copySelected(rows, noun, copyOne) {
|
|
1039
|
+
if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
|
|
1040
|
+
const selected = await checkbox({
|
|
1041
|
+
message: `Select ${noun.plural} to copy:`,
|
|
1042
|
+
choices: rows.map((r) => ({
|
|
1043
|
+
name: formatRow(r),
|
|
1044
|
+
value: r.id
|
|
1045
|
+
})),
|
|
1046
|
+
pageSize: 20
|
|
1047
|
+
});
|
|
1048
|
+
if (selected.length === 0) {
|
|
1049
|
+
console.log("Nothing selected.");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
let copied = 0;
|
|
1053
|
+
const failures = [];
|
|
1054
|
+
const queue = [...selected];
|
|
1055
|
+
async function worker() {
|
|
1056
|
+
for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
|
|
1057
|
+
await copyOne(id);
|
|
1058
|
+
copied++;
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
|
|
1064
|
+
const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
|
|
1065
|
+
if (failures.length === 0) console.log(summary);
|
|
1066
|
+
else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
|
|
1067
|
+
}
|
|
1068
|
+
function formatRow(row) {
|
|
1069
|
+
const room = (process.stdout.columns ?? 80) - stripVTControlCharacters(row.fixedColumns).length - 2;
|
|
1070
|
+
const text = room > 0 ? truncate(row.freeText, room) : "";
|
|
1071
|
+
return text ? `${row.fixedColumns} ${text}` : row.fixedColumns;
|
|
1072
|
+
}
|
|
1073
|
+
function truncate(text, max) {
|
|
1074
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
1075
|
+
return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
|
|
1076
|
+
}
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region src/adf/to-markdown.ts
|
|
1079
|
+
function adfToMarkdown(doc, options = {}) {
|
|
1080
|
+
if (!doc) return "";
|
|
1081
|
+
const ctx = { resolveMedia: options.resolveMedia };
|
|
1082
|
+
return (doc.type === "doc" ? renderBlocks(doc.content ?? [], ctx, "") : renderBlock(doc, ctx, "")).trim();
|
|
1083
|
+
}
|
|
1084
|
+
function renderBlocks(nodes, ctx, indent) {
|
|
1085
|
+
return nodes.map((n) => renderBlock(n, ctx, indent)).filter((s) => s.length > 0).join("\n\n");
|
|
1086
|
+
}
|
|
1087
|
+
function renderBlock(node, ctx, indent) {
|
|
1088
|
+
switch (node.type) {
|
|
1089
|
+
case "paragraph": return indent + renderInline(node.content ?? [], ctx);
|
|
1090
|
+
case "heading": {
|
|
1091
|
+
const level = clampLevel(node.attrs?.["level"]);
|
|
1092
|
+
return `${"#".repeat(level)} ${renderInline(node.content ?? [], ctx)}`;
|
|
1093
|
+
}
|
|
1094
|
+
case "bulletList": return renderList(node, ctx, indent, "bullet");
|
|
1095
|
+
case "orderedList": return renderList(node, ctx, indent, "ordered");
|
|
1096
|
+
case "taskList": return renderTaskList(node, ctx, indent);
|
|
1097
|
+
case "decisionList": return renderDecisionList(node, ctx, indent);
|
|
1098
|
+
case "codeBlock": return renderCodeBlock(node, indent);
|
|
1099
|
+
case "blockquote": return prefixLines(renderBlocks(node.content ?? [], ctx, ""), `${indent}> `);
|
|
1100
|
+
case "panel": return renderPanel(node, ctx, indent);
|
|
1101
|
+
case "rule": return `${indent}---`;
|
|
1102
|
+
case "table": return renderTable(node, ctx);
|
|
1103
|
+
case "mediaSingle":
|
|
1104
|
+
case "mediaGroup": return renderBlocks(node.content ?? [], ctx, indent);
|
|
1105
|
+
case "media": return indent + renderMedia(node, ctx);
|
|
1106
|
+
case "expand":
|
|
1107
|
+
case "nestedExpand": return renderExpand(node, ctx, indent);
|
|
1108
|
+
default:
|
|
1109
|
+
if (node.content?.length) return renderBlocks(node.content, ctx, indent);
|
|
1110
|
+
return node.text ? indent + node.text : "";
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
function renderList(node, ctx, indent, kind) {
|
|
1114
|
+
const start = kind === "ordered" ? toNumber(node.attrs?.["order"]) ?? 1 : 0;
|
|
1115
|
+
return (node.content ?? []).filter((n) => n.type === "listItem").map((item, i) => {
|
|
1116
|
+
return renderListItem(item, ctx, indent, kind === "ordered" ? `${start + i}. ` : "- ");
|
|
1117
|
+
}).join("\n");
|
|
1118
|
+
}
|
|
1119
|
+
function renderListItem(item, ctx, indent, marker) {
|
|
1120
|
+
const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
|
|
1121
|
+
return (item.content ?? []).map((block, i) => i === 0 ? `${indent}${marker}${renderBlock(block, ctx, "")}` : renderBlock(block, ctx, continuationIndent)).join("\n");
|
|
1122
|
+
}
|
|
1123
|
+
function renderTaskList(node, ctx, indent) {
|
|
1124
|
+
return (node.content ?? []).filter((n) => n.type === "taskItem").map((item) => {
|
|
1125
|
+
return `${indent}- [${item.attrs?.["state"] === "DONE" ? "x" : " "}] ${renderInline(item.content ?? [], ctx)}`;
|
|
1126
|
+
}).join("\n");
|
|
1127
|
+
}
|
|
1128
|
+
function renderDecisionList(node, ctx, indent) {
|
|
1129
|
+
return (node.content ?? []).filter((n) => n.type === "decisionItem").map((item) => `${indent}- (decision) ${renderInline(item.content ?? [], ctx)}`).join("\n");
|
|
1130
|
+
}
|
|
1131
|
+
function renderCodeBlock(node, indent) {
|
|
1132
|
+
const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
|
|
1133
|
+
const code = (node.content ?? []).map((n) => n.text ?? "").join("");
|
|
1134
|
+
const fence = "```";
|
|
1135
|
+
return prefixLines(`${fence}${lang}\n${code}\n${fence}`, indent);
|
|
1136
|
+
}
|
|
1137
|
+
const PANEL_LABELS = {
|
|
1138
|
+
info: "Info",
|
|
1139
|
+
note: "Note",
|
|
1140
|
+
warning: "Warning",
|
|
1141
|
+
success: "Success",
|
|
1142
|
+
error: "Error"
|
|
1143
|
+
};
|
|
1144
|
+
function renderPanel(node, ctx, indent) {
|
|
1145
|
+
return prefixLines(`**${PANEL_LABELS[typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info"] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
|
|
1146
|
+
}
|
|
1147
|
+
function renderExpand(node, ctx, indent) {
|
|
1148
|
+
return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
|
|
1149
|
+
}
|
|
1150
|
+
function renderTable(node, ctx) {
|
|
1151
|
+
const rows = (node.content ?? []).filter((n) => n.type === "tableRow");
|
|
1152
|
+
if (rows.length === 0) return "";
|
|
1153
|
+
const grid = rows.map((row) => (row.content ?? []).map((cell) => renderCellOnOneLine(cell, ctx)));
|
|
1154
|
+
const cols = Math.max(...grid.map((r) => r.length));
|
|
1155
|
+
const pad = (r) => {
|
|
1156
|
+
const copy = [...r];
|
|
1157
|
+
while (copy.length < cols) copy.push("");
|
|
1158
|
+
return copy;
|
|
1159
|
+
};
|
|
1160
|
+
const header = pad(grid[0] ?? []);
|
|
1161
|
+
const lines = [`| ${header.join(" | ")} |`, `| ${header.map(() => "---").join(" | ")} |`];
|
|
1162
|
+
for (const row of grid.slice(1)) lines.push(`| ${pad(row).join(" | ")} |`);
|
|
1163
|
+
return lines.join("\n");
|
|
1164
|
+
}
|
|
1165
|
+
function renderCellOnOneLine(cell, ctx) {
|
|
1166
|
+
return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
|
|
1167
|
+
}
|
|
1168
|
+
function renderMedia(node, ctx) {
|
|
1169
|
+
const attrs = node.attrs ?? {};
|
|
1170
|
+
const alt = attrs.alt ?? "";
|
|
1171
|
+
const resolved = ctx.resolveMedia?.(attrs);
|
|
1172
|
+
if (resolved) return ``;
|
|
1173
|
+
return `[embedded media: ${alt || attrs.id || "media"}]`;
|
|
1174
|
+
}
|
|
1175
|
+
function renderInline(nodes, ctx) {
|
|
1176
|
+
return nodes.map((n) => renderInlineNode(n, ctx)).join("");
|
|
1177
|
+
}
|
|
1178
|
+
function renderInlineNode(node, ctx) {
|
|
1179
|
+
switch (node.type) {
|
|
1180
|
+
case "text": return applyMarks(node.text ?? "", node.marks ?? []);
|
|
1181
|
+
case "hardBreak": return " \n";
|
|
1182
|
+
case "mention": return (typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : "") || "@unknown";
|
|
1183
|
+
case "emoji": {
|
|
1184
|
+
const text = node.attrs?.["text"];
|
|
1185
|
+
if (typeof text === "string" && text.length > 0) return text;
|
|
1186
|
+
const short = node.attrs?.["shortName"];
|
|
1187
|
+
return typeof short === "string" ? short : "";
|
|
1188
|
+
}
|
|
1189
|
+
case "date": return formatDate(node.attrs?.["timestamp"]);
|
|
1190
|
+
case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
|
|
1191
|
+
case "inlineCard": {
|
|
1192
|
+
const url = node.attrs?.["url"];
|
|
1193
|
+
if (typeof url === "string") return `[${url}](${url})`;
|
|
1194
|
+
return "";
|
|
1195
|
+
}
|
|
1196
|
+
case "media": return renderMedia(node, ctx);
|
|
1197
|
+
default: return node.text ?? "";
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
function applyMarks(text, marks) {
|
|
1201
|
+
if (text.length === 0) return text;
|
|
1202
|
+
let out = text;
|
|
1203
|
+
let href;
|
|
1204
|
+
for (const mark of marks) switch (mark.type) {
|
|
1205
|
+
case "code":
|
|
1206
|
+
out = `\`${out}\``;
|
|
1207
|
+
break;
|
|
1208
|
+
case "strong":
|
|
1209
|
+
out = `**${out}**`;
|
|
1210
|
+
break;
|
|
1211
|
+
case "em":
|
|
1212
|
+
out = `*${out}*`;
|
|
1213
|
+
break;
|
|
1214
|
+
case "strike":
|
|
1215
|
+
out = `~~${out}~~`;
|
|
1216
|
+
break;
|
|
1217
|
+
case "link": {
|
|
1218
|
+
const value = mark.attrs?.["href"];
|
|
1219
|
+
if (typeof value === "string") href = value;
|
|
1220
|
+
break;
|
|
1221
|
+
}
|
|
1222
|
+
default: break;
|
|
1223
|
+
}
|
|
1224
|
+
if (href) out = `[${out}](${href})`;
|
|
1225
|
+
return out;
|
|
1226
|
+
}
|
|
1227
|
+
function prefixLines(text, prefix) {
|
|
1228
|
+
return text.split("\n").map((line) => line.length > 0 ? prefix + line : prefix.trimEnd()).join("\n");
|
|
1229
|
+
}
|
|
1230
|
+
function clampLevel(value) {
|
|
1231
|
+
const n = toNumber(value) ?? 1;
|
|
1232
|
+
return Math.min(6, Math.max(1, n));
|
|
1233
|
+
}
|
|
1234
|
+
function toNumber(value) {
|
|
1235
|
+
return typeof value === "number" ? value : void 0;
|
|
1236
|
+
}
|
|
1237
|
+
function formatDate(timestamp) {
|
|
1238
|
+
const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
|
|
1239
|
+
if (Number.isNaN(ms)) return "";
|
|
1240
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
1241
|
+
}
|
|
1242
|
+
//#endregion
|
|
1243
|
+
//#region src/api/attachments.ts
|
|
1244
|
+
function mediaResolver(downloaded) {
|
|
1245
|
+
const byMediaId = new Map(downloaded.map((d) => [d.mediaId, d.relativePath]));
|
|
1246
|
+
const byFilename = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
|
|
1247
|
+
return (media) => {
|
|
1248
|
+
if (media.id && byMediaId.has(media.id)) return byMediaId.get(media.id);
|
|
1249
|
+
if (media.alt && byFilename.has(media.alt)) return byFilename.get(media.alt);
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
//#endregion
|
|
1253
|
+
//#region src/markdown/copied-document.ts
|
|
1254
|
+
const COMMENTS_HEADING = "## Comments";
|
|
1255
|
+
const ATTACHMENTS_HEADING = "## Attachments";
|
|
1256
|
+
const FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---\n?/;
|
|
1257
|
+
const FRONTMATTER_KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
|
|
1258
|
+
const FRONTMATTER_LIST_ITEM = /^ {2}- (.*)$/;
|
|
1259
|
+
const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
|
|
1260
|
+
const TRAILING_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
|
|
1261
|
+
function render(doc) {
|
|
1262
|
+
return `${[
|
|
1263
|
+
frontmatter(doc.fields),
|
|
1264
|
+
`# ${doc.title}`,
|
|
1265
|
+
doc.body,
|
|
1266
|
+
commentsSection(doc.comments),
|
|
1267
|
+
attachmentsSection(doc.attachments)
|
|
1268
|
+
].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
|
|
1269
|
+
}
|
|
1270
|
+
function parse(text) {
|
|
1271
|
+
const match = text.match(FRONTMATTER_BLOCK);
|
|
1272
|
+
if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
|
|
1273
|
+
const fields = parseFrontmatter(match[1] ?? "");
|
|
1274
|
+
const lines = text.slice(match[0].length).split("\n");
|
|
1275
|
+
const h1 = leadingH1(lines);
|
|
1276
|
+
const start = h1 ? h1.index + 1 : 0;
|
|
1277
|
+
const trailing = lines.findIndex((line, i) => i >= start && TRAILING_SECTION_HEADING.test(line));
|
|
1278
|
+
const end = trailing === -1 ? lines.length : trailing;
|
|
1279
|
+
const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
|
|
1280
|
+
return {
|
|
1281
|
+
fields,
|
|
1282
|
+
title: h1?.title ?? fallbackTitle,
|
|
1283
|
+
body: lines.slice(start, end).join("\n").trim()
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
function parseIssueSource(text) {
|
|
1287
|
+
const source = parse(text);
|
|
1288
|
+
const key = scalarString(source.fields["key"]);
|
|
1289
|
+
if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
|
|
1290
|
+
return {
|
|
1291
|
+
...source,
|
|
1292
|
+
key,
|
|
1293
|
+
updatedAtCopy: scalarString(source.fields["updated"])
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
function parsePageSource(text) {
|
|
1297
|
+
const source = parse(text);
|
|
1298
|
+
const id = scalarString(source.fields["id"]);
|
|
1299
|
+
if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
|
|
1300
|
+
const rawVersion = scalarString(source.fields["version"]);
|
|
1301
|
+
const version = rawVersion === "" ? NaN : Number(rawVersion);
|
|
1302
|
+
if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
|
|
1303
|
+
return {
|
|
1304
|
+
...source,
|
|
1305
|
+
id,
|
|
1306
|
+
version
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
function scalarString(value) {
|
|
1310
|
+
if (typeof value === "string") return value;
|
|
1311
|
+
if (typeof value === "number") return String(value);
|
|
1312
|
+
return "";
|
|
1313
|
+
}
|
|
1314
|
+
function frontmatter(fields) {
|
|
1315
|
+
const lines = ["---"];
|
|
1316
|
+
for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) if (value.length === 0) lines.push(`${key}: []`);
|
|
1317
|
+
else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
|
|
1318
|
+
else if (typeof value === "number") lines.push(`${key}: ${value}`);
|
|
1319
|
+
else lines.push(`${key}: ${quote(value)}`);
|
|
1320
|
+
lines.push("---");
|
|
1321
|
+
return lines.join("\n");
|
|
1322
|
+
}
|
|
1323
|
+
function parseFrontmatter(block) {
|
|
1324
|
+
const fields = {};
|
|
1325
|
+
const lines = block.split("\n");
|
|
1326
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1327
|
+
const m = lines[i]?.match(FRONTMATTER_KEY);
|
|
1328
|
+
if (!m) continue;
|
|
1329
|
+
const key = m[1] ?? "";
|
|
1330
|
+
const raw = (m[2] ?? "").trim();
|
|
1331
|
+
if (raw === "[]") fields[key] = [];
|
|
1332
|
+
else if (raw === "") {
|
|
1333
|
+
const items = [];
|
|
1334
|
+
for (let item; item = lines[i + 1]?.match(FRONTMATTER_LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
|
|
1335
|
+
fields[key] = items;
|
|
1336
|
+
} else fields[key] = scalar(raw);
|
|
1337
|
+
}
|
|
1338
|
+
return fields;
|
|
1339
|
+
}
|
|
1340
|
+
function scalar(raw) {
|
|
1341
|
+
if (raw.startsWith("\"")) return unquote(raw);
|
|
1342
|
+
if (BARE_NUMBER.test(raw)) return Number(raw);
|
|
1343
|
+
return raw;
|
|
1344
|
+
}
|
|
1345
|
+
function quote(value) {
|
|
1346
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1347
|
+
}
|
|
1348
|
+
function unquote(value) {
|
|
1349
|
+
if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
|
|
1350
|
+
return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
1351
|
+
}
|
|
1352
|
+
function leadingH1(lines) {
|
|
1353
|
+
for (const [index, line] of lines.entries()) {
|
|
1354
|
+
if (line.startsWith("# ")) return {
|
|
1355
|
+
index,
|
|
1356
|
+
title: line.slice(2).trim()
|
|
1357
|
+
};
|
|
1358
|
+
if (line.trim().length > 0) return null;
|
|
1359
|
+
}
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
function commentsSection(comments) {
|
|
1363
|
+
if (comments.length === 0) return "";
|
|
1364
|
+
return [
|
|
1365
|
+
COMMENTS_HEADING,
|
|
1366
|
+
"",
|
|
1367
|
+
comments.map((c) => {
|
|
1368
|
+
const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
|
|
1369
|
+
return c.body ? `${heading}\n\n${c.body}` : heading;
|
|
1370
|
+
}).join("\n\n")
|
|
1371
|
+
].join("\n");
|
|
1372
|
+
}
|
|
1373
|
+
function attachmentsSection(attachments) {
|
|
1374
|
+
if (attachments.length === 0) return "";
|
|
1375
|
+
return [
|
|
1376
|
+
ATTACHMENTS_HEADING,
|
|
1377
|
+
"",
|
|
1378
|
+
...attachments.map((a) => `- [${a.filename}](${a.relativePath})`)
|
|
1379
|
+
].join("\n");
|
|
1380
|
+
}
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/copy/plan.ts
|
|
1383
|
+
function planIssueCopy(issue, out) {
|
|
1384
|
+
return planCopy({
|
|
1385
|
+
baseName: issue.key,
|
|
1386
|
+
fields: {
|
|
1387
|
+
key: issue.key,
|
|
1388
|
+
type: issue.type,
|
|
1389
|
+
status: issue.status,
|
|
1390
|
+
assignee: issue.assignee,
|
|
1391
|
+
reporter: issue.reporter,
|
|
1392
|
+
priority: issue.priority,
|
|
1393
|
+
labels: issue.labels,
|
|
1394
|
+
created: issue.created,
|
|
1395
|
+
updated: issue.updated,
|
|
1396
|
+
url: issue.url
|
|
1397
|
+
},
|
|
1398
|
+
title: issue.summary,
|
|
1399
|
+
body: issue.description,
|
|
1400
|
+
comments: issue.comments,
|
|
1401
|
+
attachments: issue.attachments
|
|
1402
|
+
}, out);
|
|
1403
|
+
}
|
|
1404
|
+
function planPageCopy(page, out) {
|
|
1405
|
+
return planCopy({
|
|
1406
|
+
baseName: `${page.id}-${slugify(page.title)}`,
|
|
1407
|
+
fields: {
|
|
1408
|
+
title: page.title,
|
|
1409
|
+
id: page.id,
|
|
1410
|
+
space: page.spaceKey,
|
|
1411
|
+
version: page.version,
|
|
1412
|
+
author: page.author,
|
|
1413
|
+
created: page.createdAt,
|
|
1414
|
+
updated: page.updatedAt,
|
|
1415
|
+
url: page.url
|
|
1416
|
+
},
|
|
1417
|
+
title: page.title,
|
|
1418
|
+
body: page.body,
|
|
1419
|
+
comments: page.comments,
|
|
1420
|
+
attachments: page.attachments
|
|
1421
|
+
}, out);
|
|
1422
|
+
}
|
|
1423
|
+
function renderCopy(plan, landed) {
|
|
1424
|
+
const resolveMedia = mediaResolver(landed);
|
|
1425
|
+
return render({
|
|
1426
|
+
fields: plan.fields,
|
|
1427
|
+
title: plan.title,
|
|
1428
|
+
body: adfToMarkdown(plan.body, { resolveMedia }),
|
|
1429
|
+
comments: plan.comments.map((c) => ({
|
|
1430
|
+
author: c.author,
|
|
1431
|
+
created: c.created,
|
|
1432
|
+
body: adfToMarkdown(c.body, { resolveMedia })
|
|
1433
|
+
})),
|
|
1434
|
+
attachments: landed
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
function planCopy(source, out) {
|
|
1438
|
+
const { baseName, attachments, ...content } = source;
|
|
1439
|
+
const target = outputTarget(baseName, out);
|
|
1440
|
+
return {
|
|
1441
|
+
...content,
|
|
1442
|
+
filePath: target.filePath,
|
|
1443
|
+
assetsDir: target.assetsDir,
|
|
1444
|
+
downloads: planDownloads(attachments, target)
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
function outputTarget(base, out) {
|
|
1448
|
+
let filePath;
|
|
1449
|
+
if (!out) filePath = resolve(`${base}.md`);
|
|
1450
|
+
else if (out.endsWith(".md")) filePath = isAbsolute(out) ? out : resolve(out);
|
|
1451
|
+
else filePath = resolve(out, `${base}.md`);
|
|
1452
|
+
const dir = dirname(filePath);
|
|
1453
|
+
const assetsDirName = `${filePath.slice(dir.length + 1).replace(/\.md$/, "")}.assets`;
|
|
1454
|
+
return {
|
|
1455
|
+
filePath,
|
|
1456
|
+
assetsDir: join(dir, assetsDirName),
|
|
1457
|
+
assetsDirName
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
function slugify(title) {
|
|
1461
|
+
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "") || "page";
|
|
1462
|
+
}
|
|
1463
|
+
function planDownloads(attachments, target) {
|
|
1464
|
+
const used = /* @__PURE__ */ new Set();
|
|
1465
|
+
return attachments.map((att) => {
|
|
1466
|
+
const savedAs = uniqueName(bareFilename(att.filename), used);
|
|
1467
|
+
return {
|
|
1468
|
+
...att,
|
|
1469
|
+
relativePath: `${target.assetsDirName}/${savedAs}`,
|
|
1470
|
+
path: join(target.assetsDir, savedAs)
|
|
1471
|
+
};
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
function bareFilename(name) {
|
|
1475
|
+
return basename(name).replace(/[/\\]/g, "_") || "attachment";
|
|
1476
|
+
}
|
|
1477
|
+
function uniqueName(name, used) {
|
|
1478
|
+
let candidate = name;
|
|
1479
|
+
for (let suffix = 1; used.has(candidate); suffix++) candidate = numberedName(name, suffix);
|
|
1480
|
+
used.add(candidate);
|
|
1481
|
+
return candidate;
|
|
1482
|
+
}
|
|
1483
|
+
function numberedName(name, suffix) {
|
|
1484
|
+
const dot = name.lastIndexOf(".");
|
|
1485
|
+
return `${dot > 0 ? name.slice(0, dot) : name}-${suffix}${dot > 0 ? name.slice(dot) : ""}`;
|
|
1486
|
+
}
|
|
1487
|
+
//#endregion
|
|
1488
|
+
//#region src/copy/run.ts
|
|
1489
|
+
async function runCopy(plan, fetchBytes) {
|
|
1490
|
+
const landed = await download(plan, fetchBytes);
|
|
1491
|
+
await mkdir(dirname(plan.filePath), { recursive: true });
|
|
1492
|
+
await writeFile(plan.filePath, renderCopy(plan, landed), "utf8");
|
|
1493
|
+
console.log(wroteLine(plan.filePath, landed.length));
|
|
1494
|
+
}
|
|
1495
|
+
async function download(plan, fetchBytes) {
|
|
1496
|
+
if (plan.downloads.length === 0) return [];
|
|
1497
|
+
await mkdir(plan.assetsDir, { recursive: true });
|
|
1498
|
+
const landed = [];
|
|
1499
|
+
for (const { path, ...attachment } of plan.downloads) try {
|
|
1500
|
+
await writeFile(path, await fetchBytes(attachment.url));
|
|
1501
|
+
landed.push(attachment);
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
console.warn(` ! could not download ${attachment.filename}: ${err.message}`);
|
|
1504
|
+
}
|
|
1505
|
+
return landed;
|
|
1506
|
+
}
|
|
1507
|
+
function wroteLine(filePath, attachmentCount) {
|
|
1508
|
+
return `Wrote ${filePath}${attachmentCount > 0 ? ` (+${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})` : ""}`;
|
|
1509
|
+
}
|
|
1510
|
+
//#endregion
|
|
1511
|
+
//#region src/adf/lossy.ts
|
|
1512
|
+
const LOSSY_LABELS = {
|
|
1513
|
+
panel: "panel",
|
|
1514
|
+
expand: "expand",
|
|
1515
|
+
nestedExpand: "expand",
|
|
1516
|
+
decisionList: "decision list",
|
|
1517
|
+
layoutSection: "layout",
|
|
1518
|
+
extension: "macro",
|
|
1519
|
+
bodiedExtension: "macro",
|
|
1520
|
+
inlineExtension: "macro"
|
|
1521
|
+
};
|
|
1522
|
+
const JIRA_LOSSY_LABELS = {
|
|
1523
|
+
...LOSSY_LABELS,
|
|
1524
|
+
media: "image",
|
|
1525
|
+
mediaInline: "image"
|
|
1526
|
+
};
|
|
1527
|
+
function findLossyNodes(node, lossyLabels = LOSSY_LABELS) {
|
|
1528
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1529
|
+
const visit = (n) => {
|
|
1530
|
+
const label = lossyLabels[n.type];
|
|
1531
|
+
if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
1532
|
+
for (const child of n.content ?? []) visit(child);
|
|
1533
|
+
};
|
|
1534
|
+
if (node) visit(node);
|
|
1535
|
+
return counts;
|
|
1536
|
+
}
|
|
1537
|
+
function formatLossy(counts) {
|
|
1538
|
+
return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
|
|
1539
|
+
}
|
|
1540
|
+
//#endregion
|
|
1541
|
+
//#region src/update/plan.ts
|
|
1542
|
+
function planIssueUpdate(source, issue, options) {
|
|
1543
|
+
const images = imageHrefs(source.body).map((href) => ({
|
|
1544
|
+
href,
|
|
1545
|
+
kind: isExternalHref(href) ? "external" : "unsupported"
|
|
1546
|
+
}));
|
|
1547
|
+
const unsupported = images.filter((i) => i.kind === "unsupported").map((i) => i.href);
|
|
1548
|
+
const body = markdownToAdf(source.body, { resolveImage: (href, alt) => isExternalHref(href) ? externalMedia(href, alt) : void 0 });
|
|
1549
|
+
const refusals = [...unsupported.length > 0 ? [`jira update does not support image changes yet. Remove local image reference(s) or edit text only: ${unsupported.join(", ")}`] : [], ...emptyBodyRefusal(body)];
|
|
1550
|
+
return withVerdict({
|
|
1551
|
+
noun: "issue",
|
|
1552
|
+
id: source.key,
|
|
1553
|
+
headline: headline("summary", issue.summary, source.title, options.summary),
|
|
1554
|
+
revision: revision(source.updatedAtCopy, issue.updated),
|
|
1555
|
+
lossy: findLossyNodes(issue.description, JIRA_LOSSY_LABELS),
|
|
1556
|
+
images,
|
|
1557
|
+
uploads: [],
|
|
1558
|
+
body,
|
|
1559
|
+
refusals
|
|
1560
|
+
}, options.force ?? false);
|
|
1561
|
+
}
|
|
1562
|
+
function planPageUpdate(source, state, attachments, localImages, options) {
|
|
1563
|
+
const collection = `contentId-${source.id}`;
|
|
1564
|
+
const entries = imageHrefs(source.body).map((href) => pageImage(href, localImages, attachments));
|
|
1565
|
+
const byHref = new Map(entries.map((e) => [e.href, e]));
|
|
1566
|
+
const body = markdownToAdf(source.body, { resolveImage: (href, alt) => {
|
|
1567
|
+
const entry = byHref.get(href);
|
|
1568
|
+
if (!entry || entry.kind === "missing") return void 0;
|
|
1569
|
+
if (entry.kind === "external") return externalMedia(href, alt);
|
|
1570
|
+
return mediaNode({
|
|
1571
|
+
type: "file",
|
|
1572
|
+
id: entry.kind === "reuse" ? entry.fileId : href,
|
|
1573
|
+
collection
|
|
1574
|
+
}, alt);
|
|
1575
|
+
} });
|
|
1576
|
+
const missing = entries.filter((e) => e.kind === "missing").map((e) => e.href);
|
|
1577
|
+
const refusals = [...missing.length > 0 ? [`Image file(s) not found: ${missing.join(", ")}`] : [], ...emptyBodyRefusal(body)];
|
|
1578
|
+
return withVerdict({
|
|
1579
|
+
noun: "page",
|
|
1580
|
+
id: source.id,
|
|
1581
|
+
headline: headline("title", state.title, source.title, options.title),
|
|
1582
|
+
revision: revision(`v${source.version}`, `v${state.version}`),
|
|
1583
|
+
lossy: findLossyNodes(state.body),
|
|
1584
|
+
images: entries.map(({ href, kind }) => ({
|
|
1585
|
+
href,
|
|
1586
|
+
kind
|
|
1587
|
+
})),
|
|
1588
|
+
uploads: entries.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
|
|
1589
|
+
href: e.href,
|
|
1590
|
+
path: e.path,
|
|
1591
|
+
filename: e.filename
|
|
1592
|
+
}] : []),
|
|
1593
|
+
body,
|
|
1594
|
+
refusals
|
|
1595
|
+
}, options.force ?? false);
|
|
1596
|
+
}
|
|
1597
|
+
function pageImage(href, localImages, attachments) {
|
|
1598
|
+
if (isExternalHref(href)) return {
|
|
1599
|
+
href,
|
|
1600
|
+
kind: "external"
|
|
1601
|
+
};
|
|
1602
|
+
const local = localImages.find((l) => l.href === href);
|
|
1603
|
+
if (!local || local.size === void 0) return {
|
|
1604
|
+
href,
|
|
1605
|
+
kind: "missing"
|
|
1606
|
+
};
|
|
1607
|
+
const existing = attachments.find((a) => a.filename === local.filename);
|
|
1608
|
+
if (existing && existing.size === local.size) return {
|
|
1609
|
+
href,
|
|
1610
|
+
kind: "reuse",
|
|
1611
|
+
fileId: existing.fileId
|
|
1612
|
+
};
|
|
1613
|
+
return {
|
|
1614
|
+
href,
|
|
1615
|
+
kind: existing ? "changed" : "upload",
|
|
1616
|
+
path: local.path,
|
|
1617
|
+
filename: local.filename
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
function withUploadedIds(doc, ids) {
|
|
1621
|
+
if (!doc.content) return doc;
|
|
1622
|
+
return {
|
|
1623
|
+
...doc,
|
|
1624
|
+
content: doc.content.map((n) => withUploadedId(n, ids))
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
function withUploadedId(node, ids) {
|
|
1628
|
+
const id = node.type === "media" ? node.attrs?.["id"] : void 0;
|
|
1629
|
+
const swapped = typeof id === "string" && ids.has(id) ? {
|
|
1630
|
+
...node,
|
|
1631
|
+
attrs: {
|
|
1632
|
+
...node.attrs,
|
|
1633
|
+
id: ids.get(id)
|
|
1634
|
+
}
|
|
1635
|
+
} : node;
|
|
1636
|
+
if (!swapped.content) return swapped;
|
|
1637
|
+
return {
|
|
1638
|
+
...swapped,
|
|
1639
|
+
content: swapped.content.map((n) => withUploadedId(n, ids))
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
function formatPlan(plan) {
|
|
1643
|
+
const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
|
|
1644
|
+
if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
|
|
1645
|
+
const counts = imageCounts(plan.images);
|
|
1646
|
+
if (counts) lines.push(row("images", counts));
|
|
1647
|
+
for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
|
|
1648
|
+
if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
|
|
1649
|
+
if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
|
|
1650
|
+
lines.push(" nothing was written (dry run)");
|
|
1651
|
+
return lines;
|
|
1652
|
+
}
|
|
1653
|
+
const IMAGE_COUNT_LABELS = [
|
|
1654
|
+
["upload", "new"],
|
|
1655
|
+
["changed", "changed"],
|
|
1656
|
+
["reuse", "reused"],
|
|
1657
|
+
["external", "external"],
|
|
1658
|
+
["unsupported", "unsupported"],
|
|
1659
|
+
["missing", "missing"]
|
|
1660
|
+
];
|
|
1661
|
+
function imageCounts(images) {
|
|
1662
|
+
return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
|
|
1663
|
+
n: images.filter((i) => i.kind === kind).length,
|
|
1664
|
+
label
|
|
1665
|
+
})).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
|
|
1666
|
+
}
|
|
1667
|
+
function row(label, value) {
|
|
1668
|
+
return ` ${`${label}:`.padEnd(9)}${value}`;
|
|
1669
|
+
}
|
|
1670
|
+
function headline(label, current, title, useTitle) {
|
|
1671
|
+
return {
|
|
1672
|
+
label,
|
|
1673
|
+
current,
|
|
1674
|
+
next: useTitle && title ? title : current
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
function revision(local, server) {
|
|
1678
|
+
return {
|
|
1679
|
+
local: local || "unknown",
|
|
1680
|
+
server: server || "unknown",
|
|
1681
|
+
stale: local !== server
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
function revisionText(revision) {
|
|
1685
|
+
return `copied at ${revision.local}, server now ${revision.server}`;
|
|
1686
|
+
}
|
|
1687
|
+
function emptyBodyRefusal(body) {
|
|
1688
|
+
if (body.content && body.content.length > 0) return [];
|
|
1689
|
+
return ["Refusing to update: the converted body is empty."];
|
|
1690
|
+
}
|
|
1691
|
+
function withVerdict(plan, force) {
|
|
1692
|
+
return {
|
|
1693
|
+
...plan,
|
|
1694
|
+
verdict: verdict(plan, force)
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
function verdict(plan, force) {
|
|
1698
|
+
if (plan.refusals.length > 0) return {
|
|
1699
|
+
kind: "refuse",
|
|
1700
|
+
message: plan.refusals.join("\n")
|
|
1701
|
+
};
|
|
1702
|
+
const noun = plan.noun;
|
|
1703
|
+
if (plan.revision.stale && !force) return {
|
|
1704
|
+
kind: "refuse",
|
|
1705
|
+
message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
|
|
1706
|
+
};
|
|
1707
|
+
if (plan.lossy.size > 0 && !force) return {
|
|
1708
|
+
kind: "confirm",
|
|
1709
|
+
message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
|
|
1710
|
+
};
|
|
1711
|
+
return { kind: "proceed" };
|
|
1712
|
+
}
|
|
1713
|
+
function capitalize(text) {
|
|
1714
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
1715
|
+
}
|
|
1716
|
+
//#endregion
|
|
1717
|
+
//#region src/update/run.ts
|
|
1718
|
+
async function runPlan(plan, options, push) {
|
|
1719
|
+
if (options.dryRun) {
|
|
1720
|
+
for (const line of formatPlan(plan)) console.log(line);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const verdict = plan.verdict;
|
|
1724
|
+
if (verdict.kind === "refuse") throw new Error(verdict.message);
|
|
1725
|
+
if (verdict.kind === "confirm") {
|
|
1726
|
+
if (!await confirm({
|
|
1727
|
+
message: verdict.message,
|
|
1728
|
+
default: false
|
|
1729
|
+
})) {
|
|
1730
|
+
console.log("Aborted.");
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
await push();
|
|
1735
|
+
}
|
|
1736
|
+
//#endregion
|
|
1737
|
+
//#region src/commands/confluence.ts
|
|
1738
|
+
async function confluenceCopy(arg, options) {
|
|
1739
|
+
const auth = await requireAuth();
|
|
1740
|
+
const id = await resolveRef(arg, PAGE_REF);
|
|
1741
|
+
await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
|
|
1742
|
+
}
|
|
1743
|
+
async function confluenceUpdate(arg, options) {
|
|
1744
|
+
const file = arg ?? await input({
|
|
1745
|
+
message: "Path to the page Markdown file:",
|
|
1746
|
+
required: true
|
|
1747
|
+
});
|
|
1748
|
+
const src = parsePageSource(await readFile(file, "utf8"));
|
|
1749
|
+
const client = new AtlassianClient(await requireAuth());
|
|
1750
|
+
const state = await fetchPageState(client, src.id);
|
|
1751
|
+
const plan = planPageUpdate(src, state, await listAttachments(client, src.id), await statLocalImages(dirname(resolve(file)), src.body), options);
|
|
1752
|
+
await runPlan(plan, options, async () => {
|
|
1753
|
+
const ids = /* @__PURE__ */ new Map();
|
|
1754
|
+
for (const upload of plan.uploads) {
|
|
1755
|
+
console.log(`Uploading ${upload.filename} ...`);
|
|
1756
|
+
const bytes = await readFile(upload.path);
|
|
1757
|
+
ids.set(upload.href, await uploadAttachment(client, src.id, upload.filename, bytes));
|
|
1758
|
+
}
|
|
1759
|
+
const version = await updatePage(client, src.id, {
|
|
1760
|
+
title: plan.headline.next,
|
|
1761
|
+
nextVersion: state.version + 1,
|
|
1762
|
+
body: withUploadedIds(plan.body, ids),
|
|
1763
|
+
message: options.message ?? "Updated via atlass"
|
|
1764
|
+
});
|
|
1765
|
+
console.log(`Updated page ${src.id} to version ${version}.`);
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
async function statLocalImages(dir, md) {
|
|
1769
|
+
const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
|
|
1770
|
+
return Promise.all(hrefs.map(async (href) => {
|
|
1771
|
+
const path = isAbsolute(href) ? href : resolve(dir, href);
|
|
1772
|
+
return {
|
|
1773
|
+
href,
|
|
1774
|
+
path,
|
|
1775
|
+
filename: basename(path),
|
|
1776
|
+
...await fileSize(path)
|
|
1777
|
+
};
|
|
1778
|
+
}));
|
|
1779
|
+
}
|
|
1780
|
+
async function fileSize(path) {
|
|
1781
|
+
try {
|
|
1782
|
+
return { size: (await stat(path)).size };
|
|
1783
|
+
} catch {
|
|
1784
|
+
return {};
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
async function confluenceSearch(query, options) {
|
|
1788
|
+
if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
|
|
1789
|
+
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
1790
|
+
const auth = await requireAuth();
|
|
1791
|
+
const client = new AtlassianClient(auth);
|
|
1792
|
+
const limit = parseLimit(options.limit);
|
|
1793
|
+
const { pages, hasMore } = await searchPages(client, auth.site, {
|
|
1794
|
+
text: query,
|
|
1795
|
+
space: options.space,
|
|
1796
|
+
cql: options.cql,
|
|
1797
|
+
limit
|
|
1798
|
+
});
|
|
1799
|
+
await runSearch(pages.map((p) => ({
|
|
1800
|
+
id: p.id,
|
|
1801
|
+
fixedColumns: `${p.id} ${p.space}`,
|
|
1802
|
+
freeText: p.title,
|
|
1803
|
+
json: {
|
|
1804
|
+
id: p.id,
|
|
1805
|
+
space: p.space,
|
|
1806
|
+
title: p.title,
|
|
1807
|
+
url: p.url
|
|
1808
|
+
}
|
|
1809
|
+
})), {
|
|
1810
|
+
json: options.json,
|
|
1811
|
+
copy: options.copy,
|
|
1812
|
+
out: options.out,
|
|
1813
|
+
empty: "No matching pages.",
|
|
1814
|
+
footer: hasMore ? searchFooter(limit) : void 0
|
|
1815
|
+
}, {
|
|
1816
|
+
singular: "page",
|
|
1817
|
+
plural: "pages"
|
|
1818
|
+
}, (id) => copyPage(client, auth.site, id, options.out));
|
|
1819
|
+
}
|
|
1820
|
+
async function copyPage(client, site, id, out) {
|
|
1821
|
+
console.log(`Fetching page ${id} ...`);
|
|
1822
|
+
await runCopy(planPageCopy(await fetchPage(client, site, id), out), (url) => client.getBinary(url));
|
|
1823
|
+
}
|
|
1824
|
+
const PAGE_REF = {
|
|
1825
|
+
message: "Confluence page id or URL:",
|
|
1826
|
+
parse: parsePageId,
|
|
1827
|
+
notFound: (raw) => `Could not find a page id in "${raw}".`
|
|
1828
|
+
};
|
|
1829
|
+
//#endregion
|
|
1830
|
+
//#region src/cli/confluence.ts
|
|
1831
|
+
function registerConfluence(confluence) {
|
|
1832
|
+
confluence.description("Confluence commands");
|
|
1833
|
+
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));
|
|
1834
|
+
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));
|
|
1835
|
+
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));
|
|
1836
|
+
return confluence;
|
|
1837
|
+
}
|
|
1838
|
+
//#endregion
|
|
1839
|
+
//#region src/api/jira.ts
|
|
1840
|
+
const FIELDS = [
|
|
1841
|
+
"summary",
|
|
1842
|
+
"description",
|
|
1843
|
+
"issuetype",
|
|
1844
|
+
"status",
|
|
1845
|
+
"assignee",
|
|
1846
|
+
"reporter",
|
|
1847
|
+
"priority",
|
|
1848
|
+
"labels",
|
|
1849
|
+
"created",
|
|
1850
|
+
"updated",
|
|
1851
|
+
"attachment"
|
|
1852
|
+
].join(",");
|
|
1853
|
+
const RECENT_ISSUES_CLAUSE = "updated >= -30d";
|
|
1854
|
+
function browseUrl(site, key) {
|
|
1855
|
+
return `${site}/browse/${key}`;
|
|
1856
|
+
}
|
|
1857
|
+
async function fetchIssue(client, site, key) {
|
|
1858
|
+
const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
|
|
1859
|
+
const comments = await fetchComments(client, key);
|
|
1860
|
+
const f = issue.fields;
|
|
1861
|
+
return {
|
|
1862
|
+
key: issue.key,
|
|
1863
|
+
url: browseUrl(site, issue.key),
|
|
1864
|
+
summary: f.summary ?? "",
|
|
1865
|
+
type: f.issuetype?.name ?? "",
|
|
1866
|
+
status: f.status?.name ?? "",
|
|
1867
|
+
statusCategory: f.status?.statusCategory?.key ?? "",
|
|
1868
|
+
assignee: f.assignee?.displayName ?? "Unassigned",
|
|
1869
|
+
reporter: f.reporter?.displayName ?? "",
|
|
1870
|
+
priority: f.priority?.name ?? "",
|
|
1871
|
+
labels: f.labels ?? [],
|
|
1872
|
+
created: f.created ?? "",
|
|
1873
|
+
updated: f.updated ?? "",
|
|
1874
|
+
description: f.description ?? null,
|
|
1875
|
+
comments,
|
|
1876
|
+
attachments: (f.attachment ?? []).map((a) => ({
|
|
1877
|
+
mediaId: a.id,
|
|
1878
|
+
filename: a.filename,
|
|
1879
|
+
url: a.content
|
|
1880
|
+
}))
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
async function updateIssue(client, key, update) {
|
|
1884
|
+
const fields = { description: update.description };
|
|
1885
|
+
if (update.summary !== void 0) fields["summary"] = update.summary;
|
|
1886
|
+
await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
|
|
1887
|
+
}
|
|
1888
|
+
const SEARCH_FIELDS = "summary,status,updated";
|
|
1889
|
+
async function searchIssues(client, site, params) {
|
|
1890
|
+
return ((await fetchSearchPage(client, buildJql(params), params.limit)).issues ?? []).map((i) => toIssueSummary(site, i));
|
|
1891
|
+
}
|
|
1892
|
+
async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
|
|
1893
|
+
const query = new URLSearchParams({
|
|
1894
|
+
jql,
|
|
1895
|
+
maxResults: String(maxResults),
|
|
1896
|
+
fields: SEARCH_FIELDS
|
|
1897
|
+
});
|
|
1898
|
+
if (nextPageToken) query.set("nextPageToken", nextPageToken);
|
|
1899
|
+
return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
|
|
1900
|
+
}
|
|
1901
|
+
function toIssueSummary(site, i) {
|
|
1902
|
+
return {
|
|
1903
|
+
key: i.key,
|
|
1904
|
+
status: i.fields?.status?.name ?? "",
|
|
1905
|
+
statusCategory: i.fields?.status?.statusCategory?.key ?? "",
|
|
1906
|
+
summary: decodeEntities(i.fields?.summary ?? ""),
|
|
1907
|
+
updated: i.fields?.updated ?? "",
|
|
1908
|
+
url: browseUrl(site, i.key)
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
function buildJql(params) {
|
|
1912
|
+
if (params.jql) return params.jql;
|
|
1913
|
+
const clauses = [];
|
|
1914
|
+
if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
|
|
1915
|
+
if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlStringLiteral(params.assignee)}`);
|
|
1916
|
+
if (params.status) clauses.push(`status = ${jqlStringLiteral(params.status)}`);
|
|
1917
|
+
if (params.text) clauses.push(`text ~ ${jqlStringLiteral(params.text)}`);
|
|
1918
|
+
if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
|
|
1919
|
+
return `${clauses.join(" AND ")} ORDER BY updated DESC`;
|
|
1920
|
+
}
|
|
1921
|
+
const OPEN_CLAUSE = "statusCategory != Done";
|
|
1922
|
+
function buildListJql(params) {
|
|
1923
|
+
const clauses = ["assignee = currentUser()"];
|
|
1924
|
+
if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
|
|
1925
|
+
clauses.push(params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE);
|
|
1926
|
+
return `${clauses.join(" AND ")} ORDER BY updated DESC`;
|
|
1927
|
+
}
|
|
1928
|
+
const LIST_PAGE_SIZE = 100;
|
|
1929
|
+
const LIST_CAP = 500;
|
|
1930
|
+
async function listAssignedIssues(client, site, params) {
|
|
1931
|
+
const jql = buildListJql(params);
|
|
1932
|
+
const issues = [];
|
|
1933
|
+
let nextPageToken;
|
|
1934
|
+
for (;;) {
|
|
1935
|
+
const room = LIST_CAP - issues.length;
|
|
1936
|
+
const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
|
|
1937
|
+
const page = res.issues ?? [];
|
|
1938
|
+
issues.push(...page.map((i) => toIssueSummary(site, i)));
|
|
1939
|
+
if (res.isLast || !res.nextPageToken || page.length === 0) return {
|
|
1940
|
+
issues,
|
|
1941
|
+
truncated: false
|
|
1942
|
+
};
|
|
1943
|
+
if (issues.length >= LIST_CAP) return {
|
|
1944
|
+
issues,
|
|
1945
|
+
truncated: true
|
|
1946
|
+
};
|
|
1947
|
+
nextPageToken = res.nextPageToken;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
const LIST_CATEGORY_ORDER = {
|
|
1951
|
+
indeterminate: 0,
|
|
1952
|
+
new: 1,
|
|
1953
|
+
done: 2
|
|
1954
|
+
};
|
|
1955
|
+
const LIST_UNKNOWN_CATEGORY_RANK = Object.keys(LIST_CATEGORY_ORDER).length;
|
|
1956
|
+
function sortByCategoryThenUpdated(issues) {
|
|
1957
|
+
const rank = (i) => LIST_CATEGORY_ORDER[i.statusCategory] ?? LIST_UNKNOWN_CATEGORY_RANK;
|
|
1958
|
+
const updatedMs = (i) => Date.parse(i.updated) || 0;
|
|
1959
|
+
return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
|
|
1960
|
+
}
|
|
1961
|
+
const PROJECT_PAGE_SIZE = 50;
|
|
1962
|
+
async function listProjects(client, site, query) {
|
|
1963
|
+
const projects = [];
|
|
1964
|
+
for (let startAt = 0;;) {
|
|
1965
|
+
const res = await client.getJson(`/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`);
|
|
1966
|
+
const values = res.values ?? [];
|
|
1967
|
+
for (const p of values) projects.push({
|
|
1968
|
+
key: p.key,
|
|
1969
|
+
name: p.name,
|
|
1970
|
+
id: p.id,
|
|
1971
|
+
type: p.projectTypeKey ?? "",
|
|
1972
|
+
url: browseUrl(site, p.key)
|
|
1973
|
+
});
|
|
1974
|
+
if (res.isLast || values.length === 0) break;
|
|
1975
|
+
startAt += values.length;
|
|
1976
|
+
}
|
|
1977
|
+
return projects;
|
|
1978
|
+
}
|
|
1979
|
+
function projectSearchQuery(query, startAt) {
|
|
1980
|
+
const params = new URLSearchParams({
|
|
1981
|
+
orderBy: "key",
|
|
1982
|
+
maxResults: String(PROJECT_PAGE_SIZE),
|
|
1983
|
+
startAt: String(startAt)
|
|
1984
|
+
});
|
|
1985
|
+
if (query) params.set("query", query);
|
|
1986
|
+
return params.toString();
|
|
1987
|
+
}
|
|
1988
|
+
async function listStatuses(client, project) {
|
|
1989
|
+
return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await client.getJson("/rest/api/3/status")).map(toStatusSummary));
|
|
1990
|
+
}
|
|
1991
|
+
async function fetchProjectStatuses(client, project) {
|
|
1992
|
+
let groups;
|
|
1993
|
+
try {
|
|
1994
|
+
groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
|
|
1995
|
+
} catch (err) {
|
|
1996
|
+
if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
|
|
1997
|
+
throw err;
|
|
1998
|
+
}
|
|
1999
|
+
return groups.flatMap((g) => g.statuses ?? []);
|
|
2000
|
+
}
|
|
2001
|
+
function toStatusSummary(s) {
|
|
2002
|
+
return {
|
|
2003
|
+
name: s.name,
|
|
2004
|
+
id: s.id,
|
|
2005
|
+
category: s.statusCategory?.name ?? "",
|
|
2006
|
+
categoryKey: s.statusCategory?.key ?? ""
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
const CATEGORY_ORDER = {
|
|
2010
|
+
new: 0,
|
|
2011
|
+
indeterminate: 1,
|
|
2012
|
+
done: 2
|
|
2013
|
+
};
|
|
2014
|
+
const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
|
|
2015
|
+
function dedupeAndSortStatuses(statuses) {
|
|
2016
|
+
const byNameCategory = /* @__PURE__ */ new Map();
|
|
2017
|
+
for (const s of statuses) {
|
|
2018
|
+
const key = `${s.name}\0${s.categoryKey}`;
|
|
2019
|
+
if (!byNameCategory.has(key)) byNameCategory.set(key, s);
|
|
2020
|
+
}
|
|
2021
|
+
return [...byNameCategory.values()].sort((a, b) => {
|
|
2022
|
+
const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
|
|
2023
|
+
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
function jqlStringLiteral(value) {
|
|
2027
|
+
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
2028
|
+
}
|
|
2029
|
+
async function fetchComments(client, key) {
|
|
2030
|
+
return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
|
|
2031
|
+
author: c.author?.displayName ?? "",
|
|
2032
|
+
created: c.created ?? "",
|
|
2033
|
+
body: c.body ?? null
|
|
2034
|
+
}));
|
|
2035
|
+
}
|
|
2036
|
+
//#endregion
|
|
2037
|
+
//#region src/markdown/highlight.ts
|
|
2038
|
+
const HEADING = /^#{1,6} /;
|
|
2039
|
+
const QUOTE = /^(\s*)>( |$)/;
|
|
2040
|
+
const LIST = /^(\s*)(-|\d+\.) (\[[ x]\] )?/;
|
|
2041
|
+
const RULE = /^\s*---$/;
|
|
2042
|
+
const TABLE_ROW = /^\|.*\|$/;
|
|
2043
|
+
const TABLE_SEPARATOR = /^\|( --- \|)+$/;
|
|
2044
|
+
const CELL_DIVIDER = /(?<!\\)\|/;
|
|
2045
|
+
const DETAILS_OPEN = /^(\s*)<details><summary>(.*)<\/summary>$/;
|
|
2046
|
+
const DETAILS_CLOSE = /^\s*<\/details>$/;
|
|
2047
|
+
const FENCE_OPEN = /^((?:\s|> |- |\d+\. )*)```(\S*)$/;
|
|
2048
|
+
const FENCE = "```";
|
|
2049
|
+
const INLINE = /(`[^`]+`)|\[([^\]]+)\]\(((?:[^()]|\([^()]*\))+)\)|\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(?!\*)(.+?)(?<!\*)\*(?!\*)|~~(.+?)~~/g;
|
|
2050
|
+
const TOKEN_STYLES = {
|
|
2051
|
+
keyword: kleur.magenta,
|
|
2052
|
+
built_in: kleur.magenta,
|
|
2053
|
+
literal: kleur.magenta,
|
|
2054
|
+
string: kleur.green,
|
|
2055
|
+
regexp: kleur.green,
|
|
2056
|
+
addition: kleur.green,
|
|
2057
|
+
number: kleur.yellow,
|
|
2058
|
+
symbol: kleur.yellow,
|
|
2059
|
+
meta: kleur.yellow,
|
|
2060
|
+
attr: kleur.yellow,
|
|
2061
|
+
comment: kleur.dim,
|
|
2062
|
+
quote: kleur.dim,
|
|
2063
|
+
deletion: kleur.dim,
|
|
2064
|
+
title: kleur.cyan,
|
|
2065
|
+
type: kleur.cyan,
|
|
2066
|
+
section: kleur.cyan
|
|
2067
|
+
};
|
|
2068
|
+
const lowlight = createLowlight(common);
|
|
2069
|
+
function highlightMarkdown(md) {
|
|
2070
|
+
const lines = md.split("\n");
|
|
2071
|
+
const out = [];
|
|
2072
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2073
|
+
const line = lines[i] ?? "";
|
|
2074
|
+
const fence = line.match(FENCE_OPEN);
|
|
2075
|
+
if (!fence) {
|
|
2076
|
+
out.push(highlightLine(line, lines[i + 1]));
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
const [, prefix = "", lang = ""] = fence;
|
|
2080
|
+
const styledPrefix = prefix.replace(/\S+/g, (marker) => kleur.dim(marker));
|
|
2081
|
+
out.push(`${styledPrefix}${kleur.dim(`${FENCE}${lang}`)}`);
|
|
2082
|
+
const code = [];
|
|
2083
|
+
const prefixed = [];
|
|
2084
|
+
let j = i + 1;
|
|
2085
|
+
for (; j < lines.length; j++) {
|
|
2086
|
+
const raw = lines[j] ?? "";
|
|
2087
|
+
const hasPrefix = raw.startsWith(prefix) || raw === prefix.trimEnd();
|
|
2088
|
+
const body = raw.startsWith(prefix) ? raw.slice(prefix.length) : hasPrefix ? "" : raw;
|
|
2089
|
+
if (body === FENCE) break;
|
|
2090
|
+
code.push(body);
|
|
2091
|
+
prefixed.push(hasPrefix);
|
|
2092
|
+
}
|
|
2093
|
+
if (code.length > 0) highlightCode(code.join("\n"), lang).split("\n").forEach((codeLine, k) => {
|
|
2094
|
+
const lead = prefixed[k] ? styledPrefix : "";
|
|
2095
|
+
out.push(codeLine ? `${lead}${codeLine}` : lead.trimEnd());
|
|
2096
|
+
});
|
|
2097
|
+
if (j < lines.length) {
|
|
2098
|
+
const lead = (lines[j] ?? "").startsWith(prefix) ? styledPrefix : "";
|
|
2099
|
+
out.push(`${lead}${kleur.dim(FENCE)}`);
|
|
2100
|
+
}
|
|
2101
|
+
i = j;
|
|
2102
|
+
}
|
|
2103
|
+
return out.join("\n");
|
|
2104
|
+
}
|
|
2105
|
+
function highlightCode(code, lang) {
|
|
2106
|
+
if (!lang || !lowlight.registered(lang)) return styleEachLine(code, kleur.yellow);
|
|
2107
|
+
return renderTree(lowlight.highlight(lang, code), (text) => text);
|
|
2108
|
+
}
|
|
2109
|
+
function renderTree(node, style) {
|
|
2110
|
+
if (node.type === "text") return styleEachLine(node.value, style);
|
|
2111
|
+
if (node.type !== "root" && node.type !== "element") return "";
|
|
2112
|
+
const own = node.type === "element" ? tokenStyle(node.properties["className"]) : void 0;
|
|
2113
|
+
const next = own ? (text) => style(own(text)) : style;
|
|
2114
|
+
return node.children.map((child) => renderTree(child, next)).join("");
|
|
2115
|
+
}
|
|
2116
|
+
function tokenStyle(className) {
|
|
2117
|
+
if (!Array.isArray(className)) return void 0;
|
|
2118
|
+
for (const name of className) {
|
|
2119
|
+
if (typeof name !== "string" || !name.startsWith("hljs-")) continue;
|
|
2120
|
+
const style = TOKEN_STYLES[name.slice(5)];
|
|
2121
|
+
if (style) return style;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
function styleEachLine(text, style) {
|
|
2125
|
+
return text.split("\n").map((line) => line ? style(line) : line).join("\n");
|
|
2126
|
+
}
|
|
2127
|
+
function highlightLine(line, nextLine) {
|
|
2128
|
+
const quote = line.match(QUOTE);
|
|
2129
|
+
if (quote) {
|
|
2130
|
+
const [whole, indent = ""] = quote;
|
|
2131
|
+
const rest = line.slice(whole.length);
|
|
2132
|
+
const gap = whole.endsWith(" ") ? " " : "";
|
|
2133
|
+
return `${indent}${kleur.dim(">")}${gap}${highlightLine(rest, stripPrefix(nextLine, whole))}`;
|
|
2134
|
+
}
|
|
2135
|
+
const list = line.match(LIST);
|
|
2136
|
+
if (list) {
|
|
2137
|
+
const [whole, indent = "", marker = "", checkbox] = list;
|
|
2138
|
+
const rest = line.slice(whole.length);
|
|
2139
|
+
const box = checkbox ? `${kleur.bold(checkbox.trim())} ` : "";
|
|
2140
|
+
return `${indent}${kleur.dim(marker)} ${box}${highlightLine(rest, void 0)}`;
|
|
2141
|
+
}
|
|
2142
|
+
if (HEADING.test(line)) return kleur.bold().cyan(line);
|
|
2143
|
+
if (RULE.test(line)) return kleur.dim(line);
|
|
2144
|
+
if (TABLE_SEPARATOR.test(line)) return kleur.dim(line);
|
|
2145
|
+
if (TABLE_ROW.test(line)) return tableRow(line, nextLine !== void 0 && TABLE_SEPARATOR.test(nextLine));
|
|
2146
|
+
const details = line.match(DETAILS_OPEN);
|
|
2147
|
+
if (details) {
|
|
2148
|
+
const [, indent = "", title = ""] = details;
|
|
2149
|
+
return `${indent}${kleur.dim("<details><summary>")}${highlightInline(title)}${kleur.dim("</summary>")}`;
|
|
2150
|
+
}
|
|
2151
|
+
if (DETAILS_CLOSE.test(line)) return kleur.dim(line);
|
|
2152
|
+
return highlightInline(line);
|
|
2153
|
+
}
|
|
2154
|
+
function stripPrefix(line, prefix) {
|
|
2155
|
+
return line?.startsWith(prefix) ? line.slice(prefix.length) : void 0;
|
|
2156
|
+
}
|
|
2157
|
+
function tableRow(line, header) {
|
|
2158
|
+
const styled = line.split(CELL_DIVIDER).slice(1, -1).map((cell) => header ? kleur.bold(highlightInline(cell)) : highlightInline(cell));
|
|
2159
|
+
const pipe = kleur.dim("|");
|
|
2160
|
+
return `${pipe}${styled.join(pipe)}${pipe}`;
|
|
2161
|
+
}
|
|
2162
|
+
function highlightInline(text) {
|
|
2163
|
+
return text.replace(INLINE, (match, code, linkText, url, boldItalic, bold, italic, strike) => {
|
|
2164
|
+
if (code !== void 0) return kleur.yellow(code);
|
|
2165
|
+
if (linkText !== void 0 && url !== void 0) return link(linkText, url);
|
|
2166
|
+
if (boldItalic !== void 0) return kleur.bold().italic(match);
|
|
2167
|
+
if (bold !== void 0) return kleur.bold(`**${highlightInline(bold)}**`);
|
|
2168
|
+
if (italic !== void 0) return kleur.italic(`*${highlightInline(italic)}*`);
|
|
2169
|
+
if (strike !== void 0) return kleur.strikethrough(`~~${highlightInline(strike)}~~`);
|
|
2170
|
+
return match;
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
function link(text, url) {
|
|
2174
|
+
const open = kleur.dim("[");
|
|
2175
|
+
const middle = kleur.dim("](");
|
|
2176
|
+
return `${open}${highlightInline(text)}${middle}${kleur.dim().underline(url)}${kleur.dim(")")}`;
|
|
2177
|
+
}
|
|
2178
|
+
//#endregion
|
|
2179
|
+
//#region src/commands/jira.ts
|
|
2180
|
+
async function jiraProjects(query, options) {
|
|
2181
|
+
const auth = await requireAuth();
|
|
2182
|
+
const projects = await listProjects(new AtlassianClient(auth), auth.site, query);
|
|
2183
|
+
if (options.json) {
|
|
2184
|
+
console.log(JSON.stringify(projects, null, 2));
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
if (projects.length === 0) {
|
|
2188
|
+
console.log("No matching projects.");
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
for (const line of formatProjectRows(projects)) console.log(line);
|
|
2192
|
+
}
|
|
2193
|
+
function formatProjectRows(projects) {
|
|
2194
|
+
const width = Math.max(...projects.map((p) => p.key.length));
|
|
2195
|
+
return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
|
|
2196
|
+
}
|
|
2197
|
+
async function jiraStatuses(query, options) {
|
|
2198
|
+
let statuses = await listStatuses(new AtlassianClient(await requireAuth()), options.project);
|
|
2199
|
+
if (query) {
|
|
2200
|
+
const needle = query.toLowerCase();
|
|
2201
|
+
statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
|
|
2202
|
+
}
|
|
2203
|
+
if (options.json) {
|
|
2204
|
+
console.log(JSON.stringify(statuses, null, 2));
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (statuses.length === 0) {
|
|
2208
|
+
console.log("No matching statuses.");
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
for (const line of formatStatusRows(statuses)) console.log(line);
|
|
2212
|
+
}
|
|
2213
|
+
function formatStatusRows(statuses) {
|
|
2214
|
+
const width = Math.max(...statuses.map((s) => s.name.length));
|
|
2215
|
+
return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
|
|
2216
|
+
}
|
|
2217
|
+
async function jiraView(arg, options) {
|
|
2218
|
+
const auth = await requireAuth();
|
|
2219
|
+
const key = await resolveRef(arg, ISSUE_REF);
|
|
2220
|
+
const issue = await fetchIssue(new AtlassianClient(auth), auth.site, key);
|
|
2221
|
+
for (const line of formatIssueView(issue, Date.now(), options.allComments ?? false)) console.log(line);
|
|
2222
|
+
}
|
|
2223
|
+
const CATEGORY_COLORS = {
|
|
2224
|
+
new: kleur.cyan,
|
|
2225
|
+
indeterminate: kleur.yellow,
|
|
2226
|
+
done: kleur.green
|
|
2227
|
+
};
|
|
2228
|
+
function colorForCategory(category) {
|
|
2229
|
+
return CATEGORY_COLORS[category] ?? ((text) => text);
|
|
2230
|
+
}
|
|
2231
|
+
function formatIssueView(issue, nowMs, allComments) {
|
|
2232
|
+
return [
|
|
2233
|
+
`${kleur.bold(issue.key)} ${issue.summary}`,
|
|
2234
|
+
...fieldLines(issue, nowMs),
|
|
2235
|
+
...bodyLines(adfToMarkdown(issue.description)),
|
|
2236
|
+
...commentSection(issue.comments, allComments),
|
|
2237
|
+
...attachmentSection(issue.attachments)
|
|
2238
|
+
];
|
|
2239
|
+
}
|
|
2240
|
+
function fieldLines(issue, nowMs) {
|
|
2241
|
+
const colorStatus = colorForCategory(issue.statusCategory);
|
|
2242
|
+
const kept = [
|
|
2243
|
+
["Type", issue.type],
|
|
2244
|
+
["Status", issue.status && colorStatus(issue.status)],
|
|
2245
|
+
["Assignee", issue.assignee],
|
|
2246
|
+
["Reporter", issue.reporter],
|
|
2247
|
+
["Priority", issue.priority],
|
|
2248
|
+
["Labels", issue.labels.join(", ")],
|
|
2249
|
+
["Created", dateWithAge(issue.created, nowMs)],
|
|
2250
|
+
["Updated", dateWithAge(issue.updated, nowMs)],
|
|
2251
|
+
["URL", issue.url]
|
|
2252
|
+
].filter(([, value]) => value !== "");
|
|
2253
|
+
const width = Math.max(...kept.map(([label]) => label.length)) + 1;
|
|
2254
|
+
return kept.map(([label, value]) => `${kleur.dim(`${label}:`.padEnd(width))} ${value}`);
|
|
2255
|
+
}
|
|
2256
|
+
function dateWithAge(iso, nowMs) {
|
|
2257
|
+
if (!iso) return "";
|
|
2258
|
+
return `${formatDateTime(iso).slice(0, 10)} (${relativeTime(iso, nowMs)})`;
|
|
2259
|
+
}
|
|
2260
|
+
function bodyLines(body) {
|
|
2261
|
+
return body ? ["", highlightMarkdown(body)] : [];
|
|
2262
|
+
}
|
|
2263
|
+
function commentSection(comments, allComments) {
|
|
2264
|
+
if (comments.length === 0) return [];
|
|
2265
|
+
const visible = allComments ? comments : comments.slice(-5);
|
|
2266
|
+
const heading = comments.length - visible.length > 0 ? `Comments (${comments.length}, showing last ${visible.length} — --all-comments for all)` : `Comments (${comments.length})`;
|
|
2267
|
+
const lines = ["", kleur.bold(heading)];
|
|
2268
|
+
for (const comment of visible) {
|
|
2269
|
+
const body = adfToMarkdown(comment.body);
|
|
2270
|
+
lines.push("", commentHeader(comment), ...body ? [highlightMarkdown(body)] : []);
|
|
2271
|
+
}
|
|
2272
|
+
return lines;
|
|
2273
|
+
}
|
|
2274
|
+
function commentHeader(comment) {
|
|
2275
|
+
return `─ ${kleur.bold(comment.author || "Unknown")}${comment.created ? ` · ${kleur.dim(formatDateTime(comment.created))}` : ""}`;
|
|
2276
|
+
}
|
|
2277
|
+
function attachmentSection(attachments) {
|
|
2278
|
+
if (attachments.length === 0) return [];
|
|
2279
|
+
return [
|
|
2280
|
+
"",
|
|
2281
|
+
kleur.bold("Attachments"),
|
|
2282
|
+
...attachments.map((a) => `- ${a.filename}`)
|
|
2283
|
+
];
|
|
2284
|
+
}
|
|
2285
|
+
async function jiraCopy(arg, options) {
|
|
2286
|
+
const auth = await requireAuth();
|
|
2287
|
+
const key = await resolveRef(arg, ISSUE_REF);
|
|
2288
|
+
await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
|
|
2289
|
+
}
|
|
2290
|
+
async function jiraUpdate(arg, options) {
|
|
2291
|
+
const src = parseIssueSource(await readFile(arg ?? await input({
|
|
2292
|
+
message: "Path to the issue Markdown file:",
|
|
2293
|
+
required: true
|
|
2294
|
+
}), "utf8"));
|
|
2295
|
+
const auth = await requireAuth();
|
|
2296
|
+
const client = new AtlassianClient(auth);
|
|
2297
|
+
const plan = planIssueUpdate(src, await fetchIssue(client, auth.site, src.key), options);
|
|
2298
|
+
await runPlan(plan, options, async () => {
|
|
2299
|
+
const { current, next } = plan.headline;
|
|
2300
|
+
await updateIssue(client, src.key, {
|
|
2301
|
+
description: plan.body,
|
|
2302
|
+
summary: next !== current ? next : void 0
|
|
2303
|
+
});
|
|
2304
|
+
console.log(`Updated ${src.key}.`);
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
async function jiraSearch(query, options) {
|
|
2308
|
+
if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
|
|
2309
|
+
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
2310
|
+
const auth = await requireAuth();
|
|
2311
|
+
const client = new AtlassianClient(auth);
|
|
2312
|
+
const limit = parseLimit(options.limit);
|
|
2313
|
+
const issues = await searchIssues(client, auth.site, {
|
|
2314
|
+
text: query,
|
|
2315
|
+
project: options.project,
|
|
2316
|
+
assignee: options.assignee,
|
|
2317
|
+
status: options.status,
|
|
2318
|
+
jql: options.jql,
|
|
2319
|
+
limit
|
|
2320
|
+
});
|
|
2321
|
+
await runSearch(issues.map((i) => ({
|
|
2322
|
+
id: i.key,
|
|
2323
|
+
fixedColumns: `${i.key} ${i.status}`,
|
|
2324
|
+
freeText: i.summary,
|
|
2325
|
+
json: {
|
|
2326
|
+
key: i.key,
|
|
2327
|
+
status: i.status,
|
|
2328
|
+
summary: i.summary,
|
|
2329
|
+
url: i.url
|
|
2330
|
+
}
|
|
2331
|
+
})), {
|
|
2332
|
+
json: options.json,
|
|
2333
|
+
copy: options.copy,
|
|
2334
|
+
out: options.out,
|
|
2335
|
+
empty: "No matching issues.",
|
|
2336
|
+
footer: issues.length === limit ? searchFooter(limit) : void 0
|
|
2337
|
+
}, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
|
|
2338
|
+
}
|
|
2339
|
+
async function jiraList(options) {
|
|
2340
|
+
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
2341
|
+
const auth = await requireAuth();
|
|
2342
|
+
const client = new AtlassianClient(auth);
|
|
2343
|
+
const { issues, truncated } = await listAssignedIssues(client, auth.site, {
|
|
2344
|
+
all: options.all,
|
|
2345
|
+
project: options.project
|
|
2346
|
+
});
|
|
2347
|
+
await runSearch(formatListRows(sortByCategoryThenUpdated(issues), Date.now()), {
|
|
2348
|
+
json: options.json,
|
|
2349
|
+
copy: options.copy,
|
|
2350
|
+
out: options.out,
|
|
2351
|
+
empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
|
|
2352
|
+
footer: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
|
|
2353
|
+
}, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
|
|
2354
|
+
}
|
|
2355
|
+
function formatListRows(issues, nowMs) {
|
|
2356
|
+
const ages = issues.map((i) => relativeTime(i.updated, nowMs));
|
|
2357
|
+
const width = (values) => Math.max(...values.map((v) => v.length));
|
|
2358
|
+
const keyWidth = width(issues.map((i) => i.key));
|
|
2359
|
+
const statusWidth = width(issues.map((i) => i.status));
|
|
2360
|
+
const ageWidth = width(ages);
|
|
2361
|
+
return issues.map((i, n) => {
|
|
2362
|
+
const status = colorForCategory(i.statusCategory)(i.status.padEnd(statusWidth));
|
|
2363
|
+
return {
|
|
2364
|
+
id: i.key,
|
|
2365
|
+
fixedColumns: `${i.key.padEnd(keyWidth)} ${status} ${(ages[n] ?? "").padEnd(ageWidth)}`,
|
|
2366
|
+
freeText: i.summary,
|
|
2367
|
+
json: { ...i }
|
|
2368
|
+
};
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
const ISSUE_NOUN = {
|
|
2372
|
+
singular: "issue",
|
|
2373
|
+
plural: "issues"
|
|
2374
|
+
};
|
|
2375
|
+
async function copyIssue(client, site, key, out) {
|
|
2376
|
+
console.log(`Fetching ${key} ...`);
|
|
2377
|
+
await runCopy(planIssueCopy(await fetchIssue(client, site, key), out), (url) => client.getBinary(url));
|
|
2378
|
+
}
|
|
2379
|
+
const ISSUE_REF = {
|
|
2380
|
+
message: "Jira issue key or URL:",
|
|
2381
|
+
parse: parseIssueKey,
|
|
2382
|
+
notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
|
|
2383
|
+
};
|
|
2384
|
+
//#endregion
|
|
2385
|
+
//#region src/cli/jira.ts
|
|
2386
|
+
function registerJira(jira) {
|
|
2387
|
+
jira.description("Jira commands");
|
|
2388
|
+
jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(run(jiraProjects));
|
|
2389
|
+
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));
|
|
2390
|
+
jira.command("view [issue]").description("Show a Jira issue (key or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").action(run(jiraView));
|
|
2391
|
+
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));
|
|
2392
|
+
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));
|
|
2393
|
+
jira.command("list").description("List open issues assigned to you").option("-p, --project <key>", "limit to a project").option("-a, --all", "include Done issues updated in the last 30 days").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(jiraList));
|
|
2394
|
+
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));
|
|
2395
|
+
return jira;
|
|
2396
|
+
}
|
|
2397
|
+
//#endregion
|
|
2398
|
+
//#region package.json
|
|
2399
|
+
var version = "1.9.0";
|
|
2400
|
+
//#endregion
|
|
2401
|
+
//#region src/cli/build.ts
|
|
2402
|
+
const PRODUCTS = {
|
|
2403
|
+
jira: registerJira,
|
|
2404
|
+
confluence: registerConfluence,
|
|
2405
|
+
bitbucket: registerBitbucket
|
|
2406
|
+
};
|
|
2407
|
+
function buildAtlass() {
|
|
2408
|
+
const program = new Command().name("atlass").description("Copy Jira issues and Confluence pages to Markdown.").version(version);
|
|
2409
|
+
registerAuth(program.command("auth"));
|
|
2410
|
+
registerJira(program.command("jira"));
|
|
2411
|
+
registerConfluence(program.command("confluence").alias("conf"));
|
|
2412
|
+
registerBitbucket(program.command("bitbucket").alias("bb"));
|
|
2413
|
+
return program;
|
|
2414
|
+
}
|
|
2415
|
+
function buildStandalone(product) {
|
|
2416
|
+
return PRODUCTS[product](new Command().name(product).version(version));
|
|
2417
|
+
}
|
|
2418
|
+
//#endregion
|
|
2419
|
+
export { buildStandalone as n, fail as r, buildAtlass as t };
|