atlass 1.11.0 → 1.13.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 +31 -5
- package/dist/bitbucket.d.mts +1 -1
- package/dist/bitbucket.mjs +1 -1
- package/dist/{build-wKtbjBhd.mjs → build-BblaPBOA.mjs} +1699 -1353
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/confluence.d.mts +1 -1
- package/dist/confluence.mjs +1 -1
- package/dist/jira.d.mts +1 -1
- package/dist/jira.mjs +1 -1
- package/package.json +6 -6
|
@@ -1,33 +1,60 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { checkbox, confirm, editor, input, password, search, select } from "@inquirer/prompts";
|
|
3
2
|
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
3
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
4
|
import { Entry } from "@napi-rs/keyring";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import { checkbox, confirm, editor, input, password, search, select } from "@inquirer/prompts";
|
|
7
9
|
import kleur from "kleur";
|
|
8
10
|
import { marked } from "marked";
|
|
9
11
|
import { randomUUID } from "node:crypto";
|
|
10
|
-
import { stripVTControlCharacters } from "node:util";
|
|
11
12
|
import { common, createLowlight } from "lowlight";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
//#region src/api/http-error.ts
|
|
14
|
+
var HttpError = class extends Error {
|
|
15
|
+
status;
|
|
16
|
+
jira;
|
|
17
|
+
constructor(status, message, jira = {
|
|
18
|
+
errorMessages: [],
|
|
19
|
+
errors: {}
|
|
20
|
+
}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.jira = jira;
|
|
24
|
+
this.name = "HttpError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
function httpError(status, path, body, loginHint) {
|
|
28
|
+
if (status === 401 || status === 403) return new HttpError(status, `Authentication failed (401/403). Run \`${loginHint}\` to update your token.`);
|
|
29
|
+
if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
|
|
30
|
+
if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
|
|
31
|
+
if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
|
|
32
|
+
if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`, jiraErrors(body));
|
|
33
|
+
return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`, jiraErrors(body));
|
|
23
34
|
}
|
|
24
|
-
function
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
function extractErrorMessage(body) {
|
|
36
|
+
if (!body) return "";
|
|
37
|
+
const json = tryParseJson(body);
|
|
38
|
+
const { errorMessages, errors } = jiraErrors(body);
|
|
39
|
+
const fromJira = [...errorMessages, ...Object.entries(errors).map(([f, m]) => `${f}: ${m}`)];
|
|
40
|
+
if (fromJira.length) return fromJira.join("; ");
|
|
41
|
+
if (json?.message) return json.message;
|
|
42
|
+
if (json?.error?.message) return json.error.message;
|
|
43
|
+
return body.slice(0, 300);
|
|
28
44
|
}
|
|
29
|
-
function
|
|
30
|
-
|
|
45
|
+
function jiraErrors(body) {
|
|
46
|
+
const json = tryParseJson(body);
|
|
47
|
+
return {
|
|
48
|
+
errorMessages: json?.errorMessages ?? [],
|
|
49
|
+
errors: json?.errors ?? {}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function tryParseJson(text) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(text);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
31
58
|
}
|
|
32
59
|
//#endregion
|
|
33
60
|
//#region src/api/client.ts
|
|
@@ -35,10 +62,12 @@ const DISABLE_XSRF_CHECK = { "X-Atlassian-Token": "nocheck" };
|
|
|
35
62
|
var AtlassianClient = class {
|
|
36
63
|
site;
|
|
37
64
|
authHeader;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
loginHint;
|
|
66
|
+
constructor(credentials, loginHint) {
|
|
67
|
+
this.site = credentials.site;
|
|
68
|
+
const basic = Buffer.from(`${credentials.email}:${credentials.token}`).toString("base64");
|
|
41
69
|
this.authHeader = `Basic ${basic}`;
|
|
70
|
+
this.loginHint = loginHint;
|
|
42
71
|
}
|
|
43
72
|
async request(path, init) {
|
|
44
73
|
const res = await fetch(`${this.site}${path}`, {
|
|
@@ -51,7 +80,7 @@ var AtlassianClient = class {
|
|
|
51
80
|
});
|
|
52
81
|
if (!res.ok) {
|
|
53
82
|
const body = await res.text().catch(() => "");
|
|
54
|
-
throw httpError(res.status, path, body);
|
|
83
|
+
throw httpError(res.status, path, body, this.loginHint);
|
|
55
84
|
}
|
|
56
85
|
return res;
|
|
57
86
|
}
|
|
@@ -111,331 +140,310 @@ function pathAndQuery(absoluteUrl) {
|
|
|
111
140
|
const u = new URL(absoluteUrl);
|
|
112
141
|
return u.pathname + u.search;
|
|
113
142
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/api/session.ts
|
|
145
|
+
const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
|
|
146
|
+
const ATLASSIAN_LOGIN = "atlass auth login";
|
|
147
|
+
const BITBUCKET_LOGIN = "atlass bitbucket login";
|
|
148
|
+
var BitbucketClient = class extends AtlassianClient {
|
|
149
|
+
workspace;
|
|
150
|
+
defaultRepo;
|
|
151
|
+
uuid;
|
|
152
|
+
constructor(email, token, bitbucket) {
|
|
153
|
+
super({
|
|
154
|
+
site: BITBUCKET_ORIGIN,
|
|
155
|
+
email,
|
|
156
|
+
token
|
|
157
|
+
}, BITBUCKET_LOGIN);
|
|
158
|
+
this.workspace = bitbucket.workspace;
|
|
159
|
+
this.defaultRepo = bitbucket.defaultRepo;
|
|
160
|
+
this.uuid = bitbucket.uuid;
|
|
125
161
|
}
|
|
126
162
|
};
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
|
|
130
|
-
if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
|
|
131
|
-
if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
|
|
132
|
-
if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`, jiraErrors(body));
|
|
133
|
-
return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`, jiraErrors(body));
|
|
163
|
+
function sessionFor(credentials) {
|
|
164
|
+
return new AtlassianClient(credentials, ATLASSIAN_LOGIN);
|
|
134
165
|
}
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
const json = tryParseJson(body);
|
|
138
|
-
const { errorMessages, errors } = jiraErrors(body);
|
|
139
|
-
const fromJira = [...errorMessages, ...Object.entries(errors).map(([f, m]) => `${f}: ${m}`)];
|
|
140
|
-
if (fromJira.length) return fromJira.join("; ");
|
|
141
|
-
if (json?.message) return json.message;
|
|
142
|
-
if (json?.error?.message) return json.error.message;
|
|
143
|
-
return body.slice(0, 300);
|
|
166
|
+
function bitbucketSessionFor(email, token, bitbucket) {
|
|
167
|
+
return new BitbucketClient(email, token, bitbucket);
|
|
144
168
|
}
|
|
145
|
-
function
|
|
146
|
-
const
|
|
169
|
+
async function openSession(profile) {
|
|
170
|
+
const config = await profile.read();
|
|
171
|
+
if (!config?.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
|
|
172
|
+
const token = await profile.token(config.email, "atlassian");
|
|
173
|
+
if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
|
|
174
|
+
return sessionFor({
|
|
175
|
+
site: config.site,
|
|
176
|
+
email: config.email,
|
|
177
|
+
token
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async function openBitbucketSession(profile) {
|
|
181
|
+
const config = await profile.read();
|
|
182
|
+
if (!config?.email || !config.bitbucket?.workspace) throw new Error("Not logged in to Bitbucket. Run `atlass bitbucket login` first.");
|
|
183
|
+
const token = await profile.token(config.email, "bitbucket");
|
|
184
|
+
if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
|
|
185
|
+
return bitbucketSessionFor(config.email, token, config.bitbucket);
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/files/disk.ts
|
|
189
|
+
async function makeRoom(path) {
|
|
190
|
+
await mkdir(dirname(path), { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
function diskFiles() {
|
|
147
193
|
return {
|
|
148
|
-
|
|
149
|
-
|
|
194
|
+
async readText(path) {
|
|
195
|
+
return readFile(path, "utf8");
|
|
196
|
+
},
|
|
197
|
+
async readBytes(path) {
|
|
198
|
+
const buffer = await readFile(path);
|
|
199
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
200
|
+
},
|
|
201
|
+
async size(path) {
|
|
202
|
+
return (await stat(path)).size;
|
|
203
|
+
},
|
|
204
|
+
async writeText(path, text) {
|
|
205
|
+
await makeRoom(path);
|
|
206
|
+
await writeFile(path, text, "utf8");
|
|
207
|
+
},
|
|
208
|
+
async writeBytes(path, bytes) {
|
|
209
|
+
await makeRoom(path);
|
|
210
|
+
await writeFile(path, bytes);
|
|
211
|
+
}
|
|
150
212
|
};
|
|
151
213
|
}
|
|
152
|
-
function tryParseJson(text) {
|
|
153
|
-
try {
|
|
154
|
-
return JSON.parse(text);
|
|
155
|
-
} catch {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
214
|
//#endregion
|
|
160
|
-
//#region src/
|
|
215
|
+
//#region src/profile/disk.ts
|
|
216
|
+
const SERVICE = "atlass";
|
|
161
217
|
function configDir() {
|
|
162
|
-
|
|
218
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
219
|
+
return join(base, "atlass");
|
|
163
220
|
}
|
|
164
221
|
function configPath() {
|
|
165
222
|
return join(configDir(), "config.json");
|
|
166
223
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const raw = await readFile(configPath(), "utf8");
|
|
170
|
-
const parsed = JSON.parse(raw);
|
|
171
|
-
return {
|
|
172
|
-
site: parsed.site,
|
|
173
|
-
email: parsed.email,
|
|
174
|
-
bitbucket: parsed.bitbucket
|
|
175
|
-
};
|
|
176
|
-
} catch {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
async function writeConfig(config) {
|
|
181
|
-
await mkdir(configDir(), { recursive: true });
|
|
182
|
-
await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
224
|
+
function entry(email, kind) {
|
|
225
|
+
return new Entry(SERVICE, kind === "bitbucket" ? `${email}:bitbucket` : email);
|
|
183
226
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
227
|
+
function diskProfile() {
|
|
228
|
+
return {
|
|
229
|
+
async read() {
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(await readFile(configPath(), "utf8"));
|
|
232
|
+
return {
|
|
233
|
+
site: parsed.site,
|
|
234
|
+
email: parsed.email,
|
|
235
|
+
bitbucket: parsed.bitbucket
|
|
236
|
+
};
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
async write(config) {
|
|
242
|
+
await mkdir(configDir(), { recursive: true });
|
|
243
|
+
await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
244
|
+
},
|
|
245
|
+
async clear() {
|
|
246
|
+
await rm(configPath(), { force: true });
|
|
247
|
+
},
|
|
248
|
+
async token(email, kind) {
|
|
249
|
+
return entry(email, kind).getPassword();
|
|
250
|
+
},
|
|
251
|
+
async setToken(email, kind, token) {
|
|
252
|
+
entry(email, kind).setPassword(token);
|
|
253
|
+
},
|
|
254
|
+
async deleteToken(email, kind) {
|
|
255
|
+
try {
|
|
256
|
+
entry(email, kind).deleteCredential();
|
|
257
|
+
} catch {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
191
262
|
}
|
|
192
263
|
//#endregion
|
|
193
|
-
//#region src/
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
entry(key).deleteCredential();
|
|
202
|
-
} catch {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
function saveToken(email, token) {
|
|
207
|
-
entry(email).setPassword(token);
|
|
208
|
-
}
|
|
209
|
-
function readToken(email) {
|
|
210
|
-
return entry(email).getPassword();
|
|
211
|
-
}
|
|
212
|
-
function deleteToken(email) {
|
|
213
|
-
deleteEntryIfPresent(email);
|
|
214
|
-
}
|
|
215
|
-
function bitbucketKey(email) {
|
|
216
|
-
return `${email}:bitbucket`;
|
|
217
|
-
}
|
|
218
|
-
function saveBitbucketToken(email, token) {
|
|
219
|
-
entry(bitbucketKey(email)).setPassword(token);
|
|
220
|
-
}
|
|
221
|
-
function readBitbucketToken(email) {
|
|
222
|
-
return entry(bitbucketKey(email)).getPassword();
|
|
264
|
+
//#region src/terminal/pager.ts
|
|
265
|
+
const DEFAULT_ROWS = 24;
|
|
266
|
+
const DEFAULT_COLUMNS = 80;
|
|
267
|
+
const DEFAULT_PAGER = "less";
|
|
268
|
+
const DEFAULT_LESS = "FRX";
|
|
269
|
+
function renderedHeight(text, columns) {
|
|
270
|
+
const width = Math.max(1, columns);
|
|
271
|
+
return text.split("\n").map((line) => Math.max(1, Math.ceil(stripVTControlCharacters(line).length / width))).reduce((sum, rows) => sum + rows, 0);
|
|
223
272
|
}
|
|
224
|
-
function
|
|
225
|
-
|
|
273
|
+
function shouldPage(text, screen) {
|
|
274
|
+
if (!screen.isTTY) return false;
|
|
275
|
+
const rows = screen.rows ?? DEFAULT_ROWS;
|
|
276
|
+
return renderedHeight(text, screen.columns ?? DEFAULT_COLUMNS) > rows;
|
|
226
277
|
}
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
if (!config || !config.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
|
|
230
|
-
const token = readToken(config.email);
|
|
231
|
-
if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
|
|
278
|
+
function pagerCommand(env) {
|
|
279
|
+
const [command = DEFAULT_PAGER, ...args] = (env["PAGER"] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
232
280
|
return {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
281
|
+
command,
|
|
282
|
+
args,
|
|
283
|
+
env: {
|
|
284
|
+
...env,
|
|
285
|
+
LESS: env["LESS"] ?? DEFAULT_LESS
|
|
286
|
+
}
|
|
236
287
|
};
|
|
237
288
|
}
|
|
238
|
-
async function
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const token = readBitbucketToken(config.email);
|
|
242
|
-
if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
|
|
243
|
-
return {
|
|
244
|
-
site: BITBUCKET_ORIGIN,
|
|
245
|
-
email: config.email,
|
|
246
|
-
token,
|
|
247
|
-
workspace: config.bitbucket.workspace,
|
|
248
|
-
defaultRepo: config.bitbucket.defaultRepo
|
|
249
|
-
};
|
|
289
|
+
async function tryPager(text, options = {}) {
|
|
290
|
+
if (!shouldPage(text, options.screen ?? process.stdout)) return false;
|
|
291
|
+
return pipeToPager(text, pagerCommand(options.env ?? process.env));
|
|
250
292
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
});
|
|
266
|
-
const me = await new AtlassianClient({
|
|
267
|
-
site,
|
|
268
|
-
email,
|
|
269
|
-
token
|
|
270
|
-
}).getJson("/rest/api/3/myself");
|
|
271
|
-
await writeConfig({
|
|
272
|
-
...await readConfig() ?? {},
|
|
273
|
-
site,
|
|
274
|
-
email
|
|
293
|
+
function pipeToPager(text, pager) {
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
const child = spawn(pager.command, pager.args, {
|
|
296
|
+
stdio: [
|
|
297
|
+
"pipe",
|
|
298
|
+
"inherit",
|
|
299
|
+
"inherit"
|
|
300
|
+
],
|
|
301
|
+
env: pager.env
|
|
302
|
+
});
|
|
303
|
+
child.once("error", () => resolve(false));
|
|
304
|
+
child.once("close", () => resolve(true));
|
|
305
|
+
child.stdin.once("error", () => {});
|
|
306
|
+
child.stdin.end(`${text}\n`);
|
|
275
307
|
});
|
|
276
|
-
saveToken(email, token);
|
|
277
|
-
console.log(`Logged in as ${me.displayName} on ${site}.`);
|
|
278
|
-
}
|
|
279
|
-
async function logout() {
|
|
280
|
-
const config = await readConfig();
|
|
281
|
-
if (config?.email) deleteToken(config.email);
|
|
282
|
-
const bitbucketLogin = config?.bitbucket && config.email ? {
|
|
283
|
-
email: config.email,
|
|
284
|
-
bitbucket: config.bitbucket
|
|
285
|
-
} : null;
|
|
286
|
-
if (bitbucketLogin) await writeConfig(bitbucketLogin);
|
|
287
|
-
else await clearConfig();
|
|
288
|
-
console.log("Logged out. Credentials removed.");
|
|
289
308
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/terminal.ts
|
|
311
|
+
var NotInteractiveError = class extends Error {
|
|
312
|
+
constructor(spec) {
|
|
313
|
+
super(spec.flag ? `Cannot prompt without a terminal. Pass ${spec.flag}.` : `Cannot prompt without a terminal: ${spec.message}`);
|
|
314
|
+
this.name = "NotInteractiveError";
|
|
295
315
|
}
|
|
296
|
-
|
|
297
|
-
console.log(`Site: ${config.site}`);
|
|
298
|
-
console.log(`Email: ${config.email}`);
|
|
299
|
-
console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
|
|
300
|
-
}
|
|
316
|
+
};
|
|
301
317
|
//#endregion
|
|
302
|
-
//#region src/
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
318
|
+
//#region src/terminal/prompts.ts
|
|
319
|
+
const inquirerPrompts = {
|
|
320
|
+
text: (spec) => input({
|
|
321
|
+
message: spec.message,
|
|
322
|
+
required: spec.required,
|
|
323
|
+
default: spec.default,
|
|
324
|
+
validate: spec.validate
|
|
325
|
+
}),
|
|
326
|
+
secret: (spec) => password({
|
|
327
|
+
message: spec.message,
|
|
328
|
+
mask: spec.mask
|
|
329
|
+
}),
|
|
330
|
+
confirm: (spec) => confirm({
|
|
331
|
+
message: spec.message,
|
|
332
|
+
default: spec.default
|
|
333
|
+
}),
|
|
334
|
+
pick: (spec) => select({
|
|
335
|
+
message: spec.message,
|
|
336
|
+
choices: spec.choices,
|
|
337
|
+
default: spec.default,
|
|
338
|
+
pageSize: spec.pageSize
|
|
339
|
+
}),
|
|
340
|
+
pickMany: (spec) => checkbox({
|
|
341
|
+
message: spec.message,
|
|
342
|
+
choices: spec.choices,
|
|
343
|
+
required: spec.required,
|
|
344
|
+
pageSize: spec.pageSize
|
|
345
|
+
}),
|
|
346
|
+
edit: (spec) => editor({
|
|
347
|
+
message: spec.message,
|
|
348
|
+
postfix: spec.postfix,
|
|
349
|
+
default: spec.default,
|
|
350
|
+
validate: spec.validate
|
|
351
|
+
}),
|
|
352
|
+
searchPick: (spec) => search({
|
|
353
|
+
message: spec.message,
|
|
354
|
+
source: (term) => spec.source(term),
|
|
355
|
+
pageSize: spec.pageSize
|
|
356
|
+
})
|
|
357
|
+
};
|
|
358
|
+
async function refuse(spec) {
|
|
359
|
+
throw new NotInteractiveError(spec);
|
|
360
|
+
}
|
|
361
|
+
const refusingPrompts = {
|
|
362
|
+
text: refuse,
|
|
363
|
+
secret: refuse,
|
|
364
|
+
confirm: refuse,
|
|
365
|
+
pick: refuse,
|
|
366
|
+
pickMany: refuse,
|
|
367
|
+
edit: refuse,
|
|
368
|
+
searchPick: refuse
|
|
369
|
+
};
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/terminal/tty.ts
|
|
372
|
+
const DEFAULT_WIDTH = 80;
|
|
373
|
+
function ttyTerminal(interactive = true) {
|
|
374
|
+
return {
|
|
375
|
+
interactive,
|
|
376
|
+
out(value) {
|
|
377
|
+
if (Array.isArray(value) && value.length === 0) return;
|
|
378
|
+
console.log(Array.isArray(value) ? value.join("\n") : value);
|
|
379
|
+
},
|
|
380
|
+
err(value) {
|
|
381
|
+
console.error(value);
|
|
382
|
+
},
|
|
383
|
+
json(value) {
|
|
384
|
+
console.log(JSON.stringify(value, null, 2));
|
|
385
|
+
},
|
|
386
|
+
async page(text, options = {}) {
|
|
387
|
+
if (options.pager !== false && await tryPager(text)) return;
|
|
388
|
+
console.log(text);
|
|
389
|
+
},
|
|
390
|
+
get width() {
|
|
391
|
+
return process.stdout.columns ?? DEFAULT_WIDTH;
|
|
392
|
+
},
|
|
393
|
+
ask: interactive ? inquirerPrompts : refusingPrompts
|
|
394
|
+
};
|
|
309
395
|
}
|
|
310
396
|
//#endregion
|
|
311
|
-
//#region src/
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
function pipelineStatus(state) {
|
|
315
|
-
if (!state) return "";
|
|
316
|
-
if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
|
|
317
|
-
return state.name ?? "";
|
|
397
|
+
//#region src/terminal/open.ts
|
|
398
|
+
function openTerminal(args, stdin) {
|
|
399
|
+
return ttyTerminal(stdin.isTTY === true && !refusedInput(args.at(-1)));
|
|
318
400
|
}
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
pagelen: String(Math.min(Math.max(limit, 1), BITBUCKET_MAX_PAGELEN))
|
|
323
|
-
}).toString();
|
|
401
|
+
function refusedInput(last) {
|
|
402
|
+
for (let command = asCommand(last); command; command = command.parent) if (command.opts()["input"] === false) return true;
|
|
403
|
+
return false;
|
|
324
404
|
}
|
|
325
|
-
function
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const end = Date.parse(endOn);
|
|
329
|
-
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
|
330
|
-
return Math.floor((end - start) / 1e3);
|
|
405
|
+
function asCommand(value) {
|
|
406
|
+
const command = value;
|
|
407
|
+
return typeof command?.opts === "function" ? command : null;
|
|
331
408
|
}
|
|
332
|
-
|
|
333
|
-
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/cli/run.ts
|
|
411
|
+
const SIGINT_EXIT_CODE = 130;
|
|
412
|
+
function bareAction(fn) {
|
|
413
|
+
return guarded((env, args) => fn(env, ...args));
|
|
414
|
+
}
|
|
415
|
+
function jiraAction(fn) {
|
|
416
|
+
return guarded(async (env, args) => fn({
|
|
417
|
+
...env,
|
|
418
|
+
session: await openSession(env.profile)
|
|
419
|
+
}, ...args));
|
|
420
|
+
}
|
|
421
|
+
function bitbucketAction(fn) {
|
|
422
|
+
return guarded(async (env, args) => fn({
|
|
423
|
+
...env,
|
|
424
|
+
session: await openBitbucketSession(env.profile)
|
|
425
|
+
}, ...args));
|
|
426
|
+
}
|
|
427
|
+
function guarded(fn) {
|
|
428
|
+
return async (...args) => {
|
|
429
|
+
try {
|
|
430
|
+
await fn({
|
|
431
|
+
term: openTerminal(args, process.stdin),
|
|
432
|
+
files: diskFiles(),
|
|
433
|
+
profile: diskProfile()
|
|
434
|
+
}, args);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
fail(err);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
334
439
|
}
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
ref: p.target?.ref_name ?? "",
|
|
340
|
-
commit: shortHash(p.target?.commit?.hash),
|
|
341
|
-
durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
|
|
342
|
-
createdOn: p.created_on ?? "",
|
|
343
|
-
creator: p.creator?.display_name ?? "",
|
|
344
|
-
uuid: p.uuid
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
function toPipelineDetail(p) {
|
|
348
|
-
return {
|
|
349
|
-
...toPipelineSummary(p),
|
|
350
|
-
repo: p.repository?.full_name ?? "",
|
|
351
|
-
trigger: p.trigger?.name ?? ""
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
function toStepSummary(step) {
|
|
355
|
-
return {
|
|
356
|
-
name: step.name ?? "",
|
|
357
|
-
status: pipelineStatus(step.state),
|
|
358
|
-
durationSeconds: wallClockSeconds(step.started_on, step.completed_on)
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
function repoPath(ref) {
|
|
362
|
-
return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/pipelines`;
|
|
363
|
-
}
|
|
364
|
-
async function* walkPages(client, firstPath) {
|
|
365
|
-
let path = firstPath;
|
|
366
|
-
while (path) {
|
|
367
|
-
const page = await client.getJson(path);
|
|
368
|
-
for (const value of page.values ?? []) yield value;
|
|
369
|
-
path = page.next ? pathAndQuery(page.next) : null;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
async function listPipelines(client, ref, limit) {
|
|
373
|
-
const out = [];
|
|
374
|
-
const first = `${repoPath(ref)}?${pipelinesQuery(limit)}`;
|
|
375
|
-
for await (const value of walkPages(client, first)) {
|
|
376
|
-
out.push(toPipelineSummary(value));
|
|
377
|
-
if (out.length >= limit) break;
|
|
378
|
-
}
|
|
379
|
-
return out;
|
|
380
|
-
}
|
|
381
|
-
async function getPipeline(client, ref, buildNumber) {
|
|
382
|
-
const found = await fetchByBuildNumber(client, ref, buildNumber) ?? await findInRecentRuns(client, ref, buildNumber);
|
|
383
|
-
if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${BUILD_NUMBER_SCAN_LIMIT} most recent runs. It may be too old.`);
|
|
384
|
-
return toPipelineDetail(found);
|
|
385
|
-
}
|
|
386
|
-
async function fetchByBuildNumber(client, ref, buildNumber) {
|
|
387
|
-
try {
|
|
388
|
-
return await client.getJson(`${repoPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
|
|
389
|
-
} catch (err) {
|
|
390
|
-
if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
|
|
391
|
-
throw err;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
async function findInRecentRuns(client, ref, buildNumber) {
|
|
395
|
-
const first = `${repoPath(ref)}?${pipelinesQuery(BITBUCKET_MAX_PAGELEN)}`;
|
|
396
|
-
let scanned = 0;
|
|
397
|
-
for await (const pipeline of walkPages(client, first)) {
|
|
398
|
-
if (pipeline.build_number === buildNumber) return pipeline;
|
|
399
|
-
if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
|
|
400
|
-
}
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
403
|
-
async function listSteps(client, ref, pipelineId) {
|
|
404
|
-
const out = [];
|
|
405
|
-
const first = `${repoPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
|
|
406
|
-
for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
|
|
407
|
-
return out;
|
|
408
|
-
}
|
|
409
|
-
//#endregion
|
|
410
|
-
//#region src/util/format.ts
|
|
411
|
-
function formatDuration(seconds) {
|
|
412
|
-
if (seconds == null) return "-";
|
|
413
|
-
if (seconds < 60) return `${seconds}s`;
|
|
414
|
-
const m = Math.floor(seconds / 60);
|
|
415
|
-
const s = seconds % 60;
|
|
416
|
-
if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
|
|
417
|
-
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
418
|
-
}
|
|
419
|
-
function formatDateTime(iso) {
|
|
420
|
-
const date = new Date(iso);
|
|
421
|
-
if (Number.isNaN(date.getTime())) return iso;
|
|
422
|
-
return date.toISOString().replace("T", " ").slice(0, 16);
|
|
440
|
+
function fail(err) {
|
|
441
|
+
if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
|
|
442
|
+
ttyTerminal().err(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
443
|
+
process.exit(1);
|
|
423
444
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const DAY = 24 * HOUR;
|
|
427
|
-
function relativeTime(iso, nowMs) {
|
|
428
|
-
if (!iso) return "-";
|
|
429
|
-
const then = Date.parse(iso);
|
|
430
|
-
if (Number.isNaN(then)) return "-";
|
|
431
|
-
const secs = Math.floor((nowMs - then) / 1e3);
|
|
432
|
-
if (secs < MINUTE) return "just now";
|
|
433
|
-
if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
|
|
434
|
-
if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
|
|
435
|
-
const days = Math.floor(secs / DAY);
|
|
436
|
-
if (days < 45) return `${days}d ago`;
|
|
437
|
-
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
|
438
|
-
return `${Math.floor(days / 365)}y ago`;
|
|
445
|
+
function isPromptInterrupt(err) {
|
|
446
|
+
return err instanceof Error && err.name === "ExitPromptError";
|
|
439
447
|
}
|
|
440
448
|
//#endregion
|
|
441
449
|
//#region src/util/parse.ts
|
|
@@ -486,201 +494,664 @@ function parseLimit(value) {
|
|
|
486
494
|
if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
|
|
487
495
|
return Math.min(n, MAX_LIMIT);
|
|
488
496
|
}
|
|
497
|
+
function siteOrigin(input) {
|
|
498
|
+
let value = input.trim();
|
|
499
|
+
if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
|
|
500
|
+
return new URL(value).origin;
|
|
501
|
+
}
|
|
489
502
|
//#endregion
|
|
490
|
-
//#region src/commands/
|
|
491
|
-
async function
|
|
492
|
-
const
|
|
493
|
-
|
|
503
|
+
//#region src/commands/auth.ts
|
|
504
|
+
async function login({ term, profile }) {
|
|
505
|
+
const site = siteOrigin(await term.ask.text({
|
|
506
|
+
message: "Atlassian site (e.g. acme.atlassian.net):",
|
|
507
|
+
required: true
|
|
508
|
+
}));
|
|
509
|
+
const email = await term.ask.text({
|
|
510
|
+
message: "Account email:",
|
|
511
|
+
required: true
|
|
512
|
+
});
|
|
513
|
+
const token = await term.ask.secret({
|
|
514
|
+
message: "API token (from id.atlassian.com/manage-profile/security/api-tokens):",
|
|
515
|
+
mask: true
|
|
516
|
+
});
|
|
517
|
+
const me = await sessionFor({
|
|
518
|
+
site,
|
|
519
|
+
email,
|
|
520
|
+
token
|
|
521
|
+
}).getJson("/rest/api/3/myself");
|
|
522
|
+
const existing = await profile.read() ?? {};
|
|
523
|
+
await profile.write({
|
|
524
|
+
...existing,
|
|
525
|
+
site,
|
|
526
|
+
email
|
|
527
|
+
});
|
|
528
|
+
await profile.setToken(email, "atlassian", token);
|
|
529
|
+
term.out(`Logged in as ${me.displayName} on ${site}.`);
|
|
530
|
+
}
|
|
531
|
+
async function logout({ term, profile }) {
|
|
532
|
+
const config = await profile.read();
|
|
533
|
+
if (config?.email) await profile.deleteToken(config.email, "atlassian");
|
|
534
|
+
const bitbucketLogin = config?.bitbucket && config.email ? {
|
|
535
|
+
email: config.email,
|
|
536
|
+
bitbucket: config.bitbucket
|
|
537
|
+
} : null;
|
|
538
|
+
if (bitbucketLogin) await profile.write(bitbucketLogin);
|
|
539
|
+
else await profile.clear();
|
|
540
|
+
term.out("Logged out. Credentials removed.");
|
|
541
|
+
}
|
|
542
|
+
async function status({ term, profile }) {
|
|
543
|
+
const config = await profile.read();
|
|
544
|
+
if (!config || !config.site || !config.email) {
|
|
545
|
+
term.out("Not logged in. Run `atlass auth login`.");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const hasToken = await profile.token(config.email, "atlassian") !== null;
|
|
549
|
+
term.out([
|
|
550
|
+
`Site: ${config.site}`,
|
|
551
|
+
`Email: ${config.email}`,
|
|
552
|
+
`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`
|
|
553
|
+
]);
|
|
554
|
+
}
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/cli/auth.ts
|
|
557
|
+
function registerAuth(auth) {
|
|
558
|
+
auth.description("Manage Atlassian credentials");
|
|
559
|
+
auth.command("login").description("Store site, email, and API token").action(bareAction(login));
|
|
560
|
+
auth.command("logout").description("Remove stored credentials").action(bareAction(logout));
|
|
561
|
+
auth.command("status").description("Show the current login").action(bareAction(status));
|
|
562
|
+
return auth;
|
|
563
|
+
}
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/api/bitbucket-user.ts
|
|
566
|
+
async function fetchCurrentUserUuid(client) {
|
|
567
|
+
const user = await client.getJson("/2.0/user");
|
|
568
|
+
if (!user.uuid) throw new Error("Bitbucket did not return an account uuid for the current user.");
|
|
569
|
+
return user.uuid;
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region src/commands/bitbucket-auth.ts
|
|
573
|
+
async function bitbucketLogin({ term, profile }) {
|
|
574
|
+
const existing = await profile.read() ?? {};
|
|
575
|
+
const email = existing.email ?? await term.ask.text({
|
|
494
576
|
message: "Account email:",
|
|
495
577
|
required: true
|
|
496
578
|
});
|
|
497
|
-
const workspace = (await
|
|
579
|
+
const workspace = (await term.ask.text({
|
|
498
580
|
message: "Bitbucket workspace (e.g. acme):",
|
|
499
581
|
required: true
|
|
500
582
|
})).trim();
|
|
501
|
-
const defaultRepo = (await
|
|
502
|
-
const token = await
|
|
503
|
-
message: "Bitbucket API token (needs
|
|
583
|
+
const defaultRepo = (await term.ask.text({ message: "Default repo slug (optional):" })).trim() || void 0;
|
|
584
|
+
const token = await term.ask.secret({
|
|
585
|
+
message: "Bitbucket API token (needs pipeline, pull request, account, and workspace read scopes):",
|
|
504
586
|
mask: true
|
|
505
587
|
});
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}), workspace);
|
|
511
|
-
await writeConfig({
|
|
588
|
+
const session = bitbucketSessionFor(email, token, { workspace });
|
|
589
|
+
const ws = await verifyWorkspace(session, workspace);
|
|
590
|
+
const uuid = await fetchCurrentUserUuid(session).catch(() => void 0);
|
|
591
|
+
await profile.write({
|
|
512
592
|
...existing,
|
|
513
593
|
email,
|
|
514
594
|
bitbucket: {
|
|
515
595
|
workspace,
|
|
516
|
-
...defaultRepo ? { defaultRepo } : {}
|
|
596
|
+
...defaultRepo ? { defaultRepo } : {},
|
|
597
|
+
...uuid ? { uuid } : {}
|
|
517
598
|
}
|
|
518
599
|
});
|
|
519
|
-
|
|
520
|
-
|
|
600
|
+
await profile.setToken(email, "bitbucket", token);
|
|
601
|
+
term.out(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
|
|
521
602
|
}
|
|
522
|
-
async function bitbucketLogout() {
|
|
523
|
-
const config = await
|
|
524
|
-
if (config?.email)
|
|
603
|
+
async function bitbucketLogout({ term, profile }) {
|
|
604
|
+
const config = await profile.read();
|
|
605
|
+
if (config?.email) await profile.deleteToken(config.email, "bitbucket");
|
|
525
606
|
const jiraLogin = config?.site && config.email ? {
|
|
526
607
|
site: config.site,
|
|
527
608
|
email: config.email
|
|
528
609
|
} : null;
|
|
529
|
-
if (jiraLogin) await
|
|
530
|
-
else await
|
|
531
|
-
|
|
610
|
+
if (jiraLogin) await profile.write(jiraLogin);
|
|
611
|
+
else await profile.clear();
|
|
612
|
+
term.out("Logged out of Bitbucket. Credentials removed.");
|
|
532
613
|
}
|
|
533
|
-
async function bitbucketStatus() {
|
|
534
|
-
const config = await
|
|
614
|
+
async function bitbucketStatus({ term, profile }) {
|
|
615
|
+
const config = await profile.read();
|
|
535
616
|
if (!config?.email || !config.bitbucket?.workspace) {
|
|
536
|
-
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
const hasToken = readBitbucketToken(config.email) !== null;
|
|
540
|
-
console.log(`Workspace: ${config.bitbucket.workspace}`);
|
|
541
|
-
console.log(`Email: ${config.email}`);
|
|
542
|
-
console.log(`Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`);
|
|
543
|
-
console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`);
|
|
544
|
-
}
|
|
545
|
-
async function bitbucketPipelines(options) {
|
|
546
|
-
const auth = await requireBitbucketAuth();
|
|
547
|
-
const ref = resolveRepo(options.repo, auth);
|
|
548
|
-
const client = new AtlassianClient(auth);
|
|
549
|
-
const limit = parseLimit(options.limit);
|
|
550
|
-
const pipelines = await withScopeHint(() => listPipelines(client, ref, limit));
|
|
551
|
-
if (options.json) {
|
|
552
|
-
console.log(JSON.stringify(pipelines, null, 2));
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
if (pipelines.length === 0) {
|
|
556
|
-
console.log("No pipelines found.");
|
|
617
|
+
term.out("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
|
|
557
618
|
return;
|
|
558
619
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const detail = await withScopeHint(() => getPipeline(client, ref, buildNumber));
|
|
567
|
-
printPipelineDetail(detail, await withScopeHint(() => listSteps(client, ref, detail.uuid)), Date.now());
|
|
568
|
-
}
|
|
569
|
-
function formatPipelineRows(pipelines, nowMs) {
|
|
570
|
-
const rows = pipelines.map((p) => ({
|
|
571
|
-
num: `#${p.buildNumber}`,
|
|
572
|
-
status: p.status || "-",
|
|
573
|
-
ref: p.ref || p.commit || "-",
|
|
574
|
-
dur: formatDuration(p.durationSeconds),
|
|
575
|
-
age: relativeTime(p.createdOn, nowMs),
|
|
576
|
-
creator: p.creator || "-"
|
|
577
|
-
}));
|
|
578
|
-
const width = (sel) => Math.max(...rows.map((r) => sel(r).length));
|
|
579
|
-
const wn = width((r) => r.num);
|
|
580
|
-
const ws = width((r) => r.status);
|
|
581
|
-
const wr = width((r) => r.ref);
|
|
582
|
-
const wd = width((r) => r.dur);
|
|
583
|
-
const wa = width((r) => r.age);
|
|
584
|
-
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}`);
|
|
585
|
-
}
|
|
586
|
-
function formatStepRows(steps) {
|
|
587
|
-
const rows = steps.map((s) => ({
|
|
588
|
-
name: s.name || "-",
|
|
589
|
-
status: s.status || "-",
|
|
590
|
-
dur: formatDuration(s.durationSeconds)
|
|
591
|
-
}));
|
|
592
|
-
const wn = Math.max(...rows.map((r) => r.name.length));
|
|
593
|
-
const ws = Math.max(...rows.map((r) => r.status.length));
|
|
594
|
-
return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
|
|
595
|
-
}
|
|
596
|
-
function printPipelineDetail(detail, steps, nowMs) {
|
|
597
|
-
console.log(`Pipeline #${detail.buildNumber} ${detail.status || "-"}`);
|
|
598
|
-
if (detail.repo) console.log(`Repo: ${detail.repo}`);
|
|
599
|
-
console.log(`Ref: ${refWithCommit(detail)}`);
|
|
600
|
-
if (detail.trigger) console.log(`Trigger: ${detail.trigger}`);
|
|
601
|
-
console.log(`Duration: ${formatDuration(detail.durationSeconds)}`);
|
|
602
|
-
const by = detail.creator ? ` by ${detail.creator}` : "";
|
|
603
|
-
console.log(`Created: ${relativeTime(detail.createdOn, nowMs)}${by}`);
|
|
604
|
-
if (steps.length > 0) {
|
|
605
|
-
console.log("");
|
|
606
|
-
console.log("Steps:");
|
|
607
|
-
for (const line of formatStepRows(steps)) console.log(line);
|
|
608
|
-
}
|
|
620
|
+
const hasToken = await profile.token(config.email, "bitbucket") !== null;
|
|
621
|
+
term.out([
|
|
622
|
+
`Workspace: ${config.bitbucket.workspace}`,
|
|
623
|
+
`Email: ${config.email}`,
|
|
624
|
+
`Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`,
|
|
625
|
+
`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`
|
|
626
|
+
]);
|
|
609
627
|
}
|
|
610
|
-
function
|
|
611
|
-
|
|
612
|
-
|
|
628
|
+
async function rememberBitbucketUuid(profile, uuid) {
|
|
629
|
+
const config = await profile.read();
|
|
630
|
+
if (!config?.bitbucket) return;
|
|
631
|
+
await profile.write({
|
|
632
|
+
...config,
|
|
633
|
+
bitbucket: {
|
|
634
|
+
...config.bitbucket,
|
|
635
|
+
uuid
|
|
636
|
+
}
|
|
637
|
+
});
|
|
613
638
|
}
|
|
614
|
-
async function verifyWorkspace(
|
|
639
|
+
async function verifyWorkspace(transport, workspace) {
|
|
615
640
|
try {
|
|
616
|
-
return await
|
|
641
|
+
return await transport.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
|
|
617
642
|
} catch (err) {
|
|
618
643
|
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.`);
|
|
619
644
|
if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
|
|
620
645
|
throw err;
|
|
621
646
|
}
|
|
622
647
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region src/api/bitbucket-repo.ts
|
|
650
|
+
const BITBUCKET_WEB_ORIGIN = "https://bitbucket.org";
|
|
651
|
+
function repoPath(ref, resource) {
|
|
652
|
+
return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/${resource}`;
|
|
627
653
|
}
|
|
628
|
-
async function
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
654
|
+
async function* walkPages(client, firstPath) {
|
|
655
|
+
let path = firstPath;
|
|
656
|
+
while (path) {
|
|
657
|
+
const page = await client.getJson(path);
|
|
658
|
+
for (const value of page.values ?? []) yield value;
|
|
659
|
+
path = page.next ? pathAndQuery(page.next) : null;
|
|
634
660
|
}
|
|
635
661
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
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));
|
|
644
|
-
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));
|
|
645
|
-
return bitbucket;
|
|
662
|
+
async function collectPages(client, first, limit, map) {
|
|
663
|
+
const out = [];
|
|
664
|
+
for await (const value of walkPages(client, first)) {
|
|
665
|
+
out.push(map(value));
|
|
666
|
+
if (out.length >= limit) break;
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
646
669
|
}
|
|
647
670
|
//#endregion
|
|
648
|
-
//#region src/
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
671
|
+
//#region src/api/bitbucket-pull-requests.ts
|
|
672
|
+
const PULL_REQUEST_STATES = [
|
|
673
|
+
"OPEN",
|
|
674
|
+
"MERGED",
|
|
675
|
+
"DECLINED",
|
|
676
|
+
"SUPERSEDED"
|
|
677
|
+
];
|
|
678
|
+
function parsePullRequestStates(values) {
|
|
679
|
+
const raw = (values ?? []).flatMap((value) => value.split(","));
|
|
680
|
+
const states = [];
|
|
681
|
+
for (const value of raw) {
|
|
682
|
+
const state = value.trim().toUpperCase();
|
|
683
|
+
if (!state) continue;
|
|
684
|
+
if (!PULL_REQUEST_STATES.includes(state)) throw new Error(`Invalid --state "${value.trim()}". Expected ${PULL_REQUEST_STATES.map((s) => s.toLowerCase()).join(", ").replace(/, (?=[^,]*$)/, ", or ")}.`);
|
|
685
|
+
if (!states.includes(state)) states.push(state);
|
|
686
|
+
}
|
|
687
|
+
return states;
|
|
688
|
+
}
|
|
689
|
+
function allPullRequestStates() {
|
|
690
|
+
return [...PULL_REQUEST_STATES];
|
|
691
|
+
}
|
|
692
|
+
const PRINCIPAL_UUID = /^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}$/i;
|
|
693
|
+
const PRINCIPAL_ACCOUNT_ID = /^(?:[0-9a-z_-]{2,}[:|][0-9a-z|_-]{8,}|[0-9a-f]{24})$/i;
|
|
694
|
+
function principalTerm(field, flag, value) {
|
|
695
|
+
if (PRINCIPAL_UUID.test(value)) return `${field}.uuid = "${value}"`;
|
|
696
|
+
if (PRINCIPAL_ACCOUNT_ID.test(value)) return `${field}.account_id = "${value}"`;
|
|
697
|
+
throw new Error(`Invalid ${flag} "${value}". Expected me, an account id, or a uuid in braces.`);
|
|
698
|
+
}
|
|
699
|
+
function pullRequestQuery(filters) {
|
|
700
|
+
if (filters.query) {
|
|
701
|
+
if (/\bstate\b/i.test(filters.query)) throw new Error("--query cannot mention state; use --state instead.");
|
|
702
|
+
return filters.query;
|
|
703
|
+
}
|
|
704
|
+
const terms = [];
|
|
705
|
+
if (filters.author) terms.push(principalTerm("author", "--author", filters.author));
|
|
706
|
+
if (filters.reviewer) terms.push(principalTerm("reviewers", "--reviewer", filters.reviewer));
|
|
707
|
+
if (terms.length === 0) return void 0;
|
|
708
|
+
return terms.length === 1 ? terms[0] : `(${terms.join(" OR ")})`;
|
|
709
|
+
}
|
|
710
|
+
function pullRequestsQuery(params) {
|
|
711
|
+
const pagelen = Math.min(Math.max(params.limit, 1), 100);
|
|
712
|
+
const search = new URLSearchParams({
|
|
713
|
+
sort: "-updated_on",
|
|
714
|
+
pagelen: String(pagelen)
|
|
715
|
+
});
|
|
716
|
+
for (const state of params.states) search.append("state", state);
|
|
717
|
+
if (params.query) search.append("q", params.query);
|
|
718
|
+
return search.toString();
|
|
719
|
+
}
|
|
720
|
+
function pullRequestUrl(ref, id) {
|
|
721
|
+
return `${BITBUCKET_WEB_ORIGIN}/${ref.workspace}/${ref.repo}/pull-requests/${id}`;
|
|
722
|
+
}
|
|
723
|
+
function toPullRequestSummary(ref, pr) {
|
|
652
724
|
return {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
725
|
+
id: pr.id,
|
|
726
|
+
title: pr.title ?? "",
|
|
727
|
+
state: pr.state ?? "",
|
|
728
|
+
draft: pr.draft ?? false,
|
|
729
|
+
author: pr.author?.display_name ?? "",
|
|
730
|
+
authorUuid: pr.author?.uuid ?? "",
|
|
731
|
+
sourceBranch: pr.source?.branch?.name ?? "",
|
|
732
|
+
destinationBranch: pr.destination?.branch?.name ?? "",
|
|
733
|
+
commentCount: pr.comment_count ?? 0,
|
|
734
|
+
createdOn: pr.created_on ?? "",
|
|
735
|
+
updatedOn: pr.updated_on ?? "",
|
|
736
|
+
url: pullRequestUrl(ref, pr.id)
|
|
659
737
|
};
|
|
660
738
|
}
|
|
661
|
-
function
|
|
662
|
-
|
|
663
|
-
marked.walkTokens(marked.lexer(md), (token) => {
|
|
664
|
-
if (token.type === "image") hrefs.add(token.href);
|
|
665
|
-
});
|
|
666
|
-
return [...hrefs];
|
|
739
|
+
async function listPullRequests(client, ref, params) {
|
|
740
|
+
return collectPages(client, `${repoPath(ref, "pullrequests")}?${pullRequestsQuery(params)}`, params.limit, (value) => toPullRequestSummary(ref, value));
|
|
667
741
|
}
|
|
668
|
-
|
|
669
|
-
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/api/bitbucket-pipelines.ts
|
|
744
|
+
const BUILD_NUMBER_SCAN_LIMIT = 1e3;
|
|
745
|
+
function pipelineStatus(state) {
|
|
746
|
+
if (!state) return "";
|
|
747
|
+
if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
|
|
748
|
+
return state.name ?? "";
|
|
749
|
+
}
|
|
750
|
+
function pipelinesQuery(limit) {
|
|
751
|
+
return new URLSearchParams({
|
|
752
|
+
sort: "-created_on",
|
|
753
|
+
pagelen: String(Math.min(Math.max(limit, 1), 100))
|
|
754
|
+
}).toString();
|
|
755
|
+
}
|
|
756
|
+
function wallClockSeconds(startOn, endOn) {
|
|
757
|
+
if (!startOn || !endOn) return null;
|
|
758
|
+
const start = Date.parse(startOn);
|
|
759
|
+
const end = Date.parse(endOn);
|
|
760
|
+
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
|
761
|
+
return Math.floor((end - start) / 1e3);
|
|
762
|
+
}
|
|
763
|
+
function shortHash(hash) {
|
|
764
|
+
return hash?.slice(0, 7) ?? "";
|
|
765
|
+
}
|
|
766
|
+
function pipelineUrl(ref, buildNumber) {
|
|
767
|
+
return `${BITBUCKET_WEB_ORIGIN}/${ref.workspace}/${ref.repo}/pipelines/results/${buildNumber}`;
|
|
768
|
+
}
|
|
769
|
+
function toPipelineSummary(ref, p) {
|
|
670
770
|
return {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
771
|
+
buildNumber: p.build_number,
|
|
772
|
+
status: pipelineStatus(p.state),
|
|
773
|
+
ref: p.target?.ref_name ?? "",
|
|
774
|
+
commit: shortHash(p.target?.commit?.hash),
|
|
775
|
+
durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
|
|
776
|
+
createdOn: p.created_on ?? "",
|
|
777
|
+
creator: p.creator?.display_name ?? "",
|
|
778
|
+
uuid: p.uuid,
|
|
779
|
+
url: pipelineUrl(ref, p.build_number)
|
|
674
780
|
};
|
|
675
781
|
}
|
|
676
|
-
function
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
782
|
+
function toPipelineDetail(ref, p) {
|
|
783
|
+
return {
|
|
784
|
+
...toPipelineSummary(ref, p),
|
|
785
|
+
repo: p.repository?.full_name ?? "",
|
|
786
|
+
trigger: p.trigger?.name ?? ""
|
|
787
|
+
};
|
|
680
788
|
}
|
|
681
|
-
function
|
|
682
|
-
|
|
683
|
-
|
|
789
|
+
function toStepSummary(step) {
|
|
790
|
+
return {
|
|
791
|
+
name: step.name ?? "",
|
|
792
|
+
status: pipelineStatus(step.state),
|
|
793
|
+
durationSeconds: wallClockSeconds(step.started_on, step.completed_on)
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
function pipelinesPath(ref) {
|
|
797
|
+
return repoPath(ref, "pipelines");
|
|
798
|
+
}
|
|
799
|
+
async function listPipelines(client, ref, limit) {
|
|
800
|
+
return collectPages(client, `${pipelinesPath(ref)}?${pipelinesQuery(limit)}`, limit, (value) => toPipelineSummary(ref, value));
|
|
801
|
+
}
|
|
802
|
+
async function getPipeline(client, ref, buildNumber) {
|
|
803
|
+
const found = await fetchByBuildNumber(client, ref, buildNumber) ?? await findInRecentRuns(client, ref, buildNumber);
|
|
804
|
+
if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${BUILD_NUMBER_SCAN_LIMIT} most recent runs. It may be too old.`);
|
|
805
|
+
return toPipelineDetail(ref, found);
|
|
806
|
+
}
|
|
807
|
+
async function fetchByBuildNumber(client, ref, buildNumber) {
|
|
808
|
+
try {
|
|
809
|
+
return await client.getJson(`${pipelinesPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
|
|
812
|
+
throw err;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
async function findInRecentRuns(client, ref, buildNumber) {
|
|
816
|
+
const first = `${pipelinesPath(ref)}?${pipelinesQuery(100)}`;
|
|
817
|
+
let scanned = 0;
|
|
818
|
+
for await (const pipeline of walkPages(client, first)) {
|
|
819
|
+
if (pipeline.build_number === buildNumber) return pipeline;
|
|
820
|
+
if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
|
|
821
|
+
}
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
async function listSteps(client, ref, pipelineId) {
|
|
825
|
+
const out = [];
|
|
826
|
+
const first = `${pipelinesPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
|
|
827
|
+
for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
|
|
828
|
+
return out;
|
|
829
|
+
}
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/util/format.ts
|
|
832
|
+
function formatDuration(seconds) {
|
|
833
|
+
if (seconds == null) return "-";
|
|
834
|
+
if (seconds < 60) return `${seconds}s`;
|
|
835
|
+
const m = Math.floor(seconds / 60);
|
|
836
|
+
const s = seconds % 60;
|
|
837
|
+
if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
|
|
838
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
839
|
+
}
|
|
840
|
+
function formatDateTime(iso) {
|
|
841
|
+
const date = new Date(iso);
|
|
842
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
843
|
+
return date.toISOString().replace("T", " ").slice(0, 16);
|
|
844
|
+
}
|
|
845
|
+
const MINUTE = 60;
|
|
846
|
+
const HOUR = 3600;
|
|
847
|
+
const DAY = 24 * HOUR;
|
|
848
|
+
function relativeTime(iso, nowMs) {
|
|
849
|
+
if (!iso) return "-";
|
|
850
|
+
const then = Date.parse(iso);
|
|
851
|
+
if (Number.isNaN(then)) return "-";
|
|
852
|
+
const secs = Math.floor((nowMs - then) / 1e3);
|
|
853
|
+
if (secs < MINUTE) return "just now";
|
|
854
|
+
if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
|
|
855
|
+
if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
|
|
856
|
+
const days = Math.floor(secs / DAY);
|
|
857
|
+
if (days < 45) return `${days}d ago`;
|
|
858
|
+
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
|
859
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
860
|
+
}
|
|
861
|
+
//#endregion
|
|
862
|
+
//#region src/util/link.ts
|
|
863
|
+
const OSC8 = "\x1B]8;;";
|
|
864
|
+
const BEL = "\x07";
|
|
865
|
+
function hyperlink(text, url) {
|
|
866
|
+
if (!kleur.enabled || !url) return text;
|
|
867
|
+
return `${OSC8}${url}${BEL}${text}${OSC8}${BEL}`;
|
|
868
|
+
}
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region src/commands/search-run.ts
|
|
871
|
+
function alignedRows(items, nowMs, cells) {
|
|
872
|
+
const rows = items.map((item) => {
|
|
873
|
+
const cell = cells(item);
|
|
874
|
+
return {
|
|
875
|
+
...cell,
|
|
876
|
+
age: relativeTime(cell.timestamp, nowMs),
|
|
877
|
+
json: { ...item }
|
|
878
|
+
};
|
|
879
|
+
});
|
|
880
|
+
const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
|
|
881
|
+
const idWidth = width((row) => row.id);
|
|
882
|
+
const labelWidth = width((row) => row.label);
|
|
883
|
+
const ageWidth = width((row) => row.age);
|
|
884
|
+
return rows.map((row) => ({
|
|
885
|
+
id: row.id,
|
|
886
|
+
url: row.url,
|
|
887
|
+
fixedColumns: `${row.id.padEnd(idWidth)} ${row.color(row.label.padEnd(labelWidth))} ${row.age.padEnd(ageWidth)}`,
|
|
888
|
+
freeText: row.text,
|
|
889
|
+
json: row.json
|
|
890
|
+
}));
|
|
891
|
+
}
|
|
892
|
+
function formatRows(rows, options) {
|
|
893
|
+
if (rows.length === 0) return [options.empty];
|
|
894
|
+
const lines = rows.map((row) => formatRow(row, options.width));
|
|
895
|
+
return options.footer ? [...lines, options.footer] : lines;
|
|
896
|
+
}
|
|
897
|
+
function writeRows(term, rows, options) {
|
|
898
|
+
if (options.json) term.json(rows.map((r) => r.json));
|
|
899
|
+
else term.out(formatRows(rows, {
|
|
900
|
+
...options,
|
|
901
|
+
width: term.width
|
|
902
|
+
}));
|
|
903
|
+
}
|
|
904
|
+
const COPY_CONCURRENCY = 5;
|
|
905
|
+
async function runSearch(term, rows, options, noun, copyOne) {
|
|
906
|
+
if (!(options.copy && !options.json && rows.length > 0)) {
|
|
907
|
+
writeRows(term, rows, options);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
|
|
911
|
+
await copySelected(term, rows, noun, copyOne);
|
|
912
|
+
}
|
|
913
|
+
function searchFooter(limit) {
|
|
914
|
+
return `
|
|
915
|
+
Showing first ${limit}; refine with flags or raise --limit.`;
|
|
916
|
+
}
|
|
917
|
+
async function copySelected(term, rows, noun, copyOne) {
|
|
918
|
+
if (!term.interactive) throw new Error("--copy requires an interactive terminal.");
|
|
919
|
+
const selected = await term.ask.pickMany({
|
|
920
|
+
message: `Select ${noun.plural} to copy:`,
|
|
921
|
+
choices: rows.map((r) => ({
|
|
922
|
+
name: formatRow(r, term.width),
|
|
923
|
+
value: r.id
|
|
924
|
+
})),
|
|
925
|
+
pageSize: 20
|
|
926
|
+
});
|
|
927
|
+
if (selected.length === 0) {
|
|
928
|
+
term.out("Nothing selected.");
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
let copied = 0;
|
|
932
|
+
const failures = [];
|
|
933
|
+
const queue = [...selected];
|
|
934
|
+
async function worker() {
|
|
935
|
+
for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
|
|
936
|
+
await copyOne(id);
|
|
937
|
+
copied++;
|
|
938
|
+
} catch (err) {
|
|
939
|
+
failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
|
|
943
|
+
const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
|
|
944
|
+
if (failures.length === 0) term.out(summary);
|
|
945
|
+
else term.out(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
|
|
946
|
+
}
|
|
947
|
+
function formatRow(row, width) {
|
|
948
|
+
const room = width - stripVTControlCharacters(row.fixedColumns).length - 2;
|
|
949
|
+
const text = room > 0 ? truncate(row.freeText, room) : "";
|
|
950
|
+
const columns = hyperlink(row.id, row.url) + row.fixedColumns.slice(row.id.length);
|
|
951
|
+
return text ? `${columns} ${hyperlink(text, row.url)}` : columns;
|
|
952
|
+
}
|
|
953
|
+
function truncate(text, max) {
|
|
954
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
955
|
+
return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
|
|
956
|
+
}
|
|
957
|
+
//#endregion
|
|
958
|
+
//#region src/commands/bitbucket.ts
|
|
959
|
+
async function bitbucketPipelines({ session, term }, options) {
|
|
960
|
+
const ref = resolveRepo(options.repo, session);
|
|
961
|
+
const limit = parseLimit(options.limit);
|
|
962
|
+
writeRows(term, pipelineRows(await withScopeHint(PIPELINE_SCOPE, () => listPipelines(session, ref, limit)), Date.now()), {
|
|
963
|
+
json: options.json,
|
|
964
|
+
empty: "No pipelines found."
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
async function bitbucketPipeline({ session, term }, arg, options) {
|
|
968
|
+
const buildNumber = parseBuildNumber(arg);
|
|
969
|
+
const ref = resolveRepo(options.repo, session);
|
|
970
|
+
const detail = await withScopeHint(PIPELINE_SCOPE, () => getPipeline(session, ref, buildNumber));
|
|
971
|
+
const steps = await withScopeHint(PIPELINE_SCOPE, () => listSteps(session, ref, detail.uuid));
|
|
972
|
+
term.out(formatPipelineDetail(detail, steps, Date.now()));
|
|
973
|
+
}
|
|
974
|
+
function pipelineRows(pipelines, nowMs) {
|
|
975
|
+
return alignedRows(pipelines, nowMs, (p) => ({
|
|
976
|
+
id: `#${p.buildNumber}`,
|
|
977
|
+
url: p.url,
|
|
978
|
+
label: p.status || "-",
|
|
979
|
+
color: colorForBitbucketState(p.status),
|
|
980
|
+
text: `${refWithCommit(p)} · ${formatDuration(p.durationSeconds)}`,
|
|
981
|
+
timestamp: p.createdOn
|
|
982
|
+
}));
|
|
983
|
+
}
|
|
984
|
+
const STATE_COLORS = {
|
|
985
|
+
SUCCESSFUL: kleur.green,
|
|
986
|
+
MERGED: kleur.green,
|
|
987
|
+
FAILED: kleur.red,
|
|
988
|
+
ERROR: kleur.red,
|
|
989
|
+
DECLINED: kleur.red,
|
|
990
|
+
IN_PROGRESS: kleur.yellow,
|
|
991
|
+
PENDING: kleur.yellow,
|
|
992
|
+
OPEN: kleur.yellow,
|
|
993
|
+
PAUSED: kleur.cyan,
|
|
994
|
+
STOPPED: kleur.gray,
|
|
995
|
+
SUPERSEDED: kleur.gray,
|
|
996
|
+
DRAFT: kleur.gray
|
|
997
|
+
};
|
|
998
|
+
function colorForBitbucketState(state) {
|
|
999
|
+
return STATE_COLORS[state.toUpperCase()] ?? kleur.white;
|
|
1000
|
+
}
|
|
1001
|
+
function formatStepRows(steps) {
|
|
1002
|
+
const rows = steps.map((s) => ({
|
|
1003
|
+
name: s.name || "-",
|
|
1004
|
+
status: s.status || "-",
|
|
1005
|
+
dur: formatDuration(s.durationSeconds)
|
|
1006
|
+
}));
|
|
1007
|
+
const wn = Math.max(...rows.map((r) => r.name.length));
|
|
1008
|
+
const ws = Math.max(...rows.map((r) => r.status.length));
|
|
1009
|
+
return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
|
|
1010
|
+
}
|
|
1011
|
+
function formatPipelineDetail(detail, steps, nowMs) {
|
|
1012
|
+
const by = detail.creator ? ` by ${detail.creator}` : "";
|
|
1013
|
+
return [
|
|
1014
|
+
`Pipeline #${detail.buildNumber} ${detail.status || "-"}`,
|
|
1015
|
+
...detail.repo ? [`Repo: ${detail.repo}`] : [],
|
|
1016
|
+
`Ref: ${refWithCommit(detail)}`,
|
|
1017
|
+
...detail.trigger ? [`Trigger: ${detail.trigger}`] : [],
|
|
1018
|
+
`Duration: ${formatDuration(detail.durationSeconds)}`,
|
|
1019
|
+
`Created: ${relativeTime(detail.createdOn, nowMs)}${by}`,
|
|
1020
|
+
...steps.length > 0 ? [
|
|
1021
|
+
"",
|
|
1022
|
+
"Steps:",
|
|
1023
|
+
...formatStepRows(steps)
|
|
1024
|
+
] : []
|
|
1025
|
+
];
|
|
1026
|
+
}
|
|
1027
|
+
function refWithCommit(pipeline) {
|
|
1028
|
+
if (!pipeline.ref) return pipeline.commit || "-";
|
|
1029
|
+
return pipeline.commit ? `${pipeline.ref} (${pipeline.commit})` : pipeline.ref;
|
|
1030
|
+
}
|
|
1031
|
+
function parseBuildNumber(arg) {
|
|
1032
|
+
const raw = (arg ?? "").replace(/^#/, "").trim();
|
|
1033
|
+
if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
|
|
1034
|
+
return Number.parseInt(raw, 10);
|
|
1035
|
+
}
|
|
1036
|
+
const PIPELINE_SCOPE = "read:pipeline:bitbucket";
|
|
1037
|
+
async function withScopeHint(scope, fn) {
|
|
1038
|
+
try {
|
|
1039
|
+
return await fn();
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error(`Bitbucket rejected the request (401/403). Check the token has the ${scope} scope, or run \`atlass bitbucket login\` to update it.`);
|
|
1042
|
+
throw err;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
//#endregion
|
|
1046
|
+
//#region src/commands/bitbucket-prs.ts
|
|
1047
|
+
const PULL_REQUEST_SCOPE = "read:pullrequest:bitbucket";
|
|
1048
|
+
const ACCOUNT_SCOPE = "read:account";
|
|
1049
|
+
async function bitbucketPrs({ session, term, profile }, options) {
|
|
1050
|
+
if (options.query && (options.author || options.reviewer)) throw new Error("--query cannot be combined with --author or --reviewer.");
|
|
1051
|
+
const states = pullRequestStates(options);
|
|
1052
|
+
const ref = resolveRepo(options.repo, session);
|
|
1053
|
+
const limit = parseLimit(options.limit);
|
|
1054
|
+
const query = pullRequestQuery({
|
|
1055
|
+
...await resolvePrincipals(session, profile, options),
|
|
1056
|
+
query: options.query
|
|
1057
|
+
});
|
|
1058
|
+
const prs = await withScopeHint(PULL_REQUEST_SCOPE, () => listPullRequests(session, ref, {
|
|
1059
|
+
limit,
|
|
1060
|
+
states,
|
|
1061
|
+
query
|
|
1062
|
+
}));
|
|
1063
|
+
writeRows(term, pullRequestRows(prs, Date.now()), {
|
|
1064
|
+
json: options.json,
|
|
1065
|
+
empty: emptyMessage(options),
|
|
1066
|
+
footer: prs.length === limit ? searchFooter(limit) : void 0
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
function pullRequestStates(options) {
|
|
1070
|
+
if (!options.all) return parsePullRequestStates(options.state);
|
|
1071
|
+
if (options.state) throw new Error("--all cannot be combined with --state.");
|
|
1072
|
+
return allPullRequestStates();
|
|
1073
|
+
}
|
|
1074
|
+
function pullRequestRows(prs, nowMs) {
|
|
1075
|
+
return alignedRows(prs, nowMs, (pr) => {
|
|
1076
|
+
const label = pr.draft ? "DRAFT" : pr.state || "-";
|
|
1077
|
+
return {
|
|
1078
|
+
id: `#${pr.id}`,
|
|
1079
|
+
url: pr.url,
|
|
1080
|
+
label,
|
|
1081
|
+
color: colorForBitbucketState(label),
|
|
1082
|
+
text: pr.title,
|
|
1083
|
+
timestamp: pr.updatedOn
|
|
1084
|
+
};
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
async function resolvePrincipals(session, profile, options) {
|
|
1088
|
+
const mine = isMe(options.author) || isMe(options.reviewer) ? await currentUuid(session, profile) : void 0;
|
|
1089
|
+
return {
|
|
1090
|
+
author: isMe(options.author) ? mine : options.author,
|
|
1091
|
+
reviewer: isMe(options.reviewer) ? mine : options.reviewer
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
function isMe(value) {
|
|
1095
|
+
return value?.toLowerCase() === "me";
|
|
1096
|
+
}
|
|
1097
|
+
async function currentUuid(session, profile) {
|
|
1098
|
+
if (session.uuid) return session.uuid;
|
|
1099
|
+
const uuid = await withScopeHint(ACCOUNT_SCOPE, () => fetchCurrentUserUuid(session));
|
|
1100
|
+
await rememberBitbucketUuid(profile, uuid);
|
|
1101
|
+
return uuid;
|
|
1102
|
+
}
|
|
1103
|
+
function emptyMessage(options) {
|
|
1104
|
+
return options.author || options.reviewer || options.query || options.state || options.all ? "No matching pull requests." : "No open pull requests.";
|
|
1105
|
+
}
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/cli/bitbucket.ts
|
|
1108
|
+
function registerBitbucket(bitbucket) {
|
|
1109
|
+
bitbucket.description("Bitbucket commands");
|
|
1110
|
+
bitbucket.command("login").description("Store Bitbucket workspace and API token").action(bareAction(bitbucketLogin));
|
|
1111
|
+
bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(bareAction(bitbucketLogout));
|
|
1112
|
+
bitbucket.command("status").description("Show the current Bitbucket login").action(bareAction(bitbucketStatus));
|
|
1113
|
+
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(bitbucketAction(bitbucketPipelines));
|
|
1114
|
+
bitbucket.command("prs").description("List pull requests for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-s, --state <state...>", "open, merged, declined, or superseded").option("--all", "include every state").option("-a, --author <who>", "limit to an author ('me', an account id, or a uuid)").option("--reviewer <who>", "limit to a reviewer ('me', an account id, or a uuid)").option("-q, --query <bbql>", "raw Bitbucket query (not for use with --author or --reviewer)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(bitbucketAction(bitbucketPrs));
|
|
1115
|
+
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(bitbucketAction(bitbucketPipeline));
|
|
1116
|
+
return bitbucket;
|
|
1117
|
+
}
|
|
1118
|
+
//#endregion
|
|
1119
|
+
//#region src/adf/from-markdown.ts
|
|
1120
|
+
function mediaNode(source, alt) {
|
|
1121
|
+
const attrs = { ...source };
|
|
1122
|
+
if (alt) attrs["alt"] = alt;
|
|
1123
|
+
return {
|
|
1124
|
+
type: "mediaSingle",
|
|
1125
|
+
attrs: { layout: "center" },
|
|
1126
|
+
content: [{
|
|
1127
|
+
type: "media",
|
|
1128
|
+
attrs
|
|
1129
|
+
}]
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
function imageHrefs(md) {
|
|
1133
|
+
const hrefs = /* @__PURE__ */ new Set();
|
|
1134
|
+
marked.walkTokens(marked.lexer(md), (token) => {
|
|
1135
|
+
if (token.type === "image") hrefs.add(token.href);
|
|
1136
|
+
});
|
|
1137
|
+
return [...hrefs];
|
|
1138
|
+
}
|
|
1139
|
+
function markdownToAdf(md, options = {}) {
|
|
1140
|
+
const ctx = { resolveImage: options.resolveImage ?? externalMedia };
|
|
1141
|
+
return {
|
|
1142
|
+
type: "doc",
|
|
1143
|
+
version: 1,
|
|
1144
|
+
content: blocks(marked.lexer(md), ctx)
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
function blocks(tokens, ctx) {
|
|
1148
|
+
const out = [];
|
|
1149
|
+
for (const token of tokens) out.push(...block(token, ctx));
|
|
1150
|
+
return out;
|
|
1151
|
+
}
|
|
1152
|
+
function block(token, ctx) {
|
|
1153
|
+
switch (token.type) {
|
|
1154
|
+
case "space": return [];
|
|
684
1155
|
case "heading": return [{
|
|
685
1156
|
type: "heading",
|
|
686
1157
|
attrs: { level: clampLevel$1(token.depth) },
|
|
@@ -849,31 +1320,40 @@ function clampLevel$1(value) {
|
|
|
849
1320
|
return Math.min(6, Math.max(1, Math.trunc(value) || 1));
|
|
850
1321
|
}
|
|
851
1322
|
//#endregion
|
|
852
|
-
//#region src/
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1323
|
+
//#region src/api/confluence-attachments.ts
|
|
1324
|
+
const UNKNOWN_SIZE = -1;
|
|
1325
|
+
async function listAttachments(client, id) {
|
|
1326
|
+
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
|
|
1327
|
+
filename: a.title ?? a.id,
|
|
1328
|
+
fileId: mediaNodeId(a),
|
|
1329
|
+
size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
|
|
1330
|
+
}));
|
|
1331
|
+
}
|
|
1332
|
+
async function uploadAttachment(client, pageId, filename, bytes) {
|
|
1333
|
+
return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
|
|
1334
|
+
}
|
|
1335
|
+
async function fileIdByListing(client, pageId, filename) {
|
|
1336
|
+
const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
|
|
1337
|
+
if (match) return match.fileId;
|
|
1338
|
+
throw new Error(`Upload of "${filename}" did not return a fileId.`);
|
|
1339
|
+
}
|
|
1340
|
+
async function fetchAttachments(client, id) {
|
|
1341
|
+
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
|
|
1342
|
+
mediaId: mediaNodeId(a),
|
|
1343
|
+
filename: a.title ?? a.id,
|
|
1344
|
+
url: withWikiContextPath(a.downloadLink ?? "")
|
|
1345
|
+
}));
|
|
1346
|
+
}
|
|
1347
|
+
function mediaNodeId(a) {
|
|
1348
|
+
return a.fileId ?? a.id;
|
|
1349
|
+
}
|
|
1350
|
+
function withWikiContextPath(link) {
|
|
1351
|
+
if (link.startsWith("http") || link.startsWith("/wiki")) return link;
|
|
1352
|
+
return `/wiki${link}`;
|
|
1353
|
+
}
|
|
1354
|
+
//#endregion
|
|
1355
|
+
//#region src/api/confluence-pages.ts
|
|
1356
|
+
async function fetchPage(client, site, id) {
|
|
877
1357
|
const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
|
|
878
1358
|
const names = new UserNames(client);
|
|
879
1359
|
const [spaceKey, attachments, comments, author] = await Promise.all([
|
|
@@ -905,22 +1385,6 @@ async function fetchPageState(client, id) {
|
|
|
905
1385
|
body: parseAdfJson(page.body?.atlas_doc_format?.value)
|
|
906
1386
|
};
|
|
907
1387
|
}
|
|
908
|
-
const UNKNOWN_SIZE = -1;
|
|
909
|
-
async function listAttachments(client, id) {
|
|
910
|
-
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
|
|
911
|
-
filename: a.title ?? a.id,
|
|
912
|
-
fileId: mediaNodeId(a),
|
|
913
|
-
size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
|
|
914
|
-
}));
|
|
915
|
-
}
|
|
916
|
-
async function uploadAttachment(client, pageId, filename, bytes) {
|
|
917
|
-
return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
|
|
918
|
-
}
|
|
919
|
-
async function fileIdByListing(client, pageId, filename) {
|
|
920
|
-
const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
|
|
921
|
-
if (match) return match.fileId;
|
|
922
|
-
throw new Error(`Upload of "${filename}" did not return a fileId.`);
|
|
923
|
-
}
|
|
924
1388
|
async function updatePage(client, id, params) {
|
|
925
1389
|
return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
|
|
926
1390
|
id,
|
|
@@ -936,37 +1400,6 @@ async function updatePage(client, id, params) {
|
|
|
936
1400
|
}
|
|
937
1401
|
})).version?.number ?? params.nextVersion;
|
|
938
1402
|
}
|
|
939
|
-
async function searchPages(client, site, params) {
|
|
940
|
-
const cql = buildCql(params);
|
|
941
|
-
const query = new URLSearchParams({
|
|
942
|
-
cql,
|
|
943
|
-
limit: String(params.limit),
|
|
944
|
-
expand: "space"
|
|
945
|
-
});
|
|
946
|
-
const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
|
|
947
|
-
const serverPageWasFull = results.length === params.limit;
|
|
948
|
-
return {
|
|
949
|
-
pages: results.filter((r) => r.content?.id).map((r) => ({
|
|
950
|
-
id: r.content?.id ?? "",
|
|
951
|
-
space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
|
|
952
|
-
title: decodeEntities(r.content?.title ?? r.title ?? ""),
|
|
953
|
-
updated: r.lastModified ?? "",
|
|
954
|
-
url: r.url ? `${site}/wiki${r.url}` : ""
|
|
955
|
-
})),
|
|
956
|
-
hasMore: serverPageWasFull
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
function buildCql(params) {
|
|
960
|
-
if (params.cql) return params.cql;
|
|
961
|
-
const clauses = ["type = page"];
|
|
962
|
-
if (params.starred) clauses.push("favourite = currentUser()");
|
|
963
|
-
if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
|
|
964
|
-
if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
|
|
965
|
-
return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
|
|
966
|
-
}
|
|
967
|
-
function cqlStringLiteral(value) {
|
|
968
|
-
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
969
|
-
}
|
|
970
1403
|
async function fetchSpaceKey(client, spaceId) {
|
|
971
1404
|
if (!spaceId) return "";
|
|
972
1405
|
try {
|
|
@@ -975,16 +1408,6 @@ async function fetchSpaceKey(client, spaceId) {
|
|
|
975
1408
|
return "";
|
|
976
1409
|
}
|
|
977
1410
|
}
|
|
978
|
-
async function fetchAttachments(client, id) {
|
|
979
|
-
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
|
|
980
|
-
mediaId: mediaNodeId(a),
|
|
981
|
-
filename: a.title ?? a.id,
|
|
982
|
-
url: withWikiContextPath(a.downloadLink ?? "")
|
|
983
|
-
}));
|
|
984
|
-
}
|
|
985
|
-
function mediaNodeId(a) {
|
|
986
|
-
return a.fileId ?? a.id;
|
|
987
|
-
}
|
|
988
1411
|
async function fetchComments$1(client, id, names) {
|
|
989
1412
|
const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
|
|
990
1413
|
return Promise.all(res.results.map(async (c) => ({
|
|
@@ -993,10 +1416,6 @@ async function fetchComments$1(client, id, names) {
|
|
|
993
1416
|
body: parseAdfJson(c.body?.atlas_doc_format?.value)
|
|
994
1417
|
})));
|
|
995
1418
|
}
|
|
996
|
-
function withWikiContextPath(link) {
|
|
997
|
-
if (link.startsWith("http") || link.startsWith("/wiki")) return link;
|
|
998
|
-
return `/wiki${link}`;
|
|
999
|
-
}
|
|
1000
1419
|
function parseAdfJson(value) {
|
|
1001
1420
|
if (!value) return null;
|
|
1002
1421
|
try {
|
|
@@ -1028,10 +1447,67 @@ var UserNames = class {
|
|
|
1028
1447
|
}
|
|
1029
1448
|
};
|
|
1030
1449
|
//#endregion
|
|
1450
|
+
//#region src/util/html.ts
|
|
1451
|
+
const MAX_CODE_POINT = 1114111;
|
|
1452
|
+
const NAMED = {
|
|
1453
|
+
amp: "&",
|
|
1454
|
+
lt: "<",
|
|
1455
|
+
gt: ">",
|
|
1456
|
+
quot: "\"",
|
|
1457
|
+
apos: "'",
|
|
1458
|
+
nbsp: " "
|
|
1459
|
+
};
|
|
1460
|
+
function decodeEntities(text) {
|
|
1461
|
+
return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
|
|
1462
|
+
if (body[0] === "#") {
|
|
1463
|
+
const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
|
|
1464
|
+
return isCodePoint(code) ? String.fromCodePoint(code) : match;
|
|
1465
|
+
}
|
|
1466
|
+
return NAMED[body.toLowerCase()] ?? match;
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
function isCodePoint(code) {
|
|
1470
|
+
return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
|
|
1471
|
+
}
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region src/api/confluence-search.ts
|
|
1474
|
+
async function searchPages(client, site, params) {
|
|
1475
|
+
const cql = buildCql(params);
|
|
1476
|
+
const query = new URLSearchParams({
|
|
1477
|
+
cql,
|
|
1478
|
+
limit: String(params.limit),
|
|
1479
|
+
expand: "space"
|
|
1480
|
+
});
|
|
1481
|
+
const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
|
|
1482
|
+
const serverPageWasFull = results.length === params.limit;
|
|
1483
|
+
return {
|
|
1484
|
+
pages: results.filter((r) => r.content?.id).map((r) => ({
|
|
1485
|
+
id: r.content?.id ?? "",
|
|
1486
|
+
space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
|
|
1487
|
+
title: decodeEntities(r.content?.title ?? r.title ?? ""),
|
|
1488
|
+
updated: r.lastModified ?? "",
|
|
1489
|
+
url: r.url ? `${site}/wiki${r.url}` : ""
|
|
1490
|
+
})),
|
|
1491
|
+
hasMore: serverPageWasFull
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
function buildCql(params) {
|
|
1495
|
+
if (params.cql) return params.cql;
|
|
1496
|
+
const clauses = ["type = page"];
|
|
1497
|
+
if (params.starred) clauses.push("favourite = currentUser()");
|
|
1498
|
+
if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
|
|
1499
|
+
if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
|
|
1500
|
+
return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
|
|
1501
|
+
}
|
|
1502
|
+
function cqlStringLiteral(value) {
|
|
1503
|
+
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
1504
|
+
}
|
|
1505
|
+
//#endregion
|
|
1031
1506
|
//#region src/commands/resolve-ref.ts
|
|
1032
|
-
async function resolveRef(arg, kind) {
|
|
1033
|
-
const raw = arg ?? await
|
|
1507
|
+
async function resolveRef(ask, arg, kind) {
|
|
1508
|
+
const raw = arg ?? await ask.text({
|
|
1034
1509
|
message: kind.message,
|
|
1510
|
+
flag: kind.flag,
|
|
1035
1511
|
required: true
|
|
1036
1512
|
});
|
|
1037
1513
|
const ref = kind.parse(raw);
|
|
@@ -1039,94 +1515,69 @@ async function resolveRef(arg, kind) {
|
|
|
1039
1515
|
return ref;
|
|
1040
1516
|
}
|
|
1041
1517
|
//#endregion
|
|
1042
|
-
//#region src/
|
|
1043
|
-
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
return
|
|
1518
|
+
//#region src/adf/to-markdown-inline.ts
|
|
1519
|
+
function renderMedia(node, ctx) {
|
|
1520
|
+
const attrs = node.attrs ?? {};
|
|
1521
|
+
const alt = attrs.alt ?? "";
|
|
1522
|
+
const resolved = ctx.resolveMedia?.(attrs);
|
|
1523
|
+
if (resolved) return ``;
|
|
1524
|
+
return `[embedded media: ${alt || attrs.id || "media"}]`;
|
|
1048
1525
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
function alignedRows(items, nowMs, cells) {
|
|
1052
|
-
const rows = items.map((item) => ({
|
|
1053
|
-
...cells(item),
|
|
1054
|
-
age: relativeTime(item.updated, nowMs),
|
|
1055
|
-
json: { ...item }
|
|
1056
|
-
}));
|
|
1057
|
-
const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
|
|
1058
|
-
const idWidth = width((row) => row.id);
|
|
1059
|
-
const labelWidth = width((row) => row.label);
|
|
1060
|
-
const ageWidth = width((row) => row.age);
|
|
1061
|
-
return rows.map((row) => ({
|
|
1062
|
-
id: row.id,
|
|
1063
|
-
url: row.url,
|
|
1064
|
-
fixedColumns: `${row.id.padEnd(idWidth)} ${row.color(row.label.padEnd(labelWidth))} ${row.age.padEnd(ageWidth)}`,
|
|
1065
|
-
freeText: row.text,
|
|
1066
|
-
json: row.json
|
|
1067
|
-
}));
|
|
1526
|
+
function renderInline(nodes, ctx) {
|
|
1527
|
+
return nodes.map((n) => renderInlineNode(n, ctx)).join("");
|
|
1068
1528
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
return;
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1529
|
+
function renderInlineNode(node, ctx) {
|
|
1530
|
+
switch (node.type) {
|
|
1531
|
+
case "text": return applyMarks(node.text ?? "", node.marks ?? []);
|
|
1532
|
+
case "hardBreak": return " \n";
|
|
1533
|
+
case "mention": return (typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : "") || "@unknown";
|
|
1534
|
+
case "emoji": {
|
|
1535
|
+
const text = node.attrs?.["text"];
|
|
1536
|
+
if (typeof text === "string" && text.length > 0) return text;
|
|
1537
|
+
const short = node.attrs?.["shortName"];
|
|
1538
|
+
return typeof short === "string" ? short : "";
|
|
1539
|
+
}
|
|
1540
|
+
case "date": return formatDate(node.attrs?.["timestamp"]);
|
|
1541
|
+
case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
|
|
1542
|
+
case "inlineCard": {
|
|
1543
|
+
const url = node.attrs?.["url"];
|
|
1544
|
+
if (typeof url === "string") return `[${url}](${url})`;
|
|
1545
|
+
return "";
|
|
1546
|
+
}
|
|
1547
|
+
case "media": return renderMedia(node, ctx);
|
|
1548
|
+
default: return node.text ?? "";
|
|
1083
1549
|
}
|
|
1084
|
-
for (const row of rows) console.log(formatRow(row));
|
|
1085
|
-
if (options.footer) console.log(options.footer);
|
|
1086
|
-
}
|
|
1087
|
-
function searchFooter(limit) {
|
|
1088
|
-
return `
|
|
1089
|
-
Showing first ${limit}; refine with flags or raise --limit.`;
|
|
1090
1550
|
}
|
|
1091
|
-
|
|
1092
|
-
if (
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
} catch (err) {
|
|
1113
|
-
failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
|
|
1551
|
+
function applyMarks(text, marks) {
|
|
1552
|
+
if (text.length === 0) return text;
|
|
1553
|
+
let out = text;
|
|
1554
|
+
let href;
|
|
1555
|
+
for (const mark of marks) switch (mark.type) {
|
|
1556
|
+
case "code":
|
|
1557
|
+
out = `\`${out}\``;
|
|
1558
|
+
break;
|
|
1559
|
+
case "strong":
|
|
1560
|
+
out = `**${out}**`;
|
|
1561
|
+
break;
|
|
1562
|
+
case "em":
|
|
1563
|
+
out = `*${out}*`;
|
|
1564
|
+
break;
|
|
1565
|
+
case "strike":
|
|
1566
|
+
out = `~~${out}~~`;
|
|
1567
|
+
break;
|
|
1568
|
+
case "link": {
|
|
1569
|
+
const value = mark.attrs?.["href"];
|
|
1570
|
+
if (typeof value === "string") href = value;
|
|
1571
|
+
break;
|
|
1114
1572
|
}
|
|
1115
1573
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
if (failures.length === 0) console.log(summary);
|
|
1119
|
-
else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
|
|
1120
|
-
}
|
|
1121
|
-
function formatRow(row, width = process.stdout.columns ?? 80) {
|
|
1122
|
-
const room = width - stripVTControlCharacters(row.fixedColumns).length - 2;
|
|
1123
|
-
const text = room > 0 ? truncate(row.freeText, room) : "";
|
|
1124
|
-
const columns = hyperlink(row.id, row.url) + row.fixedColumns.slice(row.id.length);
|
|
1125
|
-
return text ? `${columns} ${hyperlink(text, row.url)}` : columns;
|
|
1574
|
+
if (href) out = `[${out}](${href})`;
|
|
1575
|
+
return out;
|
|
1126
1576
|
}
|
|
1127
|
-
function
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1577
|
+
function formatDate(timestamp) {
|
|
1578
|
+
const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
|
|
1579
|
+
if (Number.isNaN(ms)) return "";
|
|
1580
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
1130
1581
|
}
|
|
1131
1582
|
//#endregion
|
|
1132
1583
|
//#region src/adf/to-markdown.ts
|
|
@@ -1196,7 +1647,8 @@ const PANEL_LABELS = {
|
|
|
1196
1647
|
error: "Error"
|
|
1197
1648
|
};
|
|
1198
1649
|
function renderPanel(node, ctx, indent) {
|
|
1199
|
-
|
|
1650
|
+
const type = typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info";
|
|
1651
|
+
return prefixLines(`**${PANEL_LABELS[type] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
|
|
1200
1652
|
}
|
|
1201
1653
|
function renderExpand(node, ctx, indent) {
|
|
1202
1654
|
return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
|
|
@@ -1219,65 +1671,6 @@ function renderTable(node, ctx) {
|
|
|
1219
1671
|
function renderCellOnOneLine(cell, ctx) {
|
|
1220
1672
|
return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
|
|
1221
1673
|
}
|
|
1222
|
-
function renderMedia(node, ctx) {
|
|
1223
|
-
const attrs = node.attrs ?? {};
|
|
1224
|
-
const alt = attrs.alt ?? "";
|
|
1225
|
-
const resolved = ctx.resolveMedia?.(attrs);
|
|
1226
|
-
if (resolved) return ``;
|
|
1227
|
-
return `[embedded media: ${alt || attrs.id || "media"}]`;
|
|
1228
|
-
}
|
|
1229
|
-
function renderInline(nodes, ctx) {
|
|
1230
|
-
return nodes.map((n) => renderInlineNode(n, ctx)).join("");
|
|
1231
|
-
}
|
|
1232
|
-
function renderInlineNode(node, ctx) {
|
|
1233
|
-
switch (node.type) {
|
|
1234
|
-
case "text": return applyMarks(node.text ?? "", node.marks ?? []);
|
|
1235
|
-
case "hardBreak": return " \n";
|
|
1236
|
-
case "mention": return (typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : "") || "@unknown";
|
|
1237
|
-
case "emoji": {
|
|
1238
|
-
const text = node.attrs?.["text"];
|
|
1239
|
-
if (typeof text === "string" && text.length > 0) return text;
|
|
1240
|
-
const short = node.attrs?.["shortName"];
|
|
1241
|
-
return typeof short === "string" ? short : "";
|
|
1242
|
-
}
|
|
1243
|
-
case "date": return formatDate(node.attrs?.["timestamp"]);
|
|
1244
|
-
case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
|
|
1245
|
-
case "inlineCard": {
|
|
1246
|
-
const url = node.attrs?.["url"];
|
|
1247
|
-
if (typeof url === "string") return `[${url}](${url})`;
|
|
1248
|
-
return "";
|
|
1249
|
-
}
|
|
1250
|
-
case "media": return renderMedia(node, ctx);
|
|
1251
|
-
default: return node.text ?? "";
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
function applyMarks(text, marks) {
|
|
1255
|
-
if (text.length === 0) return text;
|
|
1256
|
-
let out = text;
|
|
1257
|
-
let href;
|
|
1258
|
-
for (const mark of marks) switch (mark.type) {
|
|
1259
|
-
case "code":
|
|
1260
|
-
out = `\`${out}\``;
|
|
1261
|
-
break;
|
|
1262
|
-
case "strong":
|
|
1263
|
-
out = `**${out}**`;
|
|
1264
|
-
break;
|
|
1265
|
-
case "em":
|
|
1266
|
-
out = `*${out}*`;
|
|
1267
|
-
break;
|
|
1268
|
-
case "strike":
|
|
1269
|
-
out = `~~${out}~~`;
|
|
1270
|
-
break;
|
|
1271
|
-
case "link": {
|
|
1272
|
-
const value = mark.attrs?.["href"];
|
|
1273
|
-
if (typeof value === "string") href = value;
|
|
1274
|
-
break;
|
|
1275
|
-
}
|
|
1276
|
-
default: break;
|
|
1277
|
-
}
|
|
1278
|
-
if (href) out = `[${out}](${href})`;
|
|
1279
|
-
return out;
|
|
1280
|
-
}
|
|
1281
1674
|
function prefixLines(text, prefix) {
|
|
1282
1675
|
return text.split("\n").map((line) => line.length > 0 ? prefix + line : prefix.trimEnd()).join("\n");
|
|
1283
1676
|
}
|
|
@@ -1288,11 +1681,6 @@ function clampLevel(value) {
|
|
|
1288
1681
|
function toNumber(value) {
|
|
1289
1682
|
return typeof value === "number" ? value : void 0;
|
|
1290
1683
|
}
|
|
1291
|
-
function formatDate(timestamp) {
|
|
1292
|
-
const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
|
|
1293
|
-
if (Number.isNaN(ms)) return "";
|
|
1294
|
-
return new Date(ms).toISOString().slice(0, 10);
|
|
1295
|
-
}
|
|
1296
1684
|
//#endregion
|
|
1297
1685
|
//#region src/markdown/highlight.ts
|
|
1298
1686
|
const HEADING = /^#{1,6} /;
|
|
@@ -1546,9 +1934,10 @@ function scalarString(value) {
|
|
|
1546
1934
|
}
|
|
1547
1935
|
function frontmatter(fields) {
|
|
1548
1936
|
const lines = ["---"];
|
|
1549
|
-
for (const [key, value] of Object.entries(fields)) if (Array.isArray(value))
|
|
1550
|
-
|
|
1551
|
-
|
|
1937
|
+
for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) {
|
|
1938
|
+
if (value.length === 0) lines.push(`${key}: []`);
|
|
1939
|
+
else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
|
|
1940
|
+
} else if (typeof value === "number") lines.push(`${key}: ${value}`);
|
|
1552
1941
|
else lines.push(`${key}: ${quote(value)}`);
|
|
1553
1942
|
lines.push("---");
|
|
1554
1943
|
return lines.join("\n");
|
|
@@ -1594,21 +1983,23 @@ function leadingH1(lines) {
|
|
|
1594
1983
|
}
|
|
1595
1984
|
function commentsSection(comments) {
|
|
1596
1985
|
if (comments.length === 0) return "";
|
|
1986
|
+
const blocks = comments.map((c) => {
|
|
1987
|
+
const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
|
|
1988
|
+
return c.body ? `${heading}\n\n${c.body}` : heading;
|
|
1989
|
+
});
|
|
1597
1990
|
return [
|
|
1598
1991
|
COMMENTS_HEADING,
|
|
1599
1992
|
"",
|
|
1600
|
-
|
|
1601
|
-
const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
|
|
1602
|
-
return c.body ? `${heading}\n\n${c.body}` : heading;
|
|
1603
|
-
}).join("\n\n")
|
|
1993
|
+
blocks.join("\n\n")
|
|
1604
1994
|
].join("\n");
|
|
1605
1995
|
}
|
|
1606
1996
|
function attachmentsSection(attachments) {
|
|
1607
1997
|
if (attachments.length === 0) return "";
|
|
1998
|
+
const items = attachments.map((a) => `- [${a.filename}](${a.relativePath})`);
|
|
1608
1999
|
return [
|
|
1609
2000
|
ATTACHMENTS_HEADING,
|
|
1610
2001
|
"",
|
|
1611
|
-
...
|
|
2002
|
+
...items
|
|
1612
2003
|
].join("\n");
|
|
1613
2004
|
}
|
|
1614
2005
|
//#endregion
|
|
@@ -1673,7 +2064,6 @@ function planCopy(source, out) {
|
|
|
1673
2064
|
return {
|
|
1674
2065
|
...content,
|
|
1675
2066
|
filePath: target.filePath,
|
|
1676
|
-
assetsDir: target.assetsDir,
|
|
1677
2067
|
downloads: planDownloads(attachments, target)
|
|
1678
2068
|
};
|
|
1679
2069
|
}
|
|
@@ -1719,21 +2109,18 @@ function numberedName(name, suffix) {
|
|
|
1719
2109
|
}
|
|
1720
2110
|
//#endregion
|
|
1721
2111
|
//#region src/copy/run.ts
|
|
1722
|
-
async function runCopy(
|
|
1723
|
-
const landed = await download(
|
|
1724
|
-
await
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
}
|
|
1728
|
-
async function download(plan, fetchBytes) {
|
|
1729
|
-
if (plan.downloads.length === 0) return [];
|
|
1730
|
-
await mkdir(plan.assetsDir, { recursive: true });
|
|
2112
|
+
async function runCopy(env, plan) {
|
|
2113
|
+
const landed = await download(env, plan);
|
|
2114
|
+
await env.files.writeText(plan.filePath, renderCopy(plan, landed));
|
|
2115
|
+
env.term.out(wroteLine(plan.filePath, landed.length));
|
|
2116
|
+
}
|
|
2117
|
+
async function download({ term, files, session }, plan) {
|
|
1731
2118
|
const landed = [];
|
|
1732
2119
|
for (const { path, ...attachment } of plan.downloads) try {
|
|
1733
|
-
await
|
|
2120
|
+
await files.writeBytes(path, await session.getBinary(attachment.url));
|
|
1734
2121
|
landed.push(attachment);
|
|
1735
2122
|
} catch (err) {
|
|
1736
|
-
|
|
2123
|
+
term.err(` ! could not download ${attachment.filename}: ${err.message}`);
|
|
1737
2124
|
}
|
|
1738
2125
|
return landed;
|
|
1739
2126
|
}
|
|
@@ -1792,6 +2179,82 @@ function planIssueUpdate(source, issue, options) {
|
|
|
1792
2179
|
refusals
|
|
1793
2180
|
}, options.force ?? false);
|
|
1794
2181
|
}
|
|
2182
|
+
function formatPlan(plan) {
|
|
2183
|
+
const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
|
|
2184
|
+
if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
|
|
2185
|
+
const counts = imageCounts(plan.images);
|
|
2186
|
+
if (counts) lines.push(row("images", counts));
|
|
2187
|
+
for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
|
|
2188
|
+
if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
|
|
2189
|
+
if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
|
|
2190
|
+
lines.push(" nothing was written (dry run)");
|
|
2191
|
+
return lines;
|
|
2192
|
+
}
|
|
2193
|
+
const IMAGE_COUNT_LABELS = [
|
|
2194
|
+
["upload", "new"],
|
|
2195
|
+
["changed", "changed"],
|
|
2196
|
+
["reuse", "reused"],
|
|
2197
|
+
["external", "external"],
|
|
2198
|
+
["unsupported", "unsupported"],
|
|
2199
|
+
["missing", "missing"]
|
|
2200
|
+
];
|
|
2201
|
+
function imageCounts(images) {
|
|
2202
|
+
return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
|
|
2203
|
+
n: images.filter((i) => i.kind === kind).length,
|
|
2204
|
+
label
|
|
2205
|
+
})).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
|
|
2206
|
+
}
|
|
2207
|
+
function row(label, value) {
|
|
2208
|
+
return ` ${`${label}:`.padEnd(9)}${value}`;
|
|
2209
|
+
}
|
|
2210
|
+
function headline(label, current, title, useTitle) {
|
|
2211
|
+
return {
|
|
2212
|
+
label,
|
|
2213
|
+
current,
|
|
2214
|
+
next: useTitle && title ? title : current
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
function revision(local, server) {
|
|
2218
|
+
return {
|
|
2219
|
+
local: local || "unknown",
|
|
2220
|
+
server: server || "unknown",
|
|
2221
|
+
stale: local !== server
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
function revisionText(revision) {
|
|
2225
|
+
return `copied at ${revision.local}, server now ${revision.server}`;
|
|
2226
|
+
}
|
|
2227
|
+
function emptyBodyRefusal(body) {
|
|
2228
|
+
if (body.content && body.content.length > 0) return [];
|
|
2229
|
+
return ["Refusing to update: the converted body is empty."];
|
|
2230
|
+
}
|
|
2231
|
+
function withVerdict(plan, force) {
|
|
2232
|
+
return {
|
|
2233
|
+
...plan,
|
|
2234
|
+
verdict: verdict(plan, force)
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
function verdict(plan, force) {
|
|
2238
|
+
if (plan.refusals.length > 0) return {
|
|
2239
|
+
kind: "refuse",
|
|
2240
|
+
message: plan.refusals.join("\n")
|
|
2241
|
+
};
|
|
2242
|
+
const noun = plan.noun;
|
|
2243
|
+
if (plan.revision.stale && !force) return {
|
|
2244
|
+
kind: "refuse",
|
|
2245
|
+
message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
|
|
2246
|
+
};
|
|
2247
|
+
if (plan.lossy.size > 0 && !force) return {
|
|
2248
|
+
kind: "confirm",
|
|
2249
|
+
message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
|
|
2250
|
+
};
|
|
2251
|
+
return { kind: "proceed" };
|
|
2252
|
+
}
|
|
2253
|
+
function capitalize(text) {
|
|
2254
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
2255
|
+
}
|
|
2256
|
+
//#endregion
|
|
2257
|
+
//#region src/update/plan-page.ts
|
|
1795
2258
|
function planPageUpdate(source, state, attachments, localImages, options) {
|
|
1796
2259
|
const collection = `contentId-${source.id}`;
|
|
1797
2260
|
const entries = imageHrefs(source.body).map((href) => pageImage(href, localImages, attachments));
|
|
@@ -1872,156 +2335,33 @@ function withUploadedId(node, ids) {
|
|
|
1872
2335
|
content: swapped.content.map((n) => withUploadedId(n, ids))
|
|
1873
2336
|
};
|
|
1874
2337
|
}
|
|
1875
|
-
function formatPlan(plan) {
|
|
1876
|
-
const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
|
|
1877
|
-
if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
|
|
1878
|
-
const counts = imageCounts(plan.images);
|
|
1879
|
-
if (counts) lines.push(row("images", counts));
|
|
1880
|
-
for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
|
|
1881
|
-
if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
|
|
1882
|
-
if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
|
|
1883
|
-
lines.push(" nothing was written (dry run)");
|
|
1884
|
-
return lines;
|
|
1885
|
-
}
|
|
1886
|
-
const IMAGE_COUNT_LABELS = [
|
|
1887
|
-
["upload", "new"],
|
|
1888
|
-
["changed", "changed"],
|
|
1889
|
-
["reuse", "reused"],
|
|
1890
|
-
["external", "external"],
|
|
1891
|
-
["unsupported", "unsupported"],
|
|
1892
|
-
["missing", "missing"]
|
|
1893
|
-
];
|
|
1894
|
-
function imageCounts(images) {
|
|
1895
|
-
return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
|
|
1896
|
-
n: images.filter((i) => i.kind === kind).length,
|
|
1897
|
-
label
|
|
1898
|
-
})).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
|
|
1899
|
-
}
|
|
1900
|
-
function row(label, value) {
|
|
1901
|
-
return ` ${`${label}:`.padEnd(9)}${value}`;
|
|
1902
|
-
}
|
|
1903
|
-
function headline(label, current, title, useTitle) {
|
|
1904
|
-
return {
|
|
1905
|
-
label,
|
|
1906
|
-
current,
|
|
1907
|
-
next: useTitle && title ? title : current
|
|
1908
|
-
};
|
|
1909
|
-
}
|
|
1910
|
-
function revision(local, server) {
|
|
1911
|
-
return {
|
|
1912
|
-
local: local || "unknown",
|
|
1913
|
-
server: server || "unknown",
|
|
1914
|
-
stale: local !== server
|
|
1915
|
-
};
|
|
1916
|
-
}
|
|
1917
|
-
function revisionText(revision) {
|
|
1918
|
-
return `copied at ${revision.local}, server now ${revision.server}`;
|
|
1919
|
-
}
|
|
1920
|
-
function emptyBodyRefusal(body) {
|
|
1921
|
-
if (body.content && body.content.length > 0) return [];
|
|
1922
|
-
return ["Refusing to update: the converted body is empty."];
|
|
1923
|
-
}
|
|
1924
|
-
function withVerdict(plan, force) {
|
|
1925
|
-
return {
|
|
1926
|
-
...plan,
|
|
1927
|
-
verdict: verdict(plan, force)
|
|
1928
|
-
};
|
|
1929
|
-
}
|
|
1930
|
-
function verdict(plan, force) {
|
|
1931
|
-
if (plan.refusals.length > 0) return {
|
|
1932
|
-
kind: "refuse",
|
|
1933
|
-
message: plan.refusals.join("\n")
|
|
1934
|
-
};
|
|
1935
|
-
const noun = plan.noun;
|
|
1936
|
-
if (plan.revision.stale && !force) return {
|
|
1937
|
-
kind: "refuse",
|
|
1938
|
-
message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
|
|
1939
|
-
};
|
|
1940
|
-
if (plan.lossy.size > 0 && !force) return {
|
|
1941
|
-
kind: "confirm",
|
|
1942
|
-
message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
|
|
1943
|
-
};
|
|
1944
|
-
return { kind: "proceed" };
|
|
1945
|
-
}
|
|
1946
|
-
function capitalize(text) {
|
|
1947
|
-
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
1948
|
-
}
|
|
1949
2338
|
//#endregion
|
|
1950
2339
|
//#region src/update/run.ts
|
|
1951
|
-
async function runPlan(plan, options, push) {
|
|
2340
|
+
async function runPlan(term, plan, options, push) {
|
|
1952
2341
|
if (options.dryRun) {
|
|
1953
|
-
|
|
2342
|
+
term.out(formatPlan(plan));
|
|
1954
2343
|
return;
|
|
1955
2344
|
}
|
|
1956
2345
|
const verdict = plan.verdict;
|
|
1957
2346
|
if (verdict.kind === "refuse") throw new Error(verdict.message);
|
|
1958
2347
|
if (verdict.kind === "confirm") {
|
|
1959
|
-
if (!await confirm({
|
|
2348
|
+
if (!await term.ask.confirm({
|
|
1960
2349
|
message: verdict.message,
|
|
2350
|
+
flag: "--force",
|
|
1961
2351
|
default: false
|
|
1962
2352
|
})) {
|
|
1963
|
-
|
|
2353
|
+
term.out("Aborted.");
|
|
1964
2354
|
return;
|
|
1965
2355
|
}
|
|
1966
2356
|
}
|
|
1967
2357
|
await push();
|
|
1968
2358
|
}
|
|
1969
2359
|
//#endregion
|
|
1970
|
-
//#region src/util/pager.ts
|
|
1971
|
-
const DEFAULT_ROWS = 24;
|
|
1972
|
-
const DEFAULT_COLUMNS = 80;
|
|
1973
|
-
const DEFAULT_PAGER = "less";
|
|
1974
|
-
const DEFAULT_LESS = "FRX";
|
|
1975
|
-
function renderedHeight(text, columns) {
|
|
1976
|
-
const width = Math.max(1, columns);
|
|
1977
|
-
return text.split("\n").map((line) => Math.max(1, Math.ceil(stripVTControlCharacters(line).length / width))).reduce((sum, rows) => sum + rows, 0);
|
|
1978
|
-
}
|
|
1979
|
-
function shouldPage(text, term) {
|
|
1980
|
-
if (!term.isTTY) return false;
|
|
1981
|
-
const rows = term.rows ?? DEFAULT_ROWS;
|
|
1982
|
-
return renderedHeight(text, term.columns ?? DEFAULT_COLUMNS) > rows;
|
|
1983
|
-
}
|
|
1984
|
-
function pagerCommand(env) {
|
|
1985
|
-
const [command = DEFAULT_PAGER, ...args] = (env["PAGER"] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
1986
|
-
return {
|
|
1987
|
-
command,
|
|
1988
|
-
args,
|
|
1989
|
-
env: {
|
|
1990
|
-
...env,
|
|
1991
|
-
LESS: env["LESS"] ?? DEFAULT_LESS
|
|
1992
|
-
}
|
|
1993
|
-
};
|
|
1994
|
-
}
|
|
1995
|
-
async function printPaged(text, options = {}) {
|
|
1996
|
-
const term = options.term ?? process.stdout;
|
|
1997
|
-
if (options.pager === false || !shouldPage(text, term)) {
|
|
1998
|
-
console.log(text);
|
|
1999
|
-
return;
|
|
2000
|
-
}
|
|
2001
|
-
if (!await pipeToPager(text, pagerCommand(options.env ?? process.env))) console.log(text);
|
|
2002
|
-
}
|
|
2003
|
-
function pipeToPager(text, pager) {
|
|
2004
|
-
return new Promise((resolve) => {
|
|
2005
|
-
const child = spawn(pager.command, pager.args, {
|
|
2006
|
-
stdio: [
|
|
2007
|
-
"pipe",
|
|
2008
|
-
"inherit",
|
|
2009
|
-
"inherit"
|
|
2010
|
-
],
|
|
2011
|
-
env: pager.env
|
|
2012
|
-
});
|
|
2013
|
-
child.once("error", () => resolve(false));
|
|
2014
|
-
child.once("close", () => resolve(true));
|
|
2015
|
-
child.stdin.once("error", () => {});
|
|
2016
|
-
child.stdin.end(`${text}\n`);
|
|
2017
|
-
});
|
|
2018
|
-
}
|
|
2019
|
-
//#endregion
|
|
2020
2360
|
//#region src/commands/confluence.ts
|
|
2021
|
-
async function confluenceView(arg, options) {
|
|
2022
|
-
const
|
|
2023
|
-
const
|
|
2024
|
-
await
|
|
2361
|
+
async function confluenceView({ session, term }, arg, options) {
|
|
2362
|
+
const id = await resolveRef(term.ask, arg, PAGE_REF);
|
|
2363
|
+
const lines = formatPageView(await fetchPage(session, session.site, id), Date.now(), options.allComments ?? false);
|
|
2364
|
+
await term.page(lines.join("\n"), { pager: options.pager });
|
|
2025
2365
|
}
|
|
2026
2366
|
function formatPageView(page, nowMs, allComments) {
|
|
2027
2367
|
return [
|
|
@@ -2040,37 +2380,35 @@ function formatPageView(page, nowMs, allComments) {
|
|
|
2040
2380
|
...attachmentSection(page.attachments)
|
|
2041
2381
|
];
|
|
2042
2382
|
}
|
|
2043
|
-
async function confluenceCopy(arg, options) {
|
|
2044
|
-
|
|
2045
|
-
const id = await resolveRef(arg, PAGE_REF);
|
|
2046
|
-
await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
|
|
2383
|
+
async function confluenceCopy(env, arg, options) {
|
|
2384
|
+
await copyPage(env, await resolveRef(env.term.ask, arg, PAGE_REF), options.out);
|
|
2047
2385
|
}
|
|
2048
|
-
async function confluenceUpdate(arg, options) {
|
|
2049
|
-
const file = arg ?? await
|
|
2386
|
+
async function confluenceUpdate({ session, term, files }, arg, options) {
|
|
2387
|
+
const file = arg ?? await term.ask.text({
|
|
2050
2388
|
message: "Path to the page Markdown file:",
|
|
2389
|
+
flag: "[file]",
|
|
2051
2390
|
required: true
|
|
2052
2391
|
});
|
|
2053
|
-
const src = parsePageSource(await
|
|
2054
|
-
const
|
|
2055
|
-
const
|
|
2056
|
-
|
|
2057
|
-
await runPlan(plan, options, async () => {
|
|
2392
|
+
const src = parsePageSource(await files.readText(file));
|
|
2393
|
+
const state = await fetchPageState(session, src.id);
|
|
2394
|
+
const plan = planPageUpdate(src, state, await listAttachments(session, src.id), await statLocalImages(files, dirname(resolve(file)), src.body), options);
|
|
2395
|
+
await runPlan(term, plan, options, async () => {
|
|
2058
2396
|
const ids = /* @__PURE__ */ new Map();
|
|
2059
2397
|
for (const upload of plan.uploads) {
|
|
2060
|
-
|
|
2061
|
-
const bytes = await
|
|
2062
|
-
ids.set(upload.href, await uploadAttachment(
|
|
2398
|
+
term.err(`Uploading ${upload.filename} ...`);
|
|
2399
|
+
const bytes = await files.readBytes(upload.path);
|
|
2400
|
+
ids.set(upload.href, await uploadAttachment(session, src.id, upload.filename, bytes));
|
|
2063
2401
|
}
|
|
2064
|
-
const version = await updatePage(
|
|
2402
|
+
const version = await updatePage(session, src.id, {
|
|
2065
2403
|
title: plan.headline.next,
|
|
2066
2404
|
nextVersion: state.version + 1,
|
|
2067
2405
|
body: withUploadedIds(plan.body, ids),
|
|
2068
2406
|
message: options.message ?? "Updated via atlass"
|
|
2069
2407
|
});
|
|
2070
|
-
|
|
2408
|
+
term.out(`Updated page ${src.id} to version ${version}.`);
|
|
2071
2409
|
});
|
|
2072
2410
|
}
|
|
2073
|
-
async function statLocalImages(dir, md) {
|
|
2411
|
+
async function statLocalImages(files, dir, md) {
|
|
2074
2412
|
const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
|
|
2075
2413
|
return Promise.all(hrefs.map(async (href) => {
|
|
2076
2414
|
const path = isAbsolute(href) ? href : resolve(dir, href);
|
|
@@ -2078,291 +2416,101 @@ async function statLocalImages(dir, md) {
|
|
|
2078
2416
|
href,
|
|
2079
2417
|
path,
|
|
2080
2418
|
filename: basename(path),
|
|
2081
|
-
...await fileSize(path)
|
|
2419
|
+
...await fileSize(files, path)
|
|
2082
2420
|
};
|
|
2083
2421
|
}));
|
|
2084
2422
|
}
|
|
2085
|
-
async function fileSize(path) {
|
|
2423
|
+
async function fileSize(files, path) {
|
|
2086
2424
|
try {
|
|
2087
|
-
return { size:
|
|
2425
|
+
return { size: await files.size(path) };
|
|
2088
2426
|
} catch {
|
|
2089
2427
|
return {};
|
|
2090
2428
|
}
|
|
2091
2429
|
}
|
|
2092
|
-
async function confluenceSearch(query, options) {
|
|
2430
|
+
async function confluenceSearch(env, query, options) {
|
|
2093
2431
|
if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
|
|
2094
|
-
await listPages({
|
|
2432
|
+
await listPages(env, {
|
|
2095
2433
|
text: query,
|
|
2096
2434
|
space: options.space,
|
|
2097
2435
|
cql: options.cql
|
|
2098
2436
|
}, "No matching pages.", options);
|
|
2099
2437
|
}
|
|
2100
|
-
async function confluenceList(options) {
|
|
2101
|
-
await listPages({
|
|
2438
|
+
async function confluenceList(env, options) {
|
|
2439
|
+
await listPages(env, {
|
|
2102
2440
|
starred: true,
|
|
2103
|
-
space: options.space
|
|
2104
|
-
}, options.space ? `No starred pages in ${options.space}.` : "No starred pages.", options);
|
|
2105
|
-
}
|
|
2106
|
-
async function listPages(filter, empty, options) {
|
|
2107
|
-
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
2108
|
-
const auth = await requireAuth();
|
|
2109
|
-
const client = new AtlassianClient(auth);
|
|
2110
|
-
const limit = parseLimit(options.limit);
|
|
2111
|
-
const { pages, hasMore } = await searchPages(client, auth.site, {
|
|
2112
|
-
...filter,
|
|
2113
|
-
limit
|
|
2114
|
-
});
|
|
2115
|
-
await runSearch(formatPageRows(pages, Date.now()), {
|
|
2116
|
-
json: options.json,
|
|
2117
|
-
copy: options.copy,
|
|
2118
|
-
out: options.out,
|
|
2119
|
-
empty,
|
|
2120
|
-
footer: hasMore ? searchFooter(limit) : void 0
|
|
2121
|
-
}, PAGE_NOUN, (id) => copyPage(client, auth.site, id, options.out));
|
|
2122
|
-
}
|
|
2123
|
-
function formatPageRows(pages, nowMs) {
|
|
2124
|
-
return alignedRows(pages, nowMs, (p) => ({
|
|
2125
|
-
id: p.id,
|
|
2126
|
-
url: p.url,
|
|
2127
|
-
label: p.space,
|
|
2128
|
-
color: colorForSpace(p.space),
|
|
2129
|
-
text: p.title
|
|
2130
|
-
}));
|
|
2131
|
-
}
|
|
2132
|
-
const SPACE_COLORS = [
|
|
2133
|
-
kleur.cyan,
|
|
2134
|
-
kleur.yellow,
|
|
2135
|
-
kleur.green,
|
|
2136
|
-
kleur.magenta,
|
|
2137
|
-
kleur.blue
|
|
2138
|
-
];
|
|
2139
|
-
function colorForSpace(space) {
|
|
2140
|
-
let hash = 0;
|
|
2141
|
-
for (const char of space) hash = hash * 31 + (char.codePointAt(0) ?? 0) >>> 0;
|
|
2142
|
-
return SPACE_COLORS[hash % SPACE_COLORS.length] ?? kleur.cyan;
|
|
2143
|
-
}
|
|
2144
|
-
const PAGE_NOUN = {
|
|
2145
|
-
singular: "page",
|
|
2146
|
-
plural: "pages"
|
|
2147
|
-
};
|
|
2148
|
-
async function copyPage(client, site, id, out) {
|
|
2149
|
-
console.log(`Fetching page ${id} ...`);
|
|
2150
|
-
await runCopy(planPageCopy(await fetchPage(client, site, id), out), (url) => client.getBinary(url));
|
|
2151
|
-
}
|
|
2152
|
-
const PAGE_REF = {
|
|
2153
|
-
message: "Confluence page id or URL:",
|
|
2154
|
-
parse: parsePageId,
|
|
2155
|
-
notFound: (raw) => `Could not find a page id in "${raw}".`
|
|
2156
|
-
};
|
|
2157
|
-
//#endregion
|
|
2158
|
-
//#region src/cli/confluence.ts
|
|
2159
|
-
function registerConfluence(confluence) {
|
|
2160
|
-
confluence.description("Confluence commands");
|
|
2161
|
-
confluence.command("view [page]").description("Show a Confluence page (id or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(run(confluenceView));
|
|
2162
|
-
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));
|
|
2163
|
-
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));
|
|
2164
|
-
confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space").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(confluenceList));
|
|
2165
|
-
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));
|
|
2166
|
-
return confluence;
|
|
2167
|
-
}
|
|
2168
|
-
//#endregion
|
|
2169
|
-
//#region src/api/jira.ts
|
|
2170
|
-
const FIELDS = [
|
|
2171
|
-
"summary",
|
|
2172
|
-
"description",
|
|
2173
|
-
"issuetype",
|
|
2174
|
-
"status",
|
|
2175
|
-
"assignee",
|
|
2176
|
-
"reporter",
|
|
2177
|
-
"priority",
|
|
2178
|
-
"labels",
|
|
2179
|
-
"created",
|
|
2180
|
-
"updated",
|
|
2181
|
-
"attachment"
|
|
2182
|
-
].join(",");
|
|
2183
|
-
const RECENT_ISSUES_CLAUSE = "updated >= -30d";
|
|
2184
|
-
function browseUrl(site, key) {
|
|
2185
|
-
return `${site}/browse/${key}`;
|
|
2186
|
-
}
|
|
2187
|
-
async function fetchIssue(client, site, key) {
|
|
2188
|
-
const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
|
|
2189
|
-
const comments = await fetchComments(client, key);
|
|
2190
|
-
const f = issue.fields;
|
|
2191
|
-
return {
|
|
2192
|
-
key: issue.key,
|
|
2193
|
-
url: browseUrl(site, issue.key),
|
|
2194
|
-
summary: f.summary ?? "",
|
|
2195
|
-
type: f.issuetype?.name ?? "",
|
|
2196
|
-
status: f.status?.name ?? "",
|
|
2197
|
-
statusCategory: f.status?.statusCategory?.key ?? "",
|
|
2198
|
-
assignee: f.assignee?.displayName ?? "Unassigned",
|
|
2199
|
-
reporter: f.reporter?.displayName ?? "",
|
|
2200
|
-
priority: f.priority?.name ?? "",
|
|
2201
|
-
labels: f.labels ?? [],
|
|
2202
|
-
created: f.created ?? "",
|
|
2203
|
-
updated: f.updated ?? "",
|
|
2204
|
-
description: f.description ?? null,
|
|
2205
|
-
comments,
|
|
2206
|
-
attachments: (f.attachment ?? []).map((a) => ({
|
|
2207
|
-
mediaId: a.id,
|
|
2208
|
-
filename: a.filename,
|
|
2209
|
-
url: a.content
|
|
2210
|
-
}))
|
|
2211
|
-
};
|
|
2212
|
-
}
|
|
2213
|
-
async function updateIssue(client, key, update) {
|
|
2214
|
-
const fields = { description: update.description };
|
|
2215
|
-
if (update.summary !== void 0) fields["summary"] = update.summary;
|
|
2216
|
-
await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
|
|
2217
|
-
}
|
|
2218
|
-
const SEARCH_FIELDS = "summary,status,updated";
|
|
2219
|
-
async function searchIssues(client, site, params) {
|
|
2220
|
-
return ((await fetchSearchPage(client, buildJql(params), params.limit)).issues ?? []).map((i) => toIssueSummary(site, i));
|
|
2221
|
-
}
|
|
2222
|
-
async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
|
|
2223
|
-
const query = new URLSearchParams({
|
|
2224
|
-
jql,
|
|
2225
|
-
maxResults: String(maxResults),
|
|
2226
|
-
fields: SEARCH_FIELDS
|
|
2227
|
-
});
|
|
2228
|
-
if (nextPageToken) query.set("nextPageToken", nextPageToken);
|
|
2229
|
-
return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
|
|
2230
|
-
}
|
|
2231
|
-
function toIssueSummary(site, i) {
|
|
2232
|
-
return {
|
|
2233
|
-
key: i.key,
|
|
2234
|
-
status: i.fields?.status?.name ?? "",
|
|
2235
|
-
statusCategory: i.fields?.status?.statusCategory?.key ?? "",
|
|
2236
|
-
summary: decodeEntities(i.fields?.summary ?? ""),
|
|
2237
|
-
updated: i.fields?.updated ?? "",
|
|
2238
|
-
url: browseUrl(site, i.key)
|
|
2239
|
-
};
|
|
2240
|
-
}
|
|
2241
|
-
function buildJql(params) {
|
|
2242
|
-
if (params.jql) return params.jql;
|
|
2243
|
-
const clauses = [];
|
|
2244
|
-
if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
|
|
2245
|
-
if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlStringLiteral(params.assignee)}`);
|
|
2246
|
-
if (params.status) clauses.push(`status = ${jqlStringLiteral(params.status)}`);
|
|
2247
|
-
if (params.text) clauses.push(`text ~ ${jqlStringLiteral(params.text)}`);
|
|
2248
|
-
if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
|
|
2249
|
-
return `${clauses.join(" AND ")} ORDER BY updated DESC`;
|
|
2250
|
-
}
|
|
2251
|
-
const OPEN_CLAUSE = "statusCategory != Done";
|
|
2252
|
-
function buildListJql(params) {
|
|
2253
|
-
const clauses = ["assignee = currentUser()"];
|
|
2254
|
-
if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
|
|
2255
|
-
clauses.push(params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE);
|
|
2256
|
-
return `${clauses.join(" AND ")} ORDER BY updated DESC`;
|
|
2257
|
-
}
|
|
2258
|
-
const LIST_PAGE_SIZE = 100;
|
|
2259
|
-
const LIST_CAP = 500;
|
|
2260
|
-
async function listAssignedIssues(client, site, params) {
|
|
2261
|
-
const jql = buildListJql(params);
|
|
2262
|
-
const issues = [];
|
|
2263
|
-
let nextPageToken;
|
|
2264
|
-
for (;;) {
|
|
2265
|
-
const room = LIST_CAP - issues.length;
|
|
2266
|
-
const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
|
|
2267
|
-
const page = res.issues ?? [];
|
|
2268
|
-
issues.push(...page.map((i) => toIssueSummary(site, i)));
|
|
2269
|
-
if (res.isLast || !res.nextPageToken || page.length === 0) return {
|
|
2270
|
-
issues,
|
|
2271
|
-
truncated: false
|
|
2272
|
-
};
|
|
2273
|
-
if (issues.length >= LIST_CAP) return {
|
|
2274
|
-
issues,
|
|
2275
|
-
truncated: true
|
|
2276
|
-
};
|
|
2277
|
-
nextPageToken = res.nextPageToken;
|
|
2278
|
-
}
|
|
2279
|
-
}
|
|
2280
|
-
const LIST_CATEGORY_ORDER = {
|
|
2281
|
-
indeterminate: 0,
|
|
2282
|
-
new: 1,
|
|
2283
|
-
done: 2
|
|
2284
|
-
};
|
|
2285
|
-
const LIST_UNKNOWN_CATEGORY_RANK = Object.keys(LIST_CATEGORY_ORDER).length;
|
|
2286
|
-
function sortByCategoryThenUpdated(issues) {
|
|
2287
|
-
const rank = (i) => LIST_CATEGORY_ORDER[i.statusCategory] ?? LIST_UNKNOWN_CATEGORY_RANK;
|
|
2288
|
-
const updatedMs = (i) => Date.parse(i.updated) || 0;
|
|
2289
|
-
return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
|
|
2290
|
-
}
|
|
2291
|
-
const PROJECT_PAGE_SIZE = 50;
|
|
2292
|
-
async function listProjects(client, site, query) {
|
|
2293
|
-
const projects = [];
|
|
2294
|
-
for (let startAt = 0;;) {
|
|
2295
|
-
const res = await client.getJson(`/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`);
|
|
2296
|
-
const values = res.values ?? [];
|
|
2297
|
-
for (const p of values) projects.push({
|
|
2298
|
-
key: p.key,
|
|
2299
|
-
name: p.name,
|
|
2300
|
-
id: p.id,
|
|
2301
|
-
type: p.projectTypeKey ?? "",
|
|
2302
|
-
url: browseUrl(site, p.key)
|
|
2303
|
-
});
|
|
2304
|
-
if (res.isLast || values.length === 0) break;
|
|
2305
|
-
startAt += values.length;
|
|
2306
|
-
}
|
|
2307
|
-
return projects;
|
|
2441
|
+
space: options.space
|
|
2442
|
+
}, options.space ? `No starred pages in ${options.space}.` : "No starred pages.", options);
|
|
2308
2443
|
}
|
|
2309
|
-
function
|
|
2310
|
-
const
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2444
|
+
async function listPages(env, filter, empty, options) {
|
|
2445
|
+
const { session, term } = env;
|
|
2446
|
+
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
2447
|
+
const limit = parseLimit(options.limit);
|
|
2448
|
+
const { pages, hasMore } = await searchPages(session, session.site, {
|
|
2449
|
+
...filter,
|
|
2450
|
+
limit
|
|
2314
2451
|
});
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2452
|
+
await runSearch(term, formatPageRows(pages, Date.now()), {
|
|
2453
|
+
json: options.json,
|
|
2454
|
+
copy: options.copy,
|
|
2455
|
+
out: options.out,
|
|
2456
|
+
empty,
|
|
2457
|
+
footer: hasMore ? searchFooter(limit) : void 0
|
|
2458
|
+
}, PAGE_NOUN, (id) => copyPage(env, id, options.out));
|
|
2320
2459
|
}
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2460
|
+
function formatPageRows(pages, nowMs) {
|
|
2461
|
+
return alignedRows(pages, nowMs, (p) => ({
|
|
2462
|
+
id: p.id,
|
|
2463
|
+
url: p.url,
|
|
2464
|
+
label: p.space,
|
|
2465
|
+
color: colorForSpace(p.space),
|
|
2466
|
+
text: p.title,
|
|
2467
|
+
timestamp: p.updated
|
|
2468
|
+
}));
|
|
2330
2469
|
}
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2470
|
+
const SPACE_COLORS = [
|
|
2471
|
+
kleur.cyan,
|
|
2472
|
+
kleur.yellow,
|
|
2473
|
+
kleur.green,
|
|
2474
|
+
kleur.magenta,
|
|
2475
|
+
kleur.blue
|
|
2476
|
+
];
|
|
2477
|
+
function colorForSpace(space) {
|
|
2478
|
+
let hash = 0;
|
|
2479
|
+
for (const char of space) hash = hash * 31 + (char.codePointAt(0) ?? 0) >>> 0;
|
|
2480
|
+
return SPACE_COLORS[hash % SPACE_COLORS.length] ?? kleur.cyan;
|
|
2338
2481
|
}
|
|
2339
|
-
const
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
done: 2
|
|
2482
|
+
const PAGE_NOUN = {
|
|
2483
|
+
singular: "page",
|
|
2484
|
+
plural: "pages"
|
|
2343
2485
|
};
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
for (const s of statuses) {
|
|
2348
|
-
const key = `${s.name}\0${s.categoryKey}`;
|
|
2349
|
-
if (!byNameCategory.has(key)) byNameCategory.set(key, s);
|
|
2350
|
-
}
|
|
2351
|
-
return [...byNameCategory.values()].sort((a, b) => {
|
|
2352
|
-
const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
|
|
2353
|
-
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
2354
|
-
});
|
|
2486
|
+
async function copyPage(env, id, out) {
|
|
2487
|
+
env.term.err(`Fetching page ${id} ...`);
|
|
2488
|
+
await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
|
|
2355
2489
|
}
|
|
2356
|
-
|
|
2357
|
-
|
|
2490
|
+
const PAGE_REF = {
|
|
2491
|
+
message: "Confluence page id or URL:",
|
|
2492
|
+
flag: "[page]",
|
|
2493
|
+
parse: parsePageId,
|
|
2494
|
+
notFound: (raw) => `Could not find a page id in "${raw}".`
|
|
2495
|
+
};
|
|
2496
|
+
//#endregion
|
|
2497
|
+
//#region src/cli/confluence.ts
|
|
2498
|
+
function registerConfluence(confluence) {
|
|
2499
|
+
confluence.description("Confluence commands");
|
|
2500
|
+
confluence.command("view [page]").description("Show a Confluence page (id or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(jiraAction(confluenceView));
|
|
2501
|
+
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(jiraAction(confluenceCopy));
|
|
2502
|
+
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(jiraAction(confluenceUpdate));
|
|
2503
|
+
confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space").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(jiraAction(confluenceList));
|
|
2504
|
+
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(jiraAction(confluenceSearch));
|
|
2505
|
+
return confluence;
|
|
2358
2506
|
}
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
body: c.body ?? null
|
|
2364
|
-
}));
|
|
2507
|
+
//#endregion
|
|
2508
|
+
//#region src/api/jira-url.ts
|
|
2509
|
+
function browseUrl(site, key) {
|
|
2510
|
+
return `${site}/browse/${key}`;
|
|
2365
2511
|
}
|
|
2512
|
+
//#endregion
|
|
2513
|
+
//#region src/api/jira-createmeta.ts
|
|
2366
2514
|
const CREATEMETA_PAGE_SIZE = 200;
|
|
2367
2515
|
async function fetchCreateIssueTypes(client, project) {
|
|
2368
2516
|
return (await pageCreateMeta(client, project, "", (page) => page.issueTypes ?? [])).map((t) => ({
|
|
@@ -2412,6 +2560,8 @@ async function createIssue(client, site, fields) {
|
|
|
2412
2560
|
url: browseUrl(site, res.key)
|
|
2413
2561
|
};
|
|
2414
2562
|
}
|
|
2563
|
+
//#endregion
|
|
2564
|
+
//#region src/api/jira-users.ts
|
|
2415
2565
|
async function searchAssignableUsers(client, project, query) {
|
|
2416
2566
|
const params = new URLSearchParams({
|
|
2417
2567
|
project,
|
|
@@ -2432,8 +2582,123 @@ function toJiraUser(u) {
|
|
|
2432
2582
|
};
|
|
2433
2583
|
}
|
|
2434
2584
|
//#endregion
|
|
2585
|
+
//#region src/api/jira-projects.ts
|
|
2586
|
+
const PROJECT_PAGE_SIZE = 50;
|
|
2587
|
+
async function listProjects(client, site, query) {
|
|
2588
|
+
const projects = [];
|
|
2589
|
+
for (let startAt = 0;;) {
|
|
2590
|
+
const res = await client.getJson(`/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`);
|
|
2591
|
+
const values = res.values ?? [];
|
|
2592
|
+
for (const p of values) projects.push({
|
|
2593
|
+
key: p.key,
|
|
2594
|
+
name: p.name,
|
|
2595
|
+
id: p.id,
|
|
2596
|
+
type: p.projectTypeKey ?? "",
|
|
2597
|
+
url: browseUrl(site, p.key)
|
|
2598
|
+
});
|
|
2599
|
+
if (res.isLast || values.length === 0) break;
|
|
2600
|
+
startAt += values.length;
|
|
2601
|
+
}
|
|
2602
|
+
return projects;
|
|
2603
|
+
}
|
|
2604
|
+
function projectSearchQuery(query, startAt) {
|
|
2605
|
+
const params = new URLSearchParams({
|
|
2606
|
+
orderBy: "key",
|
|
2607
|
+
maxResults: String(PROJECT_PAGE_SIZE),
|
|
2608
|
+
startAt: String(startAt)
|
|
2609
|
+
});
|
|
2610
|
+
if (query) params.set("query", query);
|
|
2611
|
+
return params.toString();
|
|
2612
|
+
}
|
|
2613
|
+
//#endregion
|
|
2614
|
+
//#region src/commands/jira-create-resolve.ts
|
|
2615
|
+
async function resolveProject(ask, client, site, arg, strict) {
|
|
2616
|
+
if (arg) return arg.toUpperCase();
|
|
2617
|
+
if (strict) throw new Error("A project key is required, e.g. `jira create BSC Bug --summary ...`.");
|
|
2618
|
+
return ask.searchPick({
|
|
2619
|
+
message: "Project:",
|
|
2620
|
+
source: async (term) => (await listProjects(client, site, term)).map((p) => ({
|
|
2621
|
+
name: `${p.key} ${p.name}`,
|
|
2622
|
+
value: p.key
|
|
2623
|
+
})),
|
|
2624
|
+
pageSize: 15
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
async function resolveType(ask, project, types, arg, strict) {
|
|
2628
|
+
if (types.length === 0) throw new Error(`You cannot create issues in ${project}.`);
|
|
2629
|
+
if (arg) {
|
|
2630
|
+
const found = matchType(types, arg);
|
|
2631
|
+
if (found) return found;
|
|
2632
|
+
const names = types.map((t) => t.name).join(", ");
|
|
2633
|
+
throw new Error(`${project} has no issue type "${arg}"; available: ${names}.`);
|
|
2634
|
+
}
|
|
2635
|
+
if (strict) throw new Error(`An issue type is required; ${project} has: ${types.map((t) => t.name).join(", ")}.`);
|
|
2636
|
+
return ask.pick({
|
|
2637
|
+
message: "Issue type:",
|
|
2638
|
+
choices: types.map((t) => ({
|
|
2639
|
+
name: t.name,
|
|
2640
|
+
value: t,
|
|
2641
|
+
description: t.description
|
|
2642
|
+
})),
|
|
2643
|
+
pageSize: 15
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
function matchType(types, arg) {
|
|
2647
|
+
const needle = arg.trim().toLowerCase();
|
|
2648
|
+
return types.find((t) => t.id === arg.trim()) ?? types.find((t) => t.name.toLowerCase() === needle);
|
|
2649
|
+
}
|
|
2650
|
+
const ACCOUNT_ID = /^(?:[0-9a-f]{24}|[a-z0-9]+:[0-9a-f-]{36}(?::[0-9a-f-]{36})?)$/i;
|
|
2651
|
+
function userResolver(client, project) {
|
|
2652
|
+
return async (query) => {
|
|
2653
|
+
if (query === "me") return (await fetchMyself(client)).accountId;
|
|
2654
|
+
if (ACCOUNT_ID.test(query)) return query;
|
|
2655
|
+
const users = (await searchAssignableUsers(client, project, query)).filter((u) => u.active);
|
|
2656
|
+
const exact = users.filter((u) => u.displayName.toLowerCase() === query.toLowerCase() || u.email.toLowerCase() === query.toLowerCase());
|
|
2657
|
+
const candidates = exact.length === 1 ? exact : users;
|
|
2658
|
+
if (candidates.length === 1) return candidates[0].accountId;
|
|
2659
|
+
if (candidates.length === 0) throw new Error(`no assignable user matches "${query}".`);
|
|
2660
|
+
const names = candidates.map((u) => u.displayName).join(", ");
|
|
2661
|
+
throw new Error(`"${query}" matches ${candidates.length} users: ${names}.`);
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
//#endregion
|
|
2665
|
+
//#region src/create/encode-allowed-values.ts
|
|
2666
|
+
function allowedLabel(v) {
|
|
2667
|
+
return v.value ?? v.name ?? v.key ?? v.id ?? "";
|
|
2668
|
+
}
|
|
2669
|
+
function findAllowed(options, token) {
|
|
2670
|
+
const needle = token.toLowerCase();
|
|
2671
|
+
return options.find((v) => v.id === token) ?? options.find((v) => allowedLabel(v).toLowerCase() === needle);
|
|
2672
|
+
}
|
|
2673
|
+
function allowedList(options) {
|
|
2674
|
+
return options.map(allowedLabel).join(", ");
|
|
2675
|
+
}
|
|
2676
|
+
function matchAllowed(field, token) {
|
|
2677
|
+
const options = field.allowedValues ?? [];
|
|
2678
|
+
const hit = findAllowed(options, token);
|
|
2679
|
+
if (!hit) return { problem: `${field.name} has no value "${token}"; allowed: ${allowedList(options)}.` };
|
|
2680
|
+
return { value: { id: hit.id } };
|
|
2681
|
+
}
|
|
2682
|
+
function matchCascading(field, value) {
|
|
2683
|
+
const options = field.allowedValues ?? [];
|
|
2684
|
+
const [parentText = "", childText] = value.split(">").map((s) => s.trim());
|
|
2685
|
+
const parent = findAllowed(options, parentText);
|
|
2686
|
+
if (!parent) return { problem: `${field.name} has no value "${parentText}"; allowed: ${allowedList(options)}.` };
|
|
2687
|
+
if (!childText) return { value: { id: parent.id } };
|
|
2688
|
+
const children = parent.children ?? [];
|
|
2689
|
+
const child = findAllowed(children, childText);
|
|
2690
|
+
if (!child) {
|
|
2691
|
+
const under = allowedLabel(parent);
|
|
2692
|
+
return { problem: `${field.name} has no value "${childText}" under ${under}; allowed: ${allowedList(children)}.` };
|
|
2693
|
+
}
|
|
2694
|
+
return { value: {
|
|
2695
|
+
id: parent.id,
|
|
2696
|
+
child: { id: child.id }
|
|
2697
|
+
} };
|
|
2698
|
+
}
|
|
2699
|
+
//#endregion
|
|
2435
2700
|
//#region src/create/encode.ts
|
|
2436
|
-
const SERVER_FILLED = new Set([
|
|
2701
|
+
const SERVER_FILLED = /* @__PURE__ */ new Set([
|
|
2437
2702
|
"project",
|
|
2438
2703
|
"issuetype",
|
|
2439
2704
|
"reporter"
|
|
@@ -2513,7 +2778,7 @@ async function encodeField(field, values, resolveUser) {
|
|
|
2513
2778
|
}
|
|
2514
2779
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
2515
2780
|
const DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i;
|
|
2516
|
-
const NAMED_TYPES = new Set([
|
|
2781
|
+
const NAMED_TYPES = /* @__PURE__ */ new Set([
|
|
2517
2782
|
"priority",
|
|
2518
2783
|
"version",
|
|
2519
2784
|
"component",
|
|
@@ -2555,63 +2820,30 @@ async function encodeScalar(field, type, raw, resolveUser) {
|
|
|
2555
2820
|
case "issuelink": return { value: { key: token.toUpperCase() } };
|
|
2556
2821
|
default:
|
|
2557
2822
|
if (NAMED_TYPES.has(type)) return { value: { name: token } };
|
|
2558
|
-
return { value: looseValue(raw) };
|
|
2559
|
-
}
|
|
2560
|
-
}
|
|
2561
|
-
function looseValue(raw) {
|
|
2562
|
-
const trimmed = raw.trim();
|
|
2563
|
-
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return raw;
|
|
2564
|
-
try {
|
|
2565
|
-
return JSON.parse(trimmed);
|
|
2566
|
-
} catch {
|
|
2567
|
-
return raw;
|
|
2568
|
-
}
|
|
2569
|
-
}
|
|
2570
|
-
function formatJiraDateTime(date) {
|
|
2571
|
-
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
2572
|
-
const offsetMinutes = -date.getTimezoneOffset();
|
|
2573
|
-
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
2574
|
-
const abs = Math.abs(offsetMinutes);
|
|
2575
|
-
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
2576
|
-
const hms = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
2577
|
-
const offset = `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`;
|
|
2578
|
-
return `${ymd}T${hms}.${pad(date.getMilliseconds(), 3)}${offset}`;
|
|
2579
|
-
}
|
|
2580
|
-
function splitCommas(values) {
|
|
2581
|
-
return values.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0);
|
|
2582
|
-
}
|
|
2583
|
-
function allowedLabel(v) {
|
|
2584
|
-
return v.value ?? v.name ?? v.key ?? v.id ?? "";
|
|
2585
|
-
}
|
|
2586
|
-
function findAllowed(options, token) {
|
|
2587
|
-
const needle = token.toLowerCase();
|
|
2588
|
-
return options.find((v) => v.id === token) ?? options.find((v) => allowedLabel(v).toLowerCase() === needle);
|
|
2589
|
-
}
|
|
2590
|
-
function allowedList(options) {
|
|
2591
|
-
return options.map(allowedLabel).join(", ");
|
|
2592
|
-
}
|
|
2593
|
-
function matchAllowed(field, token) {
|
|
2594
|
-
const options = field.allowedValues ?? [];
|
|
2595
|
-
const hit = findAllowed(options, token);
|
|
2596
|
-
if (!hit) return { problem: `${field.name} has no value "${token}"; allowed: ${allowedList(options)}.` };
|
|
2597
|
-
return { value: { id: hit.id } };
|
|
2598
|
-
}
|
|
2599
|
-
function matchCascading(field, value) {
|
|
2600
|
-
const options = field.allowedValues ?? [];
|
|
2601
|
-
const [parentText = "", childText] = value.split(">").map((s) => s.trim());
|
|
2602
|
-
const parent = findAllowed(options, parentText);
|
|
2603
|
-
if (!parent) return { problem: `${field.name} has no value "${parentText}"; allowed: ${allowedList(options)}.` };
|
|
2604
|
-
if (!childText) return { value: { id: parent.id } };
|
|
2605
|
-
const children = parent.children ?? [];
|
|
2606
|
-
const child = findAllowed(children, childText);
|
|
2607
|
-
if (!child) {
|
|
2608
|
-
const under = allowedLabel(parent);
|
|
2609
|
-
return { problem: `${field.name} has no value "${childText}" under ${under}; allowed: ${allowedList(children)}.` };
|
|
2823
|
+
return { value: looseValue(raw) };
|
|
2610
2824
|
}
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2825
|
+
}
|
|
2826
|
+
function looseValue(raw) {
|
|
2827
|
+
const trimmed = raw.trim();
|
|
2828
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return raw;
|
|
2829
|
+
try {
|
|
2830
|
+
return JSON.parse(trimmed);
|
|
2831
|
+
} catch {
|
|
2832
|
+
return raw;
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
function formatJiraDateTime(date) {
|
|
2836
|
+
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
2837
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
2838
|
+
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
2839
|
+
const abs = Math.abs(offsetMinutes);
|
|
2840
|
+
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
2841
|
+
const hms = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
2842
|
+
const offset = `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`;
|
|
2843
|
+
return `${ymd}T${hms}.${pad(date.getMilliseconds(), 3)}${offset}`;
|
|
2844
|
+
}
|
|
2845
|
+
function splitCommas(values) {
|
|
2846
|
+
return values.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0);
|
|
2615
2847
|
}
|
|
2616
2848
|
//#endregion
|
|
2617
2849
|
//#region src/create/format.ts
|
|
@@ -2727,16 +2959,16 @@ function formatIssueTypeRows(types) {
|
|
|
2727
2959
|
//#endregion
|
|
2728
2960
|
//#region src/create/prompt.ts
|
|
2729
2961
|
const PROMPT_SOURCE = "prompt";
|
|
2730
|
-
async function walkFields(meta, deps) {
|
|
2962
|
+
async function walkFields(ask, meta, deps) {
|
|
2731
2963
|
const firstPass = meta.filter((f) => f.required && !SERVER_FILLED.has(f.fieldId));
|
|
2732
2964
|
const inputs = [];
|
|
2733
2965
|
for (const field of firstPass) {
|
|
2734
|
-
const answer = await promptField(field, deps, true);
|
|
2966
|
+
const answer = await promptField(ask, field, deps, true);
|
|
2735
2967
|
if (answer) inputs.push(answer);
|
|
2736
2968
|
}
|
|
2737
2969
|
const optional = meta.filter((f) => !firstPass.includes(f) && !SERVER_FILLED.has(f.fieldId));
|
|
2738
2970
|
if (optional.length === 0) return inputs;
|
|
2739
|
-
const chosen = await
|
|
2971
|
+
const chosen = await ask.pickMany({
|
|
2740
2972
|
message: "Set any optional fields?",
|
|
2741
2973
|
choices: optional.map((f) => ({
|
|
2742
2974
|
name: `${f.name} (${fieldTypeLabel(f.schema)})`,
|
|
@@ -2745,13 +2977,13 @@ async function walkFields(meta, deps) {
|
|
|
2745
2977
|
pageSize: 15
|
|
2746
2978
|
});
|
|
2747
2979
|
for (const field of optional.filter((f) => chosen.includes(f.fieldId))) {
|
|
2748
|
-
const answer = await promptField(field, deps, false);
|
|
2980
|
+
const answer = await promptField(ask, field, deps, false);
|
|
2749
2981
|
if (answer) inputs.push(answer);
|
|
2750
2982
|
}
|
|
2751
2983
|
return inputs;
|
|
2752
2984
|
}
|
|
2753
|
-
async function promptField(field, deps, required) {
|
|
2754
|
-
const answer = await askValues(field, deps, required);
|
|
2985
|
+
async function promptField(ask, field, deps, required) {
|
|
2986
|
+
const answer = await askValues(ask, field, deps, required);
|
|
2755
2987
|
if (answer.values.length === 0) return void 0;
|
|
2756
2988
|
return {
|
|
2757
2989
|
name: field.fieldId,
|
|
@@ -2764,14 +2996,14 @@ const SKIP = {
|
|
|
2764
2996
|
name: "(skip)",
|
|
2765
2997
|
value: ""
|
|
2766
2998
|
};
|
|
2767
|
-
async function askValues(field, deps, required) {
|
|
2999
|
+
async function askValues(ask, field, deps, required) {
|
|
2768
3000
|
const { schema } = field;
|
|
2769
3001
|
const message = `${field.name}:`;
|
|
2770
3002
|
const options = field.allowedValues ?? [];
|
|
2771
|
-
if (schema.type === "option-with-child") return askCascading(field, required);
|
|
3003
|
+
if (schema.type === "option-with-child") return askCascading(ask, field, required);
|
|
2772
3004
|
if (options.length > 0 && schema.type === "array") {
|
|
2773
3005
|
const defaults = new Set(defaultIds(field));
|
|
2774
|
-
return { values: await
|
|
3006
|
+
return { values: await ask.pickMany({
|
|
2775
3007
|
message,
|
|
2776
3008
|
choices: options.map((v) => ({
|
|
2777
3009
|
...choice(v),
|
|
@@ -2783,7 +3015,7 @@ async function askValues(field, deps, required) {
|
|
|
2783
3015
|
}
|
|
2784
3016
|
if (options.length > 0) {
|
|
2785
3017
|
const choices = options.map(choice);
|
|
2786
|
-
const picked = await
|
|
3018
|
+
const picked = await ask.pick({
|
|
2787
3019
|
message,
|
|
2788
3020
|
choices: required ? choices : [SKIP, ...choices],
|
|
2789
3021
|
default: defaultIds(field)[0],
|
|
@@ -2791,9 +3023,9 @@ async function askValues(field, deps, required) {
|
|
|
2791
3023
|
});
|
|
2792
3024
|
return { values: picked ? [picked] : [] };
|
|
2793
3025
|
}
|
|
2794
|
-
if (schema.type === "user") return askUser(field, deps, required);
|
|
3026
|
+
if (schema.type === "user") return askUser(ask, field, deps, required);
|
|
2795
3027
|
if (isMultiLine(schema)) {
|
|
2796
|
-
const text = await
|
|
3028
|
+
const text = await ask.edit({
|
|
2797
3029
|
message: `${field.name} (opens your editor, Markdown):`,
|
|
2798
3030
|
postfix: ".md",
|
|
2799
3031
|
default: primitiveDefault(field),
|
|
@@ -2802,7 +3034,7 @@ async function askValues(field, deps, required) {
|
|
|
2802
3034
|
return { values: text.trim() ? [text] : [] };
|
|
2803
3035
|
}
|
|
2804
3036
|
const hint = inputHint(field);
|
|
2805
|
-
const text = await
|
|
3037
|
+
const text = await ask.text({
|
|
2806
3038
|
message: hint ? `${field.name} (${hint}):` : message,
|
|
2807
3039
|
default: primitiveDefault(field),
|
|
2808
3040
|
validate: async (v) => {
|
|
@@ -2840,10 +3072,10 @@ function primitiveDefault(field) {
|
|
|
2840
3072
|
if (!field.hasDefaultValue || d == null) return void 0;
|
|
2841
3073
|
return typeof d === "string" || typeof d === "number" ? String(d) : void 0;
|
|
2842
3074
|
}
|
|
2843
|
-
async function askCascading(field, required) {
|
|
3075
|
+
async function askCascading(ask, field, required) {
|
|
2844
3076
|
const options = field.allowedValues ?? [];
|
|
2845
3077
|
const parentChoices = options.map(choice);
|
|
2846
|
-
const parentId = await
|
|
3078
|
+
const parentId = await ask.pick({
|
|
2847
3079
|
message: `${field.name}:`,
|
|
2848
3080
|
choices: required ? parentChoices : [SKIP, ...parentChoices],
|
|
2849
3081
|
pageSize: 15
|
|
@@ -2852,7 +3084,7 @@ async function askCascading(field, required) {
|
|
|
2852
3084
|
const parent = options.find((v) => v.id === parentId);
|
|
2853
3085
|
const children = parent?.children ?? [];
|
|
2854
3086
|
if (children.length === 0) return { values: [parentId] };
|
|
2855
|
-
const childId = await
|
|
3087
|
+
const childId = await ask.pick({
|
|
2856
3088
|
message: `${field.name} > ${allowedLabel(parent)}:`,
|
|
2857
3089
|
choices: [{
|
|
2858
3090
|
name: "(none)",
|
|
@@ -2870,10 +3102,10 @@ const UNASSIGNED = {
|
|
|
2870
3102
|
name: "(unassigned)",
|
|
2871
3103
|
value: ""
|
|
2872
3104
|
};
|
|
2873
|
-
async function askUser(field, deps, required) {
|
|
3105
|
+
async function askUser(ask, field, deps, required) {
|
|
2874
3106
|
const fixed = required ? [ME] : [ME, UNASSIGNED];
|
|
2875
3107
|
const seen = /* @__PURE__ */ new Map();
|
|
2876
|
-
const picked = await
|
|
3108
|
+
const picked = await ask.searchPick({
|
|
2877
3109
|
message: `${field.name} (type to search):`,
|
|
2878
3110
|
source: async (term) => {
|
|
2879
3111
|
if (!term) return fixed;
|
|
@@ -2915,7 +3147,7 @@ function summarizeText(text) {
|
|
|
2915
3147
|
}
|
|
2916
3148
|
//#endregion
|
|
2917
3149
|
//#region src/commands/jira-create.ts
|
|
2918
|
-
async function flagInputs(options) {
|
|
3150
|
+
async function flagInputs(files, options) {
|
|
2919
3151
|
const inputs = [];
|
|
2920
3152
|
const one = (name, value, source) => {
|
|
2921
3153
|
if (value !== void 0) inputs.push({
|
|
@@ -2928,7 +3160,7 @@ async function flagInputs(options) {
|
|
|
2928
3160
|
one("description", options.description, "--description");
|
|
2929
3161
|
if (options.descriptionFile !== void 0) {
|
|
2930
3162
|
if (options.description !== void 0) throw new Error("--description and --description-file cannot be used together.");
|
|
2931
|
-
one("description", await
|
|
3163
|
+
one("description", await files.readText(options.descriptionFile), "--description-file");
|
|
2932
3164
|
}
|
|
2933
3165
|
one("assignee", options.assignee, "--assignee");
|
|
2934
3166
|
one("priority", options.priority, "--priority");
|
|
@@ -2955,17 +3187,16 @@ function parseFieldFlag(raw) {
|
|
|
2955
3187
|
source: "--field"
|
|
2956
3188
|
};
|
|
2957
3189
|
}
|
|
2958
|
-
async function jiraCreate(projectArg, typeArg, options) {
|
|
2959
|
-
const inputs = await flagInputs(options);
|
|
2960
|
-
const strict = inputs.length > 0 ||
|
|
2961
|
-
const
|
|
2962
|
-
const
|
|
2963
|
-
const
|
|
2964
|
-
const
|
|
2965
|
-
const
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
searchUsers: (query) => searchAssignableUsers(client, project, query),
|
|
3190
|
+
async function jiraCreate({ session, term, files }, projectArg, typeArg, options) {
|
|
3191
|
+
const inputs = await flagInputs(files, options);
|
|
3192
|
+
const strict = inputs.length > 0 || !term.interactive;
|
|
3193
|
+
const project = await resolveProject(term.ask, session, session.site, projectArg, strict);
|
|
3194
|
+
const types = await fetchCreateIssueTypes(session, project);
|
|
3195
|
+
const type = await resolveType(term.ask, project, types, typeArg, strict);
|
|
3196
|
+
const meta = await fetchCreateFields(session, project, type.id);
|
|
3197
|
+
const resolveUser = userResolver(session, project);
|
|
3198
|
+
if (!strict) inputs.push(...await walkFields(term.ask, meta, {
|
|
3199
|
+
searchUsers: (query) => searchAssignableUsers(session, project, query),
|
|
2969
3200
|
validate: async (field, value) => {
|
|
2970
3201
|
return (await encodeCreate([field], [{
|
|
2971
3202
|
name: field.fieldId,
|
|
@@ -2983,23 +3214,23 @@ async function jiraCreate(projectArg, typeArg, options) {
|
|
|
2983
3214
|
...result.fields
|
|
2984
3215
|
};
|
|
2985
3216
|
if (options.dryRun) {
|
|
2986
|
-
|
|
3217
|
+
term.json({ fields });
|
|
2987
3218
|
return;
|
|
2988
3219
|
}
|
|
2989
3220
|
if (!strict) {
|
|
2990
|
-
|
|
2991
|
-
if (!await confirm({
|
|
3221
|
+
term.out(formatReview(meta, inputs));
|
|
3222
|
+
if (!await term.ask.confirm({
|
|
2992
3223
|
message: `Create ${project} ${type.name}?`,
|
|
2993
3224
|
default: true
|
|
2994
3225
|
})) {
|
|
2995
|
-
|
|
3226
|
+
term.out("Aborted.");
|
|
2996
3227
|
return;
|
|
2997
3228
|
}
|
|
2998
3229
|
}
|
|
2999
3230
|
try {
|
|
3000
|
-
const created = await createIssue(
|
|
3001
|
-
if (options.json)
|
|
3002
|
-
else
|
|
3231
|
+
const created = await createIssue(session, session.site, fields);
|
|
3232
|
+
if (options.json) term.json(created);
|
|
3233
|
+
else term.out(`Created ${created.key} ${created.url}`);
|
|
3003
3234
|
} catch (err) {
|
|
3004
3235
|
throw describeRejection(err, meta);
|
|
3005
3236
|
}
|
|
@@ -3025,112 +3256,221 @@ function describeRejection(err, meta) {
|
|
|
3025
3256
|
...perField
|
|
3026
3257
|
].join("\n"));
|
|
3027
3258
|
}
|
|
3028
|
-
async function
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3259
|
+
async function jiraFields({ session, term }, projectArg, typeArg, options) {
|
|
3260
|
+
const project = projectArg.toUpperCase();
|
|
3261
|
+
const types = await fetchCreateIssueTypes(session, project);
|
|
3262
|
+
if (!typeArg) {
|
|
3263
|
+
if (options.json) term.json(types);
|
|
3264
|
+
else if (types.length === 0) term.out(`You cannot create issues in ${project}.`);
|
|
3265
|
+
else term.out(formatIssueTypeRows(types));
|
|
3266
|
+
return;
|
|
3267
|
+
}
|
|
3268
|
+
const fields = await fetchCreateFields(session, project, (await resolveType(term.ask, project, types, typeArg, true)).id);
|
|
3269
|
+
if (options.json) term.json(fields);
|
|
3270
|
+
else term.out(formatFieldRows(fields));
|
|
3271
|
+
}
|
|
3272
|
+
//#endregion
|
|
3273
|
+
//#region src/api/jira-search.ts
|
|
3274
|
+
const RECENT_ISSUES_CLAUSE = "updated >= -30d";
|
|
3275
|
+
const SEARCH_FIELDS = "summary,status,updated";
|
|
3276
|
+
async function searchIssues(client, site, params) {
|
|
3277
|
+
return ((await fetchSearchPage(client, buildJql(params), params.limit)).issues ?? []).map((i) => toIssueSummary(site, i));
|
|
3278
|
+
}
|
|
3279
|
+
async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
|
|
3280
|
+
const query = new URLSearchParams({
|
|
3281
|
+
jql,
|
|
3282
|
+
maxResults: String(maxResults),
|
|
3283
|
+
fields: SEARCH_FIELDS
|
|
3038
3284
|
});
|
|
3285
|
+
if (nextPageToken) query.set("nextPageToken", nextPageToken);
|
|
3286
|
+
return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
|
|
3039
3287
|
}
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3288
|
+
function toIssueSummary(site, i) {
|
|
3289
|
+
return {
|
|
3290
|
+
key: i.key,
|
|
3291
|
+
status: i.fields?.status?.name ?? "",
|
|
3292
|
+
statusCategory: i.fields?.status?.statusCategory?.key ?? "",
|
|
3293
|
+
summary: decodeEntities(i.fields?.summary ?? ""),
|
|
3294
|
+
updated: i.fields?.updated ?? "",
|
|
3295
|
+
url: browseUrl(site, i.key)
|
|
3296
|
+
};
|
|
3297
|
+
}
|
|
3298
|
+
function buildJql(params) {
|
|
3299
|
+
if (params.jql) return params.jql;
|
|
3300
|
+
const clauses = [];
|
|
3301
|
+
if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
|
|
3302
|
+
if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlStringLiteral(params.assignee)}`);
|
|
3303
|
+
if (params.status) clauses.push(`status = ${jqlStringLiteral(params.status)}`);
|
|
3304
|
+
if (params.text) clauses.push(`text ~ ${jqlStringLiteral(params.text)}`);
|
|
3305
|
+
if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
|
|
3306
|
+
return `${clauses.join(" AND ")} ORDER BY updated DESC`;
|
|
3307
|
+
}
|
|
3308
|
+
const OPEN_CLAUSE = "statusCategory != Done";
|
|
3309
|
+
function buildListJql(params) {
|
|
3310
|
+
const clauses = ["assignee = currentUser()"];
|
|
3311
|
+
if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
|
|
3312
|
+
clauses.push(params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE);
|
|
3313
|
+
return `${clauses.join(" AND ")} ORDER BY updated DESC`;
|
|
3314
|
+
}
|
|
3315
|
+
const LIST_PAGE_SIZE = 100;
|
|
3316
|
+
const LIST_CAP = 500;
|
|
3317
|
+
async function listAssignedIssues(client, site, params) {
|
|
3318
|
+
const jql = buildListJql(params);
|
|
3319
|
+
const issues = [];
|
|
3320
|
+
let nextPageToken;
|
|
3321
|
+
for (;;) {
|
|
3322
|
+
const room = LIST_CAP - issues.length;
|
|
3323
|
+
const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
|
|
3324
|
+
const page = res.issues ?? [];
|
|
3325
|
+
issues.push(...page.map((i) => toIssueSummary(site, i)));
|
|
3326
|
+
if (res.isLast || !res.nextPageToken || page.length === 0) return {
|
|
3327
|
+
issues,
|
|
3328
|
+
truncated: false
|
|
3329
|
+
};
|
|
3330
|
+
if (issues.length >= LIST_CAP) return {
|
|
3331
|
+
issues,
|
|
3332
|
+
truncated: true
|
|
3333
|
+
};
|
|
3334
|
+
nextPageToken = res.nextPageToken;
|
|
3047
3335
|
}
|
|
3048
|
-
if (strict) throw new Error(`An issue type is required; ${project} has: ${types.map((t) => t.name).join(", ")}.`);
|
|
3049
|
-
return select({
|
|
3050
|
-
message: "Issue type:",
|
|
3051
|
-
choices: types.map((t) => ({
|
|
3052
|
-
name: t.name,
|
|
3053
|
-
value: t,
|
|
3054
|
-
description: t.description
|
|
3055
|
-
})),
|
|
3056
|
-
pageSize: 15
|
|
3057
|
-
});
|
|
3058
3336
|
}
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3337
|
+
const LIST_CATEGORY_ORDER = {
|
|
3338
|
+
indeterminate: 0,
|
|
3339
|
+
new: 1,
|
|
3340
|
+
done: 2
|
|
3341
|
+
};
|
|
3342
|
+
const LIST_UNKNOWN_CATEGORY_RANK = Object.keys(LIST_CATEGORY_ORDER).length;
|
|
3343
|
+
function sortByCategoryThenUpdated(issues) {
|
|
3344
|
+
const rank = (i) => LIST_CATEGORY_ORDER[i.statusCategory] ?? LIST_UNKNOWN_CATEGORY_RANK;
|
|
3345
|
+
const updatedMs = (i) => Date.parse(i.updated) || 0;
|
|
3346
|
+
return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
|
|
3062
3347
|
}
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3348
|
+
function jqlStringLiteral(value) {
|
|
3349
|
+
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
3350
|
+
}
|
|
3351
|
+
//#endregion
|
|
3352
|
+
//#region src/api/jira-issues.ts
|
|
3353
|
+
const FIELDS = [
|
|
3354
|
+
"summary",
|
|
3355
|
+
"description",
|
|
3356
|
+
"issuetype",
|
|
3357
|
+
"status",
|
|
3358
|
+
"assignee",
|
|
3359
|
+
"reporter",
|
|
3360
|
+
"priority",
|
|
3361
|
+
"labels",
|
|
3362
|
+
"created",
|
|
3363
|
+
"updated",
|
|
3364
|
+
"attachment"
|
|
3365
|
+
].join(",");
|
|
3366
|
+
async function fetchIssue(client, site, key) {
|
|
3367
|
+
const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
|
|
3368
|
+
const comments = await fetchComments(client, key);
|
|
3369
|
+
const f = issue.fields;
|
|
3370
|
+
return {
|
|
3371
|
+
key: issue.key,
|
|
3372
|
+
url: browseUrl(site, issue.key),
|
|
3373
|
+
summary: f.summary ?? "",
|
|
3374
|
+
type: f.issuetype?.name ?? "",
|
|
3375
|
+
status: f.status?.name ?? "",
|
|
3376
|
+
statusCategory: f.status?.statusCategory?.key ?? "",
|
|
3377
|
+
assignee: f.assignee?.displayName ?? "Unassigned",
|
|
3378
|
+
reporter: f.reporter?.displayName ?? "",
|
|
3379
|
+
priority: f.priority?.name ?? "",
|
|
3380
|
+
labels: f.labels ?? [],
|
|
3381
|
+
created: f.created ?? "",
|
|
3382
|
+
updated: f.updated ?? "",
|
|
3383
|
+
description: f.description ?? null,
|
|
3384
|
+
comments,
|
|
3385
|
+
attachments: (f.attachment ?? []).map((a) => ({
|
|
3386
|
+
mediaId: a.id,
|
|
3387
|
+
filename: a.filename,
|
|
3388
|
+
url: a.content
|
|
3389
|
+
}))
|
|
3075
3390
|
};
|
|
3076
3391
|
}
|
|
3077
|
-
async function
|
|
3078
|
-
const
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
if (options.json) console.log(JSON.stringify(fields, null, 2));
|
|
3089
|
-
else for (const line of formatFieldRows(fields)) console.log(line);
|
|
3392
|
+
async function updateIssue(client, key, update) {
|
|
3393
|
+
const fields = { description: update.description };
|
|
3394
|
+
if (update.summary !== void 0) fields["summary"] = update.summary;
|
|
3395
|
+
await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
|
|
3396
|
+
}
|
|
3397
|
+
async function fetchComments(client, key) {
|
|
3398
|
+
return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
|
|
3399
|
+
author: c.author?.displayName ?? "",
|
|
3400
|
+
created: c.created ?? "",
|
|
3401
|
+
body: c.body ?? null
|
|
3402
|
+
}));
|
|
3090
3403
|
}
|
|
3091
3404
|
//#endregion
|
|
3092
|
-
//#region src/
|
|
3093
|
-
async function
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3405
|
+
//#region src/api/jira-statuses.ts
|
|
3406
|
+
async function listStatuses(client, project) {
|
|
3407
|
+
return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await client.getJson("/rest/api/3/status")).map(toStatusSummary));
|
|
3408
|
+
}
|
|
3409
|
+
async function fetchProjectStatuses(client, project) {
|
|
3410
|
+
let groups;
|
|
3411
|
+
try {
|
|
3412
|
+
groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
|
|
3413
|
+
} catch (err) {
|
|
3414
|
+
if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
|
|
3415
|
+
throw err;
|
|
3099
3416
|
}
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3417
|
+
return groups.flatMap((g) => g.statuses ?? []);
|
|
3418
|
+
}
|
|
3419
|
+
function toStatusSummary(s) {
|
|
3420
|
+
return {
|
|
3421
|
+
name: s.name,
|
|
3422
|
+
id: s.id,
|
|
3423
|
+
category: s.statusCategory?.name ?? "",
|
|
3424
|
+
categoryKey: s.statusCategory?.key ?? ""
|
|
3425
|
+
};
|
|
3426
|
+
}
|
|
3427
|
+
const CATEGORY_ORDER = {
|
|
3428
|
+
new: 0,
|
|
3429
|
+
indeterminate: 1,
|
|
3430
|
+
done: 2
|
|
3431
|
+
};
|
|
3432
|
+
const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
|
|
3433
|
+
function dedupeAndSortStatuses(statuses) {
|
|
3434
|
+
const byNameCategory = /* @__PURE__ */ new Map();
|
|
3435
|
+
for (const s of statuses) {
|
|
3436
|
+
const key = `${s.name}\0${s.categoryKey}`;
|
|
3437
|
+
if (!byNameCategory.has(key)) byNameCategory.set(key, s);
|
|
3103
3438
|
}
|
|
3104
|
-
|
|
3439
|
+
return [...byNameCategory.values()].sort((a, b) => {
|
|
3440
|
+
const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
|
|
3441
|
+
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
3442
|
+
});
|
|
3443
|
+
}
|
|
3444
|
+
//#endregion
|
|
3445
|
+
//#region src/commands/jira.ts
|
|
3446
|
+
async function jiraProjects({ session, term }, query, options) {
|
|
3447
|
+
const projects = await listProjects(session, session.site, query);
|
|
3448
|
+
if (options.json) term.json(projects);
|
|
3449
|
+
else if (projects.length === 0) term.out("No matching projects.");
|
|
3450
|
+
else term.out(formatProjectRows(projects));
|
|
3105
3451
|
}
|
|
3106
3452
|
function formatProjectRows(projects) {
|
|
3107
3453
|
const width = Math.max(...projects.map((p) => p.key.length));
|
|
3108
3454
|
return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
|
|
3109
3455
|
}
|
|
3110
|
-
async function jiraStatuses(query, options) {
|
|
3111
|
-
let statuses = await listStatuses(
|
|
3456
|
+
async function jiraStatuses({ session, term }, query, options) {
|
|
3457
|
+
let statuses = await listStatuses(session, options.project);
|
|
3112
3458
|
if (query) {
|
|
3113
3459
|
const needle = query.toLowerCase();
|
|
3114
3460
|
statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
|
|
3115
3461
|
}
|
|
3116
|
-
if (options.json)
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
}
|
|
3120
|
-
if (statuses.length === 0) {
|
|
3121
|
-
console.log("No matching statuses.");
|
|
3122
|
-
return;
|
|
3123
|
-
}
|
|
3124
|
-
for (const line of formatStatusRows(statuses)) console.log(line);
|
|
3462
|
+
if (options.json) term.json(statuses);
|
|
3463
|
+
else if (statuses.length === 0) term.out("No matching statuses.");
|
|
3464
|
+
else term.out(formatStatusRows(statuses));
|
|
3125
3465
|
}
|
|
3126
3466
|
function formatStatusRows(statuses) {
|
|
3127
3467
|
const width = Math.max(...statuses.map((s) => s.name.length));
|
|
3128
3468
|
return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
|
|
3129
3469
|
}
|
|
3130
|
-
async function jiraView(arg, options) {
|
|
3131
|
-
const
|
|
3132
|
-
const
|
|
3133
|
-
await
|
|
3470
|
+
async function jiraView({ session, term }, arg, options) {
|
|
3471
|
+
const key = await resolveRef(term.ask, arg, ISSUE_REF);
|
|
3472
|
+
const lines = formatIssueView(await fetchIssue(session, session.site, key), Date.now(), options.allComments ?? false);
|
|
3473
|
+
await term.page(lines.join("\n"), { pager: options.pager });
|
|
3134
3474
|
}
|
|
3135
3475
|
const CATEGORY_COLORS = {
|
|
3136
3476
|
new: kleur.cyan,
|
|
@@ -3160,35 +3500,44 @@ function formatIssueView(issue, nowMs, allComments) {
|
|
|
3160
3500
|
...attachmentSection(issue.attachments)
|
|
3161
3501
|
];
|
|
3162
3502
|
}
|
|
3163
|
-
async function jiraCopy(arg, options) {
|
|
3164
|
-
|
|
3165
|
-
const key = await resolveRef(arg, ISSUE_REF);
|
|
3166
|
-
await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
|
|
3503
|
+
async function jiraCopy(env, arg, options) {
|
|
3504
|
+
await copyIssue(env, await resolveRef(env.term.ask, arg, ISSUE_REF), options.out);
|
|
3167
3505
|
}
|
|
3168
|
-
async function jiraUpdate(arg, options) {
|
|
3169
|
-
const
|
|
3506
|
+
async function jiraUpdate({ session, term, files }, arg, options) {
|
|
3507
|
+
const file = arg ?? await term.ask.text({
|
|
3170
3508
|
message: "Path to the issue Markdown file:",
|
|
3509
|
+
flag: "[file]",
|
|
3171
3510
|
required: true
|
|
3172
|
-
})
|
|
3173
|
-
const
|
|
3174
|
-
const
|
|
3175
|
-
|
|
3176
|
-
await runPlan(plan, options, async () => {
|
|
3511
|
+
});
|
|
3512
|
+
const src = parseIssueSource(await files.readText(file));
|
|
3513
|
+
const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
|
|
3514
|
+
await runPlan(term, plan, options, async () => {
|
|
3177
3515
|
const { current, next } = plan.headline;
|
|
3178
|
-
await updateIssue(
|
|
3516
|
+
await updateIssue(session, src.key, {
|
|
3179
3517
|
description: plan.body,
|
|
3180
3518
|
summary: next !== current ? next : void 0
|
|
3181
3519
|
});
|
|
3182
|
-
|
|
3520
|
+
term.out(`Updated ${src.key}.`);
|
|
3183
3521
|
});
|
|
3184
3522
|
}
|
|
3185
|
-
async function
|
|
3523
|
+
async function copyIssue(env, key, out) {
|
|
3524
|
+
env.term.err(`Fetching ${key} ...`);
|
|
3525
|
+
await runCopy(env, planIssueCopy(await fetchIssue(env.session, env.session.site, key), out));
|
|
3526
|
+
}
|
|
3527
|
+
const ISSUE_REF = {
|
|
3528
|
+
message: "Jira issue key or URL:",
|
|
3529
|
+
flag: "[issue]",
|
|
3530
|
+
parse: parseIssueKey,
|
|
3531
|
+
notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
|
|
3532
|
+
};
|
|
3533
|
+
//#endregion
|
|
3534
|
+
//#region src/commands/jira-search.ts
|
|
3535
|
+
async function jiraSearch(env, query, options) {
|
|
3536
|
+
const { session, term } = env;
|
|
3186
3537
|
if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
|
|
3187
3538
|
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
3188
|
-
const auth = await requireAuth();
|
|
3189
|
-
const client = new AtlassianClient(auth);
|
|
3190
3539
|
const limit = parseLimit(options.limit);
|
|
3191
|
-
const issues = await searchIssues(
|
|
3540
|
+
const issues = await searchIssues(session, session.site, {
|
|
3192
3541
|
text: query,
|
|
3193
3542
|
project: options.project,
|
|
3194
3543
|
assignee: options.assignee,
|
|
@@ -3196,29 +3545,28 @@ async function jiraSearch(query, options) {
|
|
|
3196
3545
|
jql: options.jql,
|
|
3197
3546
|
limit
|
|
3198
3547
|
});
|
|
3199
|
-
await runSearch(formatIssueRows(issues, Date.now()), {
|
|
3548
|
+
await runSearch(term, formatIssueRows(issues, Date.now()), {
|
|
3200
3549
|
json: options.json,
|
|
3201
3550
|
copy: options.copy,
|
|
3202
3551
|
out: options.out,
|
|
3203
3552
|
empty: "No matching issues.",
|
|
3204
3553
|
footer: issues.length === limit ? searchFooter(limit) : void 0
|
|
3205
|
-
}, ISSUE_NOUN, (key) => copyIssue(
|
|
3554
|
+
}, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
|
|
3206
3555
|
}
|
|
3207
|
-
async function jiraList(options) {
|
|
3556
|
+
async function jiraList(env, options) {
|
|
3557
|
+
const { session, term } = env;
|
|
3208
3558
|
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
3209
|
-
const
|
|
3210
|
-
const client = new AtlassianClient(auth);
|
|
3211
|
-
const { issues, truncated } = await listAssignedIssues(client, auth.site, {
|
|
3559
|
+
const { issues, truncated } = await listAssignedIssues(session, session.site, {
|
|
3212
3560
|
all: options.all,
|
|
3213
3561
|
project: options.project
|
|
3214
3562
|
});
|
|
3215
|
-
await runSearch(formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
|
|
3563
|
+
await runSearch(term, formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
|
|
3216
3564
|
json: options.json,
|
|
3217
3565
|
copy: options.copy,
|
|
3218
3566
|
out: options.out,
|
|
3219
3567
|
empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
|
|
3220
3568
|
footer: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
|
|
3221
|
-
}, ISSUE_NOUN, (key) => copyIssue(
|
|
3569
|
+
}, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
|
|
3222
3570
|
}
|
|
3223
3571
|
function formatIssueRows(issues, nowMs) {
|
|
3224
3572
|
return alignedRows(issues, nowMs, (i) => ({
|
|
@@ -3226,22 +3574,14 @@ function formatIssueRows(issues, nowMs) {
|
|
|
3226
3574
|
url: i.url,
|
|
3227
3575
|
label: i.status,
|
|
3228
3576
|
color: colorForCategory(i.statusCategory),
|
|
3229
|
-
text: i.summary
|
|
3577
|
+
text: i.summary,
|
|
3578
|
+
timestamp: i.updated
|
|
3230
3579
|
}));
|
|
3231
3580
|
}
|
|
3232
3581
|
const ISSUE_NOUN = {
|
|
3233
3582
|
singular: "issue",
|
|
3234
3583
|
plural: "issues"
|
|
3235
3584
|
};
|
|
3236
|
-
async function copyIssue(client, site, key, out) {
|
|
3237
|
-
console.log(`Fetching ${key} ...`);
|
|
3238
|
-
await runCopy(planIssueCopy(await fetchIssue(client, site, key), out), (url) => client.getBinary(url));
|
|
3239
|
-
}
|
|
3240
|
-
const ISSUE_REF = {
|
|
3241
|
-
message: "Jira issue key or URL:",
|
|
3242
|
-
parse: parseIssueKey,
|
|
3243
|
-
notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
|
|
3244
|
-
};
|
|
3245
3585
|
//#endregion
|
|
3246
3586
|
//#region src/cli/jira.ts
|
|
3247
3587
|
function collect(value, previous = []) {
|
|
@@ -3249,20 +3589,20 @@ function collect(value, previous = []) {
|
|
|
3249
3589
|
}
|
|
3250
3590
|
function registerJira(jira) {
|
|
3251
3591
|
jira.description("Jira commands");
|
|
3252
|
-
jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(
|
|
3253
|
-
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(
|
|
3254
|
-
jira.command("create [project] [type]").description("Create a Jira issue, prompting for fields or taking them all as flags").option("-s, --summary <text>", "issue summary").option("-d, --description <markdown>", "description as Markdown").option("--description-file <path>", "read the description from a Markdown file").option("-a, --assignee <who>", "assignee: me, an account id, or a name to look up").option("--priority <name>", "priority name").option("-l, --label <label>", "label (repeatable, or comma separated)", collect).option("-c, --component <name>", "component (repeatable, or comma separated)", collect).option("--parent <key>", "parent issue key for subtasks").option("-f, --field <name=value>", "any other create-screen field (repeatable)", collect).option("--
|
|
3255
|
-
jira.command("fields <project> [type]").description("Show the issue types you can create in a project, or the create form for one type").option("--json", "output results as JSON").action(
|
|
3256
|
-
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").option("--no-pager", "print directly instead of paging long output").action(
|
|
3257
|
-
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(
|
|
3258
|
-
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(
|
|
3259
|
-
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(
|
|
3260
|
-
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(
|
|
3592
|
+
jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(jiraAction(jiraProjects));
|
|
3593
|
+
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(jiraAction(jiraStatuses));
|
|
3594
|
+
jira.command("create [project] [type]").description("Create a Jira issue, prompting for fields or taking them all as flags").option("-s, --summary <text>", "issue summary").option("-d, --description <markdown>", "description as Markdown").option("--description-file <path>", "read the description from a Markdown file").option("-a, --assignee <who>", "assignee: me, an account id, or a name to look up").option("--priority <name>", "priority name").option("-l, --label <label>", "label (repeatable, or comma separated)", collect).option("-c, --component <name>", "component (repeatable, or comma separated)", collect).option("--parent <key>", "parent issue key for subtasks").option("-f, --field <name=value>", "any other create-screen field (repeatable)", collect).option("--dry-run", "print the resolved payload instead of creating").option("--json", "print the created issue as JSON").action(jiraAction(jiraCreate));
|
|
3595
|
+
jira.command("fields <project> [type]").description("Show the issue types you can create in a project, or the create form for one type").option("--json", "output results as JSON").action(jiraAction(jiraFields));
|
|
3596
|
+
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").option("--no-pager", "print directly instead of paging long output").action(jiraAction(jiraView));
|
|
3597
|
+
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(jiraAction(jiraCopy));
|
|
3598
|
+
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(jiraAction(jiraUpdate));
|
|
3599
|
+
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(jiraAction(jiraList));
|
|
3600
|
+
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(jiraAction(jiraSearch));
|
|
3261
3601
|
return jira;
|
|
3262
3602
|
}
|
|
3263
3603
|
//#endregion
|
|
3264
3604
|
//#region package.json
|
|
3265
|
-
var version = "1.
|
|
3605
|
+
var version = "1.13.0";
|
|
3266
3606
|
//#endregion
|
|
3267
3607
|
//#region src/cli/build.ts
|
|
3268
3608
|
const PRODUCTS = {
|
|
@@ -3270,16 +3610,22 @@ const PRODUCTS = {
|
|
|
3270
3610
|
confluence: registerConfluence,
|
|
3271
3611
|
bitbucket: registerBitbucket
|
|
3272
3612
|
};
|
|
3613
|
+
const NO_INPUT = "never prompt; fail if a required value is missing";
|
|
3614
|
+
function withNoInput(command) {
|
|
3615
|
+
command.option("--no-input", NO_INPUT);
|
|
3616
|
+
for (const sub of command.commands) withNoInput(sub);
|
|
3617
|
+
return command;
|
|
3618
|
+
}
|
|
3273
3619
|
function buildAtlass() {
|
|
3274
3620
|
const program = new Command().name("atlass").description("Copy Jira issues and Confluence pages to Markdown.").version(version);
|
|
3275
3621
|
registerAuth(program.command("auth"));
|
|
3276
3622
|
registerJira(program.command("jira"));
|
|
3277
3623
|
registerConfluence(program.command("confluence").alias("conf"));
|
|
3278
3624
|
registerBitbucket(program.command("bitbucket").alias("bb"));
|
|
3279
|
-
return program;
|
|
3625
|
+
return withNoInput(program);
|
|
3280
3626
|
}
|
|
3281
3627
|
function buildStandalone(product) {
|
|
3282
|
-
return PRODUCTS[product](new Command().name(product).version(version));
|
|
3628
|
+
return withNoInput(PRODUCTS[product](new Command().name(product).version(version)));
|
|
3283
3629
|
}
|
|
3284
3630
|
//#endregion
|
|
3285
3631
|
export { buildStandalone as n, fail as r, buildAtlass as t };
|