atlass 2.0.0 → 2.2.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 +85 -10
- package/dist/bitbucket.mjs +1 -1
- package/dist/{build-DMLlCHdF.mjs → build-H7eTLrf3.mjs} +1390 -664
- package/dist/cli.mjs +1 -1
- package/dist/confluence.mjs +1 -1
- package/dist/jira.mjs +1 -1
- package/package.json +2 -1
|
@@ -10,6 +10,7 @@ import kleur from "kleur";
|
|
|
10
10
|
import { common, createLowlight } from "lowlight";
|
|
11
11
|
import { marked } from "marked";
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { structuredPatch } from "diff";
|
|
13
14
|
//#region src/api/http-error.ts
|
|
14
15
|
var HttpError = class extends Error {
|
|
15
16
|
status;
|
|
@@ -28,7 +29,7 @@ var HttpError = class extends Error {
|
|
|
28
29
|
};
|
|
29
30
|
function httpError(status, path, body, loginHint) {
|
|
30
31
|
if (status === 401 || status === 403) return new HttpError(status, `Authentication failed (401/403). Run \`${loginHint}\` to update your token.`, void 0, path);
|
|
31
|
-
if (status === 404) return new HttpError(status, `Not found (404): ${path}
|
|
32
|
+
if (status === 404) return new HttpError(status, `Not found (404): ${path}`, void 0, path);
|
|
32
33
|
if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
|
|
33
34
|
if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
|
|
34
35
|
if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`, jiraErrors(body));
|
|
@@ -119,6 +120,12 @@ var AtlassianClient = class {
|
|
|
119
120
|
body: JSON.stringify(body)
|
|
120
121
|
});
|
|
121
122
|
}
|
|
123
|
+
async deleteNoContent(path) {
|
|
124
|
+
await this.request(path, {
|
|
125
|
+
method: "DELETE",
|
|
126
|
+
headers: { Accept: "application/json" }
|
|
127
|
+
});
|
|
128
|
+
}
|
|
122
129
|
async postMultipart(path, filename, bytes) {
|
|
123
130
|
const form = new FormData();
|
|
124
131
|
const blob = new Blob([bytes]);
|
|
@@ -440,14 +447,58 @@ function guarded(fn) {
|
|
|
440
447
|
};
|
|
441
448
|
}
|
|
442
449
|
function fail(err) {
|
|
443
|
-
if (isPromptInterrupt(err))
|
|
450
|
+
if (isPromptInterrupt(err)) {
|
|
451
|
+
process.exitCode = SIGINT_EXIT_CODE;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
444
454
|
ttyTerminal().err(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
445
|
-
process.
|
|
455
|
+
process.exitCode = 1;
|
|
446
456
|
}
|
|
447
457
|
function isPromptInterrupt(err) {
|
|
448
458
|
return err instanceof Error && err.name === "ExitPromptError";
|
|
449
459
|
}
|
|
450
460
|
//#endregion
|
|
461
|
+
//#region src/util/format.ts
|
|
462
|
+
function formatDuration(seconds) {
|
|
463
|
+
if (seconds == null) return "-";
|
|
464
|
+
if (seconds < 60) return `${seconds}s`;
|
|
465
|
+
const m = Math.floor(seconds / 60);
|
|
466
|
+
const s = seconds % 60;
|
|
467
|
+
if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
|
|
468
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
469
|
+
}
|
|
470
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
471
|
+
function formatDate$1(iso) {
|
|
472
|
+
const date = new Date(iso);
|
|
473
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
474
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
475
|
+
}
|
|
476
|
+
function formatDateTime(iso) {
|
|
477
|
+
const date = new Date(iso);
|
|
478
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
479
|
+
return `${formatDate$1(iso)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
480
|
+
}
|
|
481
|
+
const MINUTE = 60;
|
|
482
|
+
const HOUR = 3600;
|
|
483
|
+
const DAY = 24 * HOUR;
|
|
484
|
+
function relativeTime(iso, nowMs) {
|
|
485
|
+
if (!iso) return "-";
|
|
486
|
+
const then = Date.parse(iso);
|
|
487
|
+
if (Number.isNaN(then)) return "-";
|
|
488
|
+
const secs = Math.floor((nowMs - then) / 1e3);
|
|
489
|
+
if (secs < MINUTE) return "just now";
|
|
490
|
+
if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
|
|
491
|
+
if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
|
|
492
|
+
const days = Math.floor(secs / DAY);
|
|
493
|
+
if (days < 45) return `${days}d ago`;
|
|
494
|
+
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
|
495
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
496
|
+
}
|
|
497
|
+
function orList(items) {
|
|
498
|
+
if (items.length < 3) return items.join(" or ");
|
|
499
|
+
return `${items.slice(0, -1).join(", ")}, or ${items.at(-1)}`;
|
|
500
|
+
}
|
|
501
|
+
//#endregion
|
|
451
502
|
//#region src/util/parse.ts
|
|
452
503
|
function parseIssueKey(input) {
|
|
453
504
|
const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
|
|
@@ -462,6 +513,13 @@ function parsePageId(input) {
|
|
|
462
513
|
if (fromQuery) return fromQuery[1];
|
|
463
514
|
return null;
|
|
464
515
|
}
|
|
516
|
+
function parseBoardId(input) {
|
|
517
|
+
const trimmed = input.trim();
|
|
518
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed);
|
|
519
|
+
if (!/^https?:\/\//i.test(trimmed)) return null;
|
|
520
|
+
const match = trimmed.match(/\/boards\/(\d+)(?:[/?#]|$)/) ?? trimmed.match(/[?&]rapidView=(\d+)/);
|
|
521
|
+
return match ? Number(match[1]) : null;
|
|
522
|
+
}
|
|
465
523
|
const PULL_REQUEST_HOSTS = ["bitbucket.org", "www.bitbucket.org"];
|
|
466
524
|
const PULL_REQUEST_PATH = /^\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)(?:\/|$)/;
|
|
467
525
|
function parsePullRequestRef(input) {
|
|
@@ -515,6 +573,19 @@ function resolveRepo(flag, config) {
|
|
|
515
573
|
};
|
|
516
574
|
throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
|
|
517
575
|
}
|
|
576
|
+
function parseChoices(values, choices, flag) {
|
|
577
|
+
const picked = /* @__PURE__ */ new Set();
|
|
578
|
+
const raw = (values ?? []).flatMap((v) => v.split(",")).map((v) => v.trim());
|
|
579
|
+
for (const value of raw.filter(Boolean)) {
|
|
580
|
+
const choice = choices.find((c) => c.toLowerCase() === value.toLowerCase());
|
|
581
|
+
if (!choice) {
|
|
582
|
+
const expected = orList(choices.map((c) => c.toLowerCase()));
|
|
583
|
+
throw new Error(`Invalid ${flag} "${value}". Expected ${expected}.`);
|
|
584
|
+
}
|
|
585
|
+
picked.add(choice);
|
|
586
|
+
}
|
|
587
|
+
return choices.filter((c) => picked.has(c));
|
|
588
|
+
}
|
|
518
589
|
const DEFAULT_LIMIT = 25;
|
|
519
590
|
const MAX_LIMIT = 100;
|
|
520
591
|
function parseLimit(value) {
|
|
@@ -761,15 +832,7 @@ const PULL_REQUEST_STATES = [
|
|
|
761
832
|
"SUPERSEDED"
|
|
762
833
|
];
|
|
763
834
|
function parsePullRequestStates(values) {
|
|
764
|
-
|
|
765
|
-
const states = [];
|
|
766
|
-
for (const value of raw) {
|
|
767
|
-
const state = value.trim().toUpperCase();
|
|
768
|
-
if (!state) continue;
|
|
769
|
-
if (!PULL_REQUEST_STATES.includes(state)) throw new Error(`Invalid --state "${value.trim()}". Expected ${PULL_REQUEST_STATES.map((s) => s.toLowerCase()).join(", ").replace(/, (?=[^,]*$)/, ", or ")}.`);
|
|
770
|
-
if (!states.includes(state)) states.push(state);
|
|
771
|
-
}
|
|
772
|
-
return states;
|
|
835
|
+
return parseChoices(values, PULL_REQUEST_STATES, "--state");
|
|
773
836
|
}
|
|
774
837
|
function allPullRequestStates() {
|
|
775
838
|
return [...PULL_REQUEST_STATES];
|
|
@@ -935,43 +998,6 @@ async function listChangedFiles(client, ref, id) {
|
|
|
935
998
|
return collectCapped(client, `${pullRequestPath(ref, id, "diffstat")}?pagelen=${PAGELEN}`, 50, toChangedFile);
|
|
936
999
|
}
|
|
937
1000
|
//#endregion
|
|
938
|
-
//#region src/util/format.ts
|
|
939
|
-
function formatDuration(seconds) {
|
|
940
|
-
if (seconds == null) return "-";
|
|
941
|
-
if (seconds < 60) return `${seconds}s`;
|
|
942
|
-
const m = Math.floor(seconds / 60);
|
|
943
|
-
const s = seconds % 60;
|
|
944
|
-
if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
|
|
945
|
-
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
946
|
-
}
|
|
947
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
948
|
-
function formatDate$1(iso) {
|
|
949
|
-
const date = new Date(iso);
|
|
950
|
-
if (Number.isNaN(date.getTime())) return iso;
|
|
951
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
952
|
-
}
|
|
953
|
-
function formatDateTime(iso) {
|
|
954
|
-
const date = new Date(iso);
|
|
955
|
-
if (Number.isNaN(date.getTime())) return iso;
|
|
956
|
-
return `${formatDate$1(iso)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
957
|
-
}
|
|
958
|
-
const MINUTE = 60;
|
|
959
|
-
const HOUR = 3600;
|
|
960
|
-
const DAY = 24 * HOUR;
|
|
961
|
-
function relativeTime(iso, nowMs) {
|
|
962
|
-
if (!iso) return "-";
|
|
963
|
-
const then = Date.parse(iso);
|
|
964
|
-
if (Number.isNaN(then)) return "-";
|
|
965
|
-
const secs = Math.floor((nowMs - then) / 1e3);
|
|
966
|
-
if (secs < MINUTE) return "just now";
|
|
967
|
-
if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
|
|
968
|
-
if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
|
|
969
|
-
const days = Math.floor(secs / DAY);
|
|
970
|
-
if (days < 45) return `${days}d ago`;
|
|
971
|
-
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
|
972
|
-
return `${Math.floor(days / 365)}y ago`;
|
|
973
|
-
}
|
|
974
|
-
//#endregion
|
|
975
1001
|
//#region src/commands/bitbucket-comments.ts
|
|
976
1002
|
const MENTION = /@\{([^}]+)\}/g;
|
|
977
1003
|
function renderedPrComments(comments, participants) {
|
|
@@ -1369,7 +1395,7 @@ function link(text, url) {
|
|
|
1369
1395
|
//#endregion
|
|
1370
1396
|
//#region src/commands/view.ts
|
|
1371
1397
|
const INDENT = " ";
|
|
1372
|
-
function fieldLines(pairs) {
|
|
1398
|
+
function fieldLines$1(pairs) {
|
|
1373
1399
|
const kept = pairs.filter(([, value]) => value !== "");
|
|
1374
1400
|
const width = Math.max(...kept.map(([label]) => label.length)) + 1;
|
|
1375
1401
|
return kept.map(([label, value]) => `${kleur.dim(`${label}:`.padEnd(width))} ${value}`);
|
|
@@ -1456,7 +1482,7 @@ function formatPullRequestView(detail, comments, files, nowMs, allComments = fal
|
|
|
1456
1482
|
const label = detail.draft ? "DRAFT" : detail.state || "-";
|
|
1457
1483
|
return [
|
|
1458
1484
|
kleur.bold(detail.title),
|
|
1459
|
-
...fieldLines([
|
|
1485
|
+
...fieldLines$1([
|
|
1460
1486
|
["State", colorForBitbucketState(label)(label)],
|
|
1461
1487
|
["Author", detail.author],
|
|
1462
1488
|
["Branch", branchLine(detail)],
|
|
@@ -1573,6 +1599,7 @@ function alignedRows(items, nowMs, cells) {
|
|
|
1573
1599
|
const cell = cells(item);
|
|
1574
1600
|
return {
|
|
1575
1601
|
...cell,
|
|
1602
|
+
columns: cell.columns ?? [],
|
|
1576
1603
|
age: relativeTime(cell.timestamp, nowMs),
|
|
1577
1604
|
json: { ...item }
|
|
1578
1605
|
};
|
|
@@ -1580,11 +1607,17 @@ function alignedRows(items, nowMs, cells) {
|
|
|
1580
1607
|
const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
|
|
1581
1608
|
const idWidth = width((row) => row.id);
|
|
1582
1609
|
const labelWidth = width((row) => row.label);
|
|
1610
|
+
const columnWidths = (rows[0]?.columns ?? []).map((_, i) => width((row) => row.columns[i]));
|
|
1583
1611
|
const ageWidth = width((row) => row.age);
|
|
1584
1612
|
return rows.map((row) => ({
|
|
1585
1613
|
id: row.id,
|
|
1586
1614
|
url: row.url,
|
|
1587
|
-
fixedColumns:
|
|
1615
|
+
fixedColumns: [
|
|
1616
|
+
row.id.padEnd(idWidth),
|
|
1617
|
+
row.color(row.label.padEnd(labelWidth)),
|
|
1618
|
+
...row.columns.map((column, i) => column.padEnd(columnWidths[i])),
|
|
1619
|
+
row.age.padEnd(ageWidth)
|
|
1620
|
+
].join(" "),
|
|
1588
1621
|
freeText: row.text,
|
|
1589
1622
|
json: row.json
|
|
1590
1623
|
}));
|
|
@@ -1887,128 +1920,227 @@ function moved(flags, command) {
|
|
|
1887
1920
|
throw new Error(`${flag} is now its own command: \`atlass ${command} "<query>"\`.`);
|
|
1888
1921
|
});
|
|
1889
1922
|
}
|
|
1890
|
-
function outputOptions(command) {
|
|
1891
|
-
return command.option("-l, --limit <n>",
|
|
1923
|
+
function outputOptions(command, limitHelp = "max results (default 25, max 100)") {
|
|
1924
|
+
return command.option("-l, --limit <n>", limitHelp).option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy");
|
|
1892
1925
|
}
|
|
1893
1926
|
//#endregion
|
|
1894
|
-
//#region src/api/
|
|
1895
|
-
function
|
|
1896
|
-
return
|
|
1927
|
+
//#region src/api/confluence-create.ts
|
|
1928
|
+
async function findSpaceId(client, key) {
|
|
1929
|
+
return (await client.getJson(`/wiki/api/v2/spaces?keys=${encodeURIComponent(key)}`)).results[0]?.id ?? null;
|
|
1897
1930
|
}
|
|
1898
|
-
function
|
|
1899
|
-
|
|
1931
|
+
async function createPage(client, page) {
|
|
1932
|
+
const res = await client.postJson("/wiki/api/v2/pages", {
|
|
1933
|
+
spaceId: page.spaceId,
|
|
1934
|
+
status: "current",
|
|
1935
|
+
title: page.title,
|
|
1936
|
+
...page.parentId ? { parentId: page.parentId } : {},
|
|
1937
|
+
body: {
|
|
1938
|
+
representation: "atlas_doc_format",
|
|
1939
|
+
value: JSON.stringify(page.body)
|
|
1940
|
+
}
|
|
1941
|
+
});
|
|
1942
|
+
return {
|
|
1943
|
+
id: res.id,
|
|
1944
|
+
version: res.version?.number ?? 1
|
|
1945
|
+
};
|
|
1900
1946
|
}
|
|
1901
|
-
|
|
1902
|
-
|
|
1947
|
+
//#endregion
|
|
1948
|
+
//#region src/api/confluence-attachments.ts
|
|
1949
|
+
const UNKNOWN_SIZE = -1;
|
|
1950
|
+
async function listAttachments(client, id) {
|
|
1951
|
+
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
|
|
1952
|
+
filename: a.title ?? a.id,
|
|
1953
|
+
fileId: mediaNodeId(a),
|
|
1954
|
+
size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
|
|
1955
|
+
}));
|
|
1903
1956
|
}
|
|
1904
|
-
function
|
|
1905
|
-
return
|
|
1957
|
+
async function uploadAttachment(client, pageId, filename, bytes) {
|
|
1958
|
+
return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
|
|
1906
1959
|
}
|
|
1907
|
-
function
|
|
1908
|
-
|
|
1909
|
-
if (
|
|
1910
|
-
|
|
1960
|
+
async function fileIdByListing(client, pageId, filename) {
|
|
1961
|
+
const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
|
|
1962
|
+
if (match) return match.fileId;
|
|
1963
|
+
throw new Error(`Upload of "${filename}" did not return a fileId.`);
|
|
1911
1964
|
}
|
|
1912
|
-
function
|
|
1913
|
-
return
|
|
1965
|
+
async function fetchAttachments(client, id) {
|
|
1966
|
+
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
|
|
1967
|
+
mediaId: mediaNodeId(a),
|
|
1968
|
+
filename: a.title ?? a.id,
|
|
1969
|
+
url: withWikiContextPath(a.downloadLink ?? "")
|
|
1970
|
+
}));
|
|
1914
1971
|
}
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
const MAX_CODE_POINT = 1114111;
|
|
1918
|
-
const NAMED = {
|
|
1919
|
-
amp: "&",
|
|
1920
|
-
lt: "<",
|
|
1921
|
-
gt: ">",
|
|
1922
|
-
quot: "\"",
|
|
1923
|
-
apos: "'",
|
|
1924
|
-
nbsp: " "
|
|
1925
|
-
};
|
|
1926
|
-
function decodeEntities(text) {
|
|
1927
|
-
return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
|
|
1928
|
-
if (body[0] === "#") {
|
|
1929
|
-
const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
|
|
1930
|
-
return isCodePoint(code) ? String.fromCodePoint(code) : match;
|
|
1931
|
-
}
|
|
1932
|
-
return NAMED[body.toLowerCase()] ?? match;
|
|
1933
|
-
});
|
|
1972
|
+
function mediaNodeId(a) {
|
|
1973
|
+
return a.fileId ?? a.id;
|
|
1934
1974
|
}
|
|
1935
|
-
function
|
|
1936
|
-
|
|
1975
|
+
function withWikiContextPath(link) {
|
|
1976
|
+
if (link.startsWith("http") || link.startsWith("/wiki")) return link;
|
|
1977
|
+
return `/wiki${link}`;
|
|
1937
1978
|
}
|
|
1938
1979
|
//#endregion
|
|
1939
|
-
//#region src/api/confluence-
|
|
1940
|
-
async function
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
const
|
|
1950
|
-
const serverPageWasFull = results.length === limit;
|
|
1980
|
+
//#region src/api/confluence-pages.ts
|
|
1981
|
+
async function fetchPage(client, site, id) {
|
|
1982
|
+
const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
|
|
1983
|
+
const names = new UserNames(client);
|
|
1984
|
+
const [spaceKey, attachments, comments, author] = await Promise.all([
|
|
1985
|
+
fetchSpaceKey(client, page.spaceId),
|
|
1986
|
+
fetchAttachments(client, id),
|
|
1987
|
+
fetchComments$1(client, id, names),
|
|
1988
|
+
names.resolve(page.version?.authorId ?? page.authorId)
|
|
1989
|
+
]);
|
|
1990
|
+
const webui = page._links?.webui ?? "";
|
|
1951
1991
|
return {
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1992
|
+
id: page.id,
|
|
1993
|
+
title: page.title,
|
|
1994
|
+
spaceKey,
|
|
1995
|
+
version: page.version?.number ?? 0,
|
|
1996
|
+
author,
|
|
1997
|
+
createdAt: page.createdAt ?? "",
|
|
1998
|
+
updatedAt: page.version?.createdAt ?? "",
|
|
1999
|
+
url: webui ? `${site}/wiki${webui}` : "",
|
|
2000
|
+
body: parseAdfJson(page.body?.atlas_doc_format?.value),
|
|
2001
|
+
attachments,
|
|
2002
|
+
comments
|
|
1960
2003
|
};
|
|
1961
2004
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
return SPACE_URL.exec(displayUrl ?? "")?.[1] ?? "";
|
|
1965
|
-
}
|
|
1966
|
-
function buildCql(params) {
|
|
1967
|
-
return joinClauses([
|
|
1968
|
-
"type = page",
|
|
1969
|
-
params.starred ? "favourite = currentUser()" : null,
|
|
1970
|
-
inClause("space", params.space),
|
|
1971
|
-
inClause("label", params.label),
|
|
1972
|
-
params.updatedSince ? `lastmodified >= ${quote$1(params.updatedSince)}` : null,
|
|
1973
|
-
params.text ? `text ~ ${quote$1(params.text)}` : null
|
|
1974
|
-
], "lastmodified DESC");
|
|
1975
|
-
}
|
|
1976
|
-
//#endregion
|
|
1977
|
-
//#region src/adf/from-markdown.ts
|
|
1978
|
-
function mediaNode(source, alt) {
|
|
1979
|
-
const attrs = { ...source };
|
|
1980
|
-
if (alt) attrs["alt"] = alt;
|
|
2005
|
+
async function fetchPageState(client, id) {
|
|
2006
|
+
const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
|
|
1981
2007
|
return {
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
type: "media",
|
|
1986
|
-
attrs
|
|
1987
|
-
}]
|
|
2008
|
+
version: page.version?.number ?? 0,
|
|
2009
|
+
title: page.title,
|
|
2010
|
+
body: parseAdfJson(page.body?.atlas_doc_format?.value)
|
|
1988
2011
|
};
|
|
1989
2012
|
}
|
|
1990
|
-
function
|
|
1991
|
-
const
|
|
1992
|
-
|
|
1993
|
-
|
|
2013
|
+
async function updatePage(client, id, params) {
|
|
2014
|
+
const res = await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
|
|
2015
|
+
id,
|
|
2016
|
+
status: "current",
|
|
2017
|
+
title: params.title,
|
|
2018
|
+
body: {
|
|
2019
|
+
representation: "atlas_doc_format",
|
|
2020
|
+
value: JSON.stringify(params.body)
|
|
2021
|
+
},
|
|
2022
|
+
version: {
|
|
2023
|
+
number: params.nextVersion,
|
|
2024
|
+
message: params.message
|
|
2025
|
+
}
|
|
1994
2026
|
});
|
|
1995
|
-
return [...hrefs];
|
|
1996
|
-
}
|
|
1997
|
-
function markdownToAdf(md, options = {}) {
|
|
1998
|
-
const ctx = { resolveImage: options.resolveImage ?? externalMedia };
|
|
1999
2027
|
return {
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
content: blocks(marked.lexer(md), ctx)
|
|
2028
|
+
number: res.version?.number ?? params.nextVersion,
|
|
2029
|
+
createdAt: res.version?.createdAt ?? ""
|
|
2003
2030
|
};
|
|
2004
2031
|
}
|
|
2005
|
-
function
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
|
|
2032
|
+
async function fetchSpaceKey(client, spaceId) {
|
|
2033
|
+
if (!spaceId) return "";
|
|
2034
|
+
try {
|
|
2035
|
+
return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
|
|
2036
|
+
} catch {
|
|
2037
|
+
return "";
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
async function fetchComments$1(client, id, names) {
|
|
2041
|
+
const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
|
|
2042
|
+
return Promise.all(res.results.map(async (c) => ({
|
|
2043
|
+
author: await names.resolve(c.version?.authorId),
|
|
2044
|
+
created: c.version?.createdAt ?? "",
|
|
2045
|
+
body: parseAdfJson(c.body?.atlas_doc_format?.value)
|
|
2046
|
+
})));
|
|
2047
|
+
}
|
|
2048
|
+
function parseAdfJson(value) {
|
|
2049
|
+
if (!value) return null;
|
|
2050
|
+
try {
|
|
2051
|
+
return JSON.parse(value);
|
|
2052
|
+
} catch {
|
|
2053
|
+
return null;
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
var UserNames = class {
|
|
2057
|
+
client;
|
|
2058
|
+
cache = /* @__PURE__ */ new Map();
|
|
2059
|
+
constructor(client) {
|
|
2060
|
+
this.client = client;
|
|
2061
|
+
}
|
|
2062
|
+
async resolve(accountId) {
|
|
2063
|
+
if (!accountId) return "";
|
|
2064
|
+
const cached = this.cache.get(accountId);
|
|
2065
|
+
if (cached !== void 0) return cached;
|
|
2066
|
+
const name = await this.fetchDisplayName(accountId) ?? accountId;
|
|
2067
|
+
this.cache.set(accountId, name);
|
|
2068
|
+
return name;
|
|
2069
|
+
}
|
|
2070
|
+
async fetchDisplayName(accountId) {
|
|
2071
|
+
try {
|
|
2072
|
+
return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
|
|
2073
|
+
} catch {
|
|
2074
|
+
return null;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
//#endregion
|
|
2079
|
+
//#region src/adf/html.ts
|
|
2080
|
+
const COMMENT = /<!--[\s\S]*?-->/g;
|
|
2081
|
+
function isOnlyComments(html) {
|
|
2082
|
+
return html.replace(COMMENT, "").trim().length === 0;
|
|
2083
|
+
}
|
|
2084
|
+
function withoutComments(md) {
|
|
2085
|
+
const kept = [];
|
|
2086
|
+
let skipSpace = false;
|
|
2087
|
+
for (const token of marked.lexer(md)) {
|
|
2088
|
+
if (skipSpace && token.type === "space") continue;
|
|
2089
|
+
skipSpace = token.type === "html" && isOnlyComments(token.text);
|
|
2090
|
+
if (skipSpace) continue;
|
|
2091
|
+
kept.push(token.type === "code" ? token.raw : token.raw.replace(COMMENT, ""));
|
|
2092
|
+
}
|
|
2093
|
+
return kept.join("").trim();
|
|
2094
|
+
}
|
|
2095
|
+
function htmlWarnings(md) {
|
|
2096
|
+
let blocks = 0;
|
|
2097
|
+
let inline = 0;
|
|
2098
|
+
marked.walkTokens(marked.lexer(md), (token) => {
|
|
2099
|
+
if (token.type !== "html" || isOnlyComments(token.text)) return;
|
|
2100
|
+
if (token.block) blocks++;
|
|
2101
|
+
else inline++;
|
|
2102
|
+
});
|
|
2103
|
+
return [...blocks > 0 ? [`${counted(blocks, "HTML block")} will be dropped`] : [], ...inline > 0 ? [`${counted(inline, "inline HTML tag")} will be sent as text`] : []];
|
|
2104
|
+
}
|
|
2105
|
+
function counted(n, noun) {
|
|
2106
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
2107
|
+
}
|
|
2108
|
+
//#endregion
|
|
2109
|
+
//#region src/adf/from-markdown.ts
|
|
2110
|
+
function mediaNode(source, alt) {
|
|
2111
|
+
const attrs = { ...source };
|
|
2112
|
+
if (alt) attrs["alt"] = alt;
|
|
2113
|
+
return {
|
|
2114
|
+
type: "mediaSingle",
|
|
2115
|
+
attrs: { layout: "center" },
|
|
2116
|
+
content: [{
|
|
2117
|
+
type: "media",
|
|
2118
|
+
attrs
|
|
2119
|
+
}]
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
function imageHrefs(md) {
|
|
2123
|
+
const hrefs = /* @__PURE__ */ new Set();
|
|
2124
|
+
marked.walkTokens(marked.lexer(md), (token) => {
|
|
2125
|
+
if (token.type === "image") hrefs.add(token.href);
|
|
2126
|
+
});
|
|
2127
|
+
return [...hrefs];
|
|
2128
|
+
}
|
|
2129
|
+
function markdownToAdf(md, options = {}) {
|
|
2130
|
+
const ctx = { resolveImage: options.resolveImage ?? externalMedia };
|
|
2131
|
+
return {
|
|
2132
|
+
type: "doc",
|
|
2133
|
+
version: 1,
|
|
2134
|
+
content: blocks(marked.lexer(md), ctx)
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
function blocks(tokens, ctx) {
|
|
2138
|
+
const out = [];
|
|
2139
|
+
for (const token of tokens) out.push(...block(token, ctx));
|
|
2140
|
+
return out;
|
|
2141
|
+
}
|
|
2142
|
+
function block(token, ctx) {
|
|
2143
|
+
switch (token.type) {
|
|
2012
2144
|
case "space": return [];
|
|
2013
2145
|
case "heading": return [{
|
|
2014
2146
|
type: "heading",
|
|
@@ -2144,7 +2276,10 @@ function inlineNode(token, ctx, marks) {
|
|
|
2144
2276
|
}));
|
|
2145
2277
|
}
|
|
2146
2278
|
case "br": return [{ type: "hardBreak" }];
|
|
2147
|
-
case "html":
|
|
2279
|
+
case "html": {
|
|
2280
|
+
const html = token.text;
|
|
2281
|
+
return isOnlyComments(html) ? [] : textNode(html, marks);
|
|
2282
|
+
}
|
|
2148
2283
|
default: return "text" in token && token.text ? textNode(token.text, marks) : [];
|
|
2149
2284
|
}
|
|
2150
2285
|
}
|
|
@@ -2178,132 +2313,301 @@ function clampLevel(value) {
|
|
|
2178
2313
|
return Math.min(6, Math.max(1, Math.trunc(value) || 1));
|
|
2179
2314
|
}
|
|
2180
2315
|
//#endregion
|
|
2181
|
-
//#region src/
|
|
2182
|
-
const
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2316
|
+
//#region src/adf/lossy.ts
|
|
2317
|
+
const LOSSY_LABELS = {
|
|
2318
|
+
panel: "panel",
|
|
2319
|
+
expand: "expand",
|
|
2320
|
+
nestedExpand: "expand",
|
|
2321
|
+
decisionList: "decision list",
|
|
2322
|
+
layoutSection: "layout",
|
|
2323
|
+
extension: "macro",
|
|
2324
|
+
bodiedExtension: "macro",
|
|
2325
|
+
inlineExtension: "macro"
|
|
2326
|
+
};
|
|
2327
|
+
const JIRA_LOSSY_LABELS = {
|
|
2328
|
+
...LOSSY_LABELS,
|
|
2329
|
+
media: "image",
|
|
2330
|
+
mediaInline: "image"
|
|
2331
|
+
};
|
|
2332
|
+
function findLossyNodes(node, lossyLabels = LOSSY_LABELS) {
|
|
2333
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2334
|
+
const visit = (n) => {
|
|
2335
|
+
const label = lossyLabels[n.type];
|
|
2336
|
+
if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
2337
|
+
for (const child of n.content ?? []) visit(child);
|
|
2338
|
+
};
|
|
2339
|
+
if (node) visit(node);
|
|
2340
|
+
return counts;
|
|
2189
2341
|
}
|
|
2190
|
-
|
|
2191
|
-
return (
|
|
2342
|
+
function formatLossy(counts) {
|
|
2343
|
+
return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
|
|
2192
2344
|
}
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2345
|
+
//#endregion
|
|
2346
|
+
//#region src/update/body-diff.ts
|
|
2347
|
+
const CONTEXT_LINES = 3;
|
|
2348
|
+
function unifiedDiff(before, after, labels) {
|
|
2349
|
+
if (before === after) return [];
|
|
2350
|
+
const [from, to] = labels;
|
|
2351
|
+
const patch = structuredPatch(from, to, `${before}\n`, `${after}\n`, void 0, void 0, { context: CONTEXT_LINES });
|
|
2352
|
+
return [
|
|
2353
|
+
`--- ${from}`,
|
|
2354
|
+
`+++ ${to}`,
|
|
2355
|
+
...patch.hunks.flatMap((h) => [`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, ...h.lines])
|
|
2356
|
+
];
|
|
2197
2357
|
}
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2358
|
+
function colorDiffLine(line) {
|
|
2359
|
+
if (line.startsWith("+++") || line.startsWith("---")) return kleur.bold(line);
|
|
2360
|
+
if (line.startsWith("@@")) return kleur.cyan(line);
|
|
2361
|
+
if (line.startsWith("+")) return kleur.green(line);
|
|
2362
|
+
if (line.startsWith("-")) return kleur.red(line);
|
|
2363
|
+
return line;
|
|
2364
|
+
}
|
|
2365
|
+
//#endregion
|
|
2366
|
+
//#region src/update/plan.ts
|
|
2367
|
+
function planIssueUpdate(source, issue, options) {
|
|
2368
|
+
const images = imageHrefs(source.body).map((href) => ({
|
|
2369
|
+
href,
|
|
2370
|
+
kind: isExternalHref(href) ? "external" : "unsupported"
|
|
2203
2371
|
}));
|
|
2372
|
+
const unsupported = images.filter((i) => i.kind === "unsupported").map((i) => i.href);
|
|
2373
|
+
const body = markdownToAdf(source.body, { resolveImage: externalImagesOnly });
|
|
2374
|
+
const refusals = [...unsupported.length > 0 ? [`jira update does not support image changes yet. Remove local image reference(s) or edit text only: ${unsupported.join(", ")}`] : [], ...emptyBodyRefusal(body)];
|
|
2375
|
+
return withVerdict({
|
|
2376
|
+
noun: "issue",
|
|
2377
|
+
id: source.key,
|
|
2378
|
+
headline: headline("summary", issue.summary, source.title, options.summary),
|
|
2379
|
+
revision: revision(source.updatedAtCopy, issue.updated),
|
|
2380
|
+
lossy: findLossyNodes(issue.description, JIRA_LOSSY_LABELS),
|
|
2381
|
+
warnings: htmlWarnings(source.body),
|
|
2382
|
+
diff: [],
|
|
2383
|
+
images,
|
|
2384
|
+
uploads: [],
|
|
2385
|
+
body,
|
|
2386
|
+
refusals
|
|
2387
|
+
}, options.force ?? false);
|
|
2204
2388
|
}
|
|
2205
|
-
function
|
|
2206
|
-
|
|
2389
|
+
function formatPlan(plan) {
|
|
2390
|
+
const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
|
|
2391
|
+
if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
|
|
2392
|
+
const counts = imageCounts(plan.images);
|
|
2393
|
+
if (counts) lines.push(row("images", counts));
|
|
2394
|
+
for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
|
|
2395
|
+
if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
|
|
2396
|
+
for (const warning of plan.warnings) lines.push(row("warning", warning));
|
|
2397
|
+
if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
|
|
2398
|
+
lines.push(...plan.diff.map(colorDiffLine));
|
|
2399
|
+
lines.push(" nothing was written (dry run)");
|
|
2400
|
+
return lines;
|
|
2207
2401
|
}
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2402
|
+
const IMAGE_COUNT_LABELS = [
|
|
2403
|
+
["upload", "new"],
|
|
2404
|
+
["changed", "changed"],
|
|
2405
|
+
["reuse", "reused"],
|
|
2406
|
+
["external", "external"],
|
|
2407
|
+
["unsupported", "unsupported"],
|
|
2408
|
+
["missing", "missing"]
|
|
2409
|
+
];
|
|
2410
|
+
function imageCounts(images) {
|
|
2411
|
+
return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
|
|
2412
|
+
n: images.filter((i) => i.kind === kind).length,
|
|
2413
|
+
label
|
|
2414
|
+
})).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
|
|
2211
2415
|
}
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
const names = new UserNames(client);
|
|
2217
|
-
const [spaceKey, attachments, comments, author] = await Promise.all([
|
|
2218
|
-
fetchSpaceKey(client, page.spaceId),
|
|
2219
|
-
fetchAttachments(client, id),
|
|
2220
|
-
fetchComments$1(client, id, names),
|
|
2221
|
-
names.resolve(page.version?.authorId ?? page.authorId)
|
|
2222
|
-
]);
|
|
2223
|
-
const webui = page._links?.webui ?? "";
|
|
2416
|
+
function row(label, value) {
|
|
2417
|
+
return ` ${`${label}:`.padEnd(9)}${value}`;
|
|
2418
|
+
}
|
|
2419
|
+
function headline(label, current, title, useTitle) {
|
|
2224
2420
|
return {
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
version: page.version?.number ?? 0,
|
|
2229
|
-
author,
|
|
2230
|
-
createdAt: page.createdAt ?? "",
|
|
2231
|
-
updatedAt: page.version?.createdAt ?? "",
|
|
2232
|
-
url: webui ? `${site}/wiki${webui}` : "",
|
|
2233
|
-
body: parseAdfJson(page.body?.atlas_doc_format?.value),
|
|
2234
|
-
attachments,
|
|
2235
|
-
comments
|
|
2421
|
+
label,
|
|
2422
|
+
current,
|
|
2423
|
+
next: useTitle && title ? title : current
|
|
2236
2424
|
};
|
|
2237
2425
|
}
|
|
2238
|
-
|
|
2239
|
-
const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
|
|
2426
|
+
function revision(local, server) {
|
|
2240
2427
|
return {
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2428
|
+
local: local || "unknown",
|
|
2429
|
+
server: server || "unknown",
|
|
2430
|
+
stale: local !== server
|
|
2244
2431
|
};
|
|
2245
2432
|
}
|
|
2246
|
-
|
|
2247
|
-
return
|
|
2248
|
-
id,
|
|
2249
|
-
status: "current",
|
|
2250
|
-
title: params.title,
|
|
2251
|
-
body: {
|
|
2252
|
-
representation: "atlas_doc_format",
|
|
2253
|
-
value: JSON.stringify(params.body)
|
|
2254
|
-
},
|
|
2255
|
-
version: {
|
|
2256
|
-
number: params.nextVersion,
|
|
2257
|
-
message: params.message
|
|
2258
|
-
}
|
|
2259
|
-
})).version?.number ?? params.nextVersion;
|
|
2433
|
+
function revisionText(revision) {
|
|
2434
|
+
return `copied at ${revision.local}, server now ${revision.server}`;
|
|
2260
2435
|
}
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
try {
|
|
2264
|
-
return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
|
|
2265
|
-
} catch {
|
|
2266
|
-
return "";
|
|
2267
|
-
}
|
|
2436
|
+
function externalImagesOnly(href, alt) {
|
|
2437
|
+
return isExternalHref(href) ? externalMedia(href, alt) : void 0;
|
|
2268
2438
|
}
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
return Promise.all(res.results.map(async (c) => ({
|
|
2272
|
-
author: await names.resolve(c.version?.authorId),
|
|
2273
|
-
created: c.version?.createdAt ?? "",
|
|
2274
|
-
body: parseAdfJson(c.body?.atlas_doc_format?.value)
|
|
2275
|
-
})));
|
|
2439
|
+
function missingImagesRefusal(missing) {
|
|
2440
|
+
return missing.length > 0 ? [`Image file(s) not found: ${missing.join(", ")}`] : [];
|
|
2276
2441
|
}
|
|
2277
|
-
function
|
|
2278
|
-
if (
|
|
2442
|
+
function emptyBodyRefusal(body) {
|
|
2443
|
+
if (body.content && body.content.length > 0) return [];
|
|
2444
|
+
return ["Refusing to update: the converted body is empty."];
|
|
2445
|
+
}
|
|
2446
|
+
function withVerdict(plan, force) {
|
|
2447
|
+
return {
|
|
2448
|
+
...plan,
|
|
2449
|
+
verdict: verdict(plan, force)
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
function verdict(plan, force) {
|
|
2453
|
+
if (plan.refusals.length > 0) return {
|
|
2454
|
+
kind: "refuse",
|
|
2455
|
+
message: plan.refusals.join("\n")
|
|
2456
|
+
};
|
|
2457
|
+
const noun = plan.noun;
|
|
2458
|
+
if (plan.revision.stale && !force) return {
|
|
2459
|
+
kind: "refuse",
|
|
2460
|
+
message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
|
|
2461
|
+
};
|
|
2462
|
+
if (plan.lossy.size > 0 && !force) return {
|
|
2463
|
+
kind: "confirm",
|
|
2464
|
+
message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
|
|
2465
|
+
};
|
|
2466
|
+
return { kind: "proceed" };
|
|
2467
|
+
}
|
|
2468
|
+
function capitalize(text) {
|
|
2469
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
2470
|
+
}
|
|
2471
|
+
//#endregion
|
|
2472
|
+
//#region src/update/plan-page.ts
|
|
2473
|
+
function planPageBody(page, attachments, localImages) {
|
|
2474
|
+
const collection = `contentId-${page.id}`;
|
|
2475
|
+
const images = imageHrefs(page.body).map((href) => pageImage(href, localImages, attachments));
|
|
2476
|
+
const byHref = new Map(images.map((e) => [e.href, e]));
|
|
2477
|
+
return {
|
|
2478
|
+
images,
|
|
2479
|
+
body: markdownToAdf(page.body, { resolveImage: (href, alt) => {
|
|
2480
|
+
const entry = byHref.get(href);
|
|
2481
|
+
if (!entry || entry.kind === "missing") return void 0;
|
|
2482
|
+
if (entry.kind === "external") return externalMedia(href, alt);
|
|
2483
|
+
return mediaNode({
|
|
2484
|
+
type: "file",
|
|
2485
|
+
id: entry.kind === "reuse" ? entry.fileId : href,
|
|
2486
|
+
collection
|
|
2487
|
+
}, alt);
|
|
2488
|
+
} }),
|
|
2489
|
+
uploads: images.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
|
|
2490
|
+
href: e.href,
|
|
2491
|
+
path: e.path,
|
|
2492
|
+
filename: e.filename
|
|
2493
|
+
}] : [])
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
function planPageUpdate(source, state, attachments, localImages, options) {
|
|
2497
|
+
const { images, body, uploads } = planPageBody(source, attachments, localImages);
|
|
2498
|
+
const missing = images.filter((e) => e.kind === "missing").map((e) => e.href);
|
|
2499
|
+
return withVerdict({
|
|
2500
|
+
noun: "page",
|
|
2501
|
+
id: source.id,
|
|
2502
|
+
headline: headline("title", state.title, source.title, options.title),
|
|
2503
|
+
revision: revision(`v${source.version}`, `v${state.version}`),
|
|
2504
|
+
lossy: findLossyNodes(state.body),
|
|
2505
|
+
warnings: htmlWarnings(source.body),
|
|
2506
|
+
diff: unifiedDiff(serverMarkdown(state, attachments, images), withoutComments(source.body), [`server v${state.version}`, options.file]),
|
|
2507
|
+
images: images.map(({ href, kind }) => ({
|
|
2508
|
+
href,
|
|
2509
|
+
kind
|
|
2510
|
+
})),
|
|
2511
|
+
uploads,
|
|
2512
|
+
body,
|
|
2513
|
+
refusals: [...missingImagesRefusal(missing), ...emptyBodyRefusal(body)]
|
|
2514
|
+
}, options.force ?? false);
|
|
2515
|
+
}
|
|
2516
|
+
function serverMarkdown(state, attachments, images) {
|
|
2517
|
+
const reused = new Map(images.flatMap((e) => e.kind === "reuse" ? [[e.fileId, e.href]] : []));
|
|
2518
|
+
return adfToMarkdown(state.body, { resolveMedia: ({ id }) => reused.get(id ?? "") ?? attachments.find((a) => a.fileId === id)?.filename });
|
|
2519
|
+
}
|
|
2520
|
+
function pageImage(href, localImages, attachments) {
|
|
2521
|
+
if (isExternalHref(href)) return {
|
|
2522
|
+
href,
|
|
2523
|
+
kind: "external"
|
|
2524
|
+
};
|
|
2525
|
+
const local = localImages.find((l) => l.href === href);
|
|
2526
|
+
if (!local || local.size === void 0) return {
|
|
2527
|
+
href,
|
|
2528
|
+
kind: "missing"
|
|
2529
|
+
};
|
|
2530
|
+
const existing = attachments.find((a) => a.filename === local.filename);
|
|
2531
|
+
if (existing && existing.size === local.size) return {
|
|
2532
|
+
href,
|
|
2533
|
+
kind: "reuse",
|
|
2534
|
+
fileId: existing.fileId
|
|
2535
|
+
};
|
|
2536
|
+
return {
|
|
2537
|
+
href,
|
|
2538
|
+
kind: existing ? "changed" : "upload",
|
|
2539
|
+
path: local.path,
|
|
2540
|
+
filename: local.filename
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
function withUploadedIds(doc, ids) {
|
|
2544
|
+
if (!doc.content) return doc;
|
|
2545
|
+
return {
|
|
2546
|
+
...doc,
|
|
2547
|
+
content: doc.content.map((n) => withUploadedId(n, ids))
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
function withUploadedId(node, ids) {
|
|
2551
|
+
const id = node.type === "media" ? node.attrs?.["id"] : void 0;
|
|
2552
|
+
const swapped = typeof id === "string" && ids.has(id) ? {
|
|
2553
|
+
...node,
|
|
2554
|
+
attrs: {
|
|
2555
|
+
...node.attrs,
|
|
2556
|
+
id: ids.get(id)
|
|
2557
|
+
}
|
|
2558
|
+
} : node;
|
|
2559
|
+
if (!swapped.content) return swapped;
|
|
2560
|
+
return {
|
|
2561
|
+
...swapped,
|
|
2562
|
+
content: swapped.content.map((n) => withUploadedId(n, ids))
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
//#endregion
|
|
2566
|
+
//#region src/commands/confluence-push.ts
|
|
2567
|
+
async function pushPage({ session, term, files }, content, push) {
|
|
2568
|
+
const ids = /* @__PURE__ */ new Map();
|
|
2569
|
+
for (const upload of content.uploads) {
|
|
2570
|
+
term.err(`Uploading ${upload.filename} ...`);
|
|
2571
|
+
const bytes = await files.readBytes(upload.path);
|
|
2572
|
+
ids.set(upload.href, await uploadAttachment(session, push.id, upload.filename, bytes));
|
|
2573
|
+
}
|
|
2574
|
+
return updatePage(session, push.id, {
|
|
2575
|
+
title: content.title,
|
|
2576
|
+
nextVersion: push.nextVersion,
|
|
2577
|
+
body: withUploadedIds(content.body, ids),
|
|
2578
|
+
message: push.message
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
async function statLocalImages(files, dir, md) {
|
|
2582
|
+
const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
|
|
2583
|
+
return Promise.all(hrefs.map(async (href) => {
|
|
2584
|
+
const path = isAbsolute(href) ? href : resolve(dir, href);
|
|
2585
|
+
return {
|
|
2586
|
+
href,
|
|
2587
|
+
path,
|
|
2588
|
+
filename: basename(path),
|
|
2589
|
+
...await fileSize(files, path)
|
|
2590
|
+
};
|
|
2591
|
+
}));
|
|
2592
|
+
}
|
|
2593
|
+
async function fileSize(files, path) {
|
|
2279
2594
|
try {
|
|
2280
|
-
return
|
|
2595
|
+
return { size: await files.size(path) };
|
|
2281
2596
|
} catch {
|
|
2282
|
-
return
|
|
2597
|
+
return {};
|
|
2283
2598
|
}
|
|
2284
2599
|
}
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
this.cache.set(accountId, name);
|
|
2297
|
-
return name;
|
|
2298
|
-
}
|
|
2299
|
-
async fetchDisplayName(accountId) {
|
|
2300
|
-
try {
|
|
2301
|
-
return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
|
|
2302
|
-
} catch {
|
|
2303
|
-
return null;
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
};
|
|
2600
|
+
//#endregion
|
|
2601
|
+
//#region src/api/confluence-favourites.ts
|
|
2602
|
+
function favouritePath(id) {
|
|
2603
|
+
return `/wiki/rest/api/relation/favourite/from/user/current/to/content/${encodeURIComponent(id)}`;
|
|
2604
|
+
}
|
|
2605
|
+
async function starPage(client, id) {
|
|
2606
|
+
await client.putNoContent(favouritePath(id), void 0);
|
|
2607
|
+
}
|
|
2608
|
+
async function unstarPage(client, id) {
|
|
2609
|
+
await client.deleteNoContent(favouritePath(id));
|
|
2610
|
+
}
|
|
2307
2611
|
//#endregion
|
|
2308
2612
|
//#region src/commands/resolve-ref.ts
|
|
2309
2613
|
async function resolveRef(ask, arg, kind) {
|
|
@@ -2316,43 +2620,138 @@ async function resolveRef(ask, arg, kind) {
|
|
|
2316
2620
|
if (!ref) throw new Error(kind.notFound(raw));
|
|
2317
2621
|
return ref;
|
|
2318
2622
|
}
|
|
2623
|
+
function layoutOf(raw) {
|
|
2624
|
+
return {
|
|
2625
|
+
bom: raw.startsWith(""),
|
|
2626
|
+
crlf: /\r?\n/.exec(raw)?.[0] === "\r\n"
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
function normalized(raw) {
|
|
2630
|
+
return (raw.startsWith("") ? raw.slice(1) : raw).replace(/\r\n/g, "\n");
|
|
2631
|
+
}
|
|
2632
|
+
function editKeepingLayout(raw, edit) {
|
|
2633
|
+
return withLayout(edit(normalized(raw)), layoutOf(raw));
|
|
2634
|
+
}
|
|
2635
|
+
function withLayout(text, layout) {
|
|
2636
|
+
const lines = layout.crlf ? text.replace(/\n/g, "\r\n") : text;
|
|
2637
|
+
return layout.bom ? `${lines}` : lines;
|
|
2638
|
+
}
|
|
2319
2639
|
//#endregion
|
|
2320
|
-
//#region src/
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2640
|
+
//#region src/markdown/frontmatter.ts
|
|
2641
|
+
const BLOCK = /^---\n([\s\S]*?)\n---\n?/;
|
|
2642
|
+
const KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
|
|
2643
|
+
const LIST_ITEM = /^ {2}- (.*)$/;
|
|
2644
|
+
const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
|
|
2645
|
+
function splitFrontmatter(text) {
|
|
2646
|
+
const match = text.match(BLOCK);
|
|
2647
|
+
if (!match) return null;
|
|
2648
|
+
return {
|
|
2649
|
+
fields: parseFields(match[1] ?? ""),
|
|
2650
|
+
rest: text.slice(match[0].length)
|
|
2327
2651
|
};
|
|
2328
2652
|
}
|
|
2653
|
+
function formatFrontmatter(fields) {
|
|
2654
|
+
return [
|
|
2655
|
+
"---",
|
|
2656
|
+
...Object.entries(fields).flatMap(([key, value]) => fieldLines(key, value)),
|
|
2657
|
+
"---"
|
|
2658
|
+
].join("\n");
|
|
2659
|
+
}
|
|
2660
|
+
function rewriteFields(raw, fields) {
|
|
2661
|
+
return editKeepingLayout(raw, (text) => withFields(text, fields));
|
|
2662
|
+
}
|
|
2663
|
+
function withFields(text, fields) {
|
|
2664
|
+
const match = text.match(BLOCK);
|
|
2665
|
+
if (!match) return `${formatFrontmatter(fields)}\n\n${text}`;
|
|
2666
|
+
const lines = (match[1] ?? "").split("\n");
|
|
2667
|
+
const pending = new Map(Object.entries(fields));
|
|
2668
|
+
const kept = [];
|
|
2669
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2670
|
+
const line = lines[i] ?? "";
|
|
2671
|
+
const key = line.match(KEY)?.[1];
|
|
2672
|
+
const value = key === void 0 ? void 0 : pending.get(key);
|
|
2673
|
+
if (key === void 0 || value === void 0) {
|
|
2674
|
+
kept.push(line);
|
|
2675
|
+
continue;
|
|
2676
|
+
}
|
|
2677
|
+
kept.push(...fieldLines(key, value));
|
|
2678
|
+
pending.delete(key);
|
|
2679
|
+
while (LIST_ITEM.test(lines[i + 1] ?? "")) i++;
|
|
2680
|
+
}
|
|
2681
|
+
for (const [key, value] of pending) kept.push(...fieldLines(key, value));
|
|
2682
|
+
const closing = match[0].endsWith("\n") ? "\n" : "";
|
|
2683
|
+
return `---\n${kept.join("\n")}\n---${closing}${text.slice(match[0].length)}`;
|
|
2684
|
+
}
|
|
2685
|
+
function fieldLines(key, value) {
|
|
2686
|
+
if (Array.isArray(value)) return value.length === 0 ? [`${key}: []`] : [`${key}:`, ...value.map((v) => ` - ${quote$1(v)}`)];
|
|
2687
|
+
return [`${key}: ${typeof value === "number" ? value : quote$1(value)}`];
|
|
2688
|
+
}
|
|
2689
|
+
function parseFields(block) {
|
|
2690
|
+
const fields = {};
|
|
2691
|
+
const lines = block.split("\n");
|
|
2692
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2693
|
+
const m = lines[i]?.match(KEY);
|
|
2694
|
+
if (!m) continue;
|
|
2695
|
+
const key = m[1] ?? "";
|
|
2696
|
+
const raw = (m[2] ?? "").trim();
|
|
2697
|
+
if (raw === "[]") fields[key] = [];
|
|
2698
|
+
else if (raw === "") {
|
|
2699
|
+
const items = [];
|
|
2700
|
+
for (let item; item = lines[i + 1]?.match(LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
|
|
2701
|
+
fields[key] = items;
|
|
2702
|
+
} else fields[key] = scalar(raw);
|
|
2703
|
+
}
|
|
2704
|
+
return fields;
|
|
2705
|
+
}
|
|
2706
|
+
function scalar(raw) {
|
|
2707
|
+
if (raw.startsWith("\"")) return unquote(raw);
|
|
2708
|
+
if (BARE_NUMBER.test(raw)) return Number(raw);
|
|
2709
|
+
return raw;
|
|
2710
|
+
}
|
|
2711
|
+
function quote$1(value) {
|
|
2712
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
2713
|
+
}
|
|
2714
|
+
function unquote(value) {
|
|
2715
|
+
if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
|
|
2716
|
+
return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
2717
|
+
}
|
|
2329
2718
|
//#endregion
|
|
2330
2719
|
//#region src/markdown/copied-document.ts
|
|
2720
|
+
const GENERATED_MARKER = "<!-- atlass:generated -->";
|
|
2331
2721
|
const COMMENTS_HEADING = "## Comments";
|
|
2332
2722
|
const ATTACHMENTS_HEADING = "## Attachments";
|
|
2333
|
-
const
|
|
2334
|
-
const FRONTMATTER_KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
|
|
2335
|
-
const FRONTMATTER_LIST_ITEM = /^ {2}- (.*)$/;
|
|
2336
|
-
const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
|
|
2337
|
-
const TRAILING_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
|
|
2723
|
+
const LEGACY_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
|
|
2338
2724
|
function render(doc) {
|
|
2339
2725
|
return `${[
|
|
2340
|
-
|
|
2726
|
+
formatFrontmatter(doc.fields),
|
|
2341
2727
|
`# ${doc.title}`,
|
|
2342
2728
|
doc.body,
|
|
2729
|
+
GENERATED_MARKER,
|
|
2343
2730
|
commentsSection(doc.comments),
|
|
2344
2731
|
attachmentsSection(doc.attachments)
|
|
2345
2732
|
].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
|
|
2346
2733
|
}
|
|
2347
|
-
function parse(
|
|
2348
|
-
const
|
|
2349
|
-
if (!
|
|
2350
|
-
|
|
2351
|
-
|
|
2734
|
+
function parse(raw) {
|
|
2735
|
+
const split = splitFrontmatter(normalized(raw));
|
|
2736
|
+
if (!split) throw new Error("Not an atlass file: no YAML frontmatter found.");
|
|
2737
|
+
return sourceOf(split, "marker-or-legacy-heading");
|
|
2738
|
+
}
|
|
2739
|
+
function parseDraft(raw) {
|
|
2740
|
+
const text = normalized(raw);
|
|
2741
|
+
return sourceOf(splitFrontmatter(text) ?? {
|
|
2742
|
+
fields: {},
|
|
2743
|
+
rest: text
|
|
2744
|
+
}, "marker");
|
|
2745
|
+
}
|
|
2746
|
+
function withGeneratedMarker(text) {
|
|
2747
|
+
if (text.split("\n").some(isMarker)) return text;
|
|
2748
|
+
return `${text.trimEnd()}\n\n${GENERATED_MARKER}\n`;
|
|
2749
|
+
}
|
|
2750
|
+
function sourceOf({ fields, rest }, ending) {
|
|
2751
|
+
const lines = rest.split("\n");
|
|
2352
2752
|
const h1 = leadingH1(lines);
|
|
2353
2753
|
const start = h1 ? h1.index + 1 : 0;
|
|
2354
|
-
const
|
|
2355
|
-
const end = trailing === -1 ? lines.length : trailing;
|
|
2754
|
+
const end = bodyEnd(lines, start, ending);
|
|
2356
2755
|
const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
|
|
2357
2756
|
return {
|
|
2358
2757
|
fields,
|
|
@@ -2388,45 +2787,6 @@ function scalarString(value) {
|
|
|
2388
2787
|
if (typeof value === "number") return String(value);
|
|
2389
2788
|
return "";
|
|
2390
2789
|
}
|
|
2391
|
-
function frontmatter(fields) {
|
|
2392
|
-
const lines = ["---"];
|
|
2393
|
-
for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) {
|
|
2394
|
-
if (value.length === 0) lines.push(`${key}: []`);
|
|
2395
|
-
else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
|
|
2396
|
-
} else if (typeof value === "number") lines.push(`${key}: ${value}`);
|
|
2397
|
-
else lines.push(`${key}: ${quote(value)}`);
|
|
2398
|
-
lines.push("---");
|
|
2399
|
-
return lines.join("\n");
|
|
2400
|
-
}
|
|
2401
|
-
function parseFrontmatter(block) {
|
|
2402
|
-
const fields = {};
|
|
2403
|
-
const lines = block.split("\n");
|
|
2404
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2405
|
-
const m = lines[i]?.match(FRONTMATTER_KEY);
|
|
2406
|
-
if (!m) continue;
|
|
2407
|
-
const key = m[1] ?? "";
|
|
2408
|
-
const raw = (m[2] ?? "").trim();
|
|
2409
|
-
if (raw === "[]") fields[key] = [];
|
|
2410
|
-
else if (raw === "") {
|
|
2411
|
-
const items = [];
|
|
2412
|
-
for (let item; item = lines[i + 1]?.match(FRONTMATTER_LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
|
|
2413
|
-
fields[key] = items;
|
|
2414
|
-
} else fields[key] = scalar(raw);
|
|
2415
|
-
}
|
|
2416
|
-
return fields;
|
|
2417
|
-
}
|
|
2418
|
-
function scalar(raw) {
|
|
2419
|
-
if (raw.startsWith("\"")) return unquote(raw);
|
|
2420
|
-
if (BARE_NUMBER.test(raw)) return Number(raw);
|
|
2421
|
-
return raw;
|
|
2422
|
-
}
|
|
2423
|
-
function quote(value) {
|
|
2424
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
2425
|
-
}
|
|
2426
|
-
function unquote(value) {
|
|
2427
|
-
if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
|
|
2428
|
-
return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
2429
|
-
}
|
|
2430
2790
|
function leadingH1(lines) {
|
|
2431
2791
|
for (const [index, line] of lines.entries()) {
|
|
2432
2792
|
if (line.startsWith("# ")) return {
|
|
@@ -2437,6 +2797,16 @@ function leadingH1(lines) {
|
|
|
2437
2797
|
}
|
|
2438
2798
|
return null;
|
|
2439
2799
|
}
|
|
2800
|
+
function bodyEnd(lines, start, ending) {
|
|
2801
|
+
const after = (test) => lines.findIndex((line, i) => i >= start && test(line));
|
|
2802
|
+
const marker = after(isMarker);
|
|
2803
|
+
if (marker !== -1) return marker;
|
|
2804
|
+
const legacy = ending === "marker" ? -1 : after((line) => LEGACY_SECTION_HEADING.test(line));
|
|
2805
|
+
return legacy === -1 ? lines.length : legacy;
|
|
2806
|
+
}
|
|
2807
|
+
function isMarker(line) {
|
|
2808
|
+
return line.trimEnd() === GENERATED_MARKER;
|
|
2809
|
+
}
|
|
2440
2810
|
function commentsSection(comments) {
|
|
2441
2811
|
if (comments.length === 0) return "";
|
|
2442
2812
|
const blocks = comments.map((c) => {
|
|
@@ -2459,6 +2829,44 @@ function attachmentsSection(attachments) {
|
|
|
2459
2829
|
].join("\n");
|
|
2460
2830
|
}
|
|
2461
2831
|
//#endregion
|
|
2832
|
+
//#region src/commands/page-ref.ts
|
|
2833
|
+
const PAGE_REF = {
|
|
2834
|
+
message: "Confluence page id or URL:",
|
|
2835
|
+
flag: "[page]",
|
|
2836
|
+
parse: parsePageId,
|
|
2837
|
+
notFound: (raw) => `Could not find a page id in "${raw}".`
|
|
2838
|
+
};
|
|
2839
|
+
async function pageIdOrFile({ term, files }, arg) {
|
|
2840
|
+
if (!arg?.endsWith(".md")) return resolveRef(term.ask, arg, PAGE_REF);
|
|
2841
|
+
const id = scalarString(parse(await files.readText(arg)).fields["id"]);
|
|
2842
|
+
if (!id) throw new Error(`${arg} has no page \`id\` in its frontmatter.`);
|
|
2843
|
+
return id;
|
|
2844
|
+
}
|
|
2845
|
+
//#endregion
|
|
2846
|
+
//#region src/commands/confluence-star.ts
|
|
2847
|
+
async function confluenceStar(env, arg) {
|
|
2848
|
+
await starById(env, await pageIdOrFile(env, arg));
|
|
2849
|
+
}
|
|
2850
|
+
async function starById({ session, term }, id) {
|
|
2851
|
+
await starPage(session, id);
|
|
2852
|
+
term.out(`Starred page ${id}.`);
|
|
2853
|
+
}
|
|
2854
|
+
async function confluenceUnstar(env, arg) {
|
|
2855
|
+
const id = await pageIdOrFile(env, arg);
|
|
2856
|
+
await unstarPage(env.session, id);
|
|
2857
|
+
env.term.out(`Unstarred page ${id}.`);
|
|
2858
|
+
}
|
|
2859
|
+
//#endregion
|
|
2860
|
+
//#region src/api/attachments.ts
|
|
2861
|
+
function mediaResolver(downloaded) {
|
|
2862
|
+
const byMediaId = new Map(downloaded.map((d) => [d.mediaId, d.relativePath]));
|
|
2863
|
+
const byFilename = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
|
|
2864
|
+
return (media) => {
|
|
2865
|
+
if (media.id && byMediaId.has(media.id)) return byMediaId.get(media.id);
|
|
2866
|
+
if (media.alt && byFilename.has(media.alt)) return byFilename.get(media.alt);
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
//#endregion
|
|
2462
2870
|
//#region src/copy/plan.ts
|
|
2463
2871
|
function planIssueCopy(issue, out) {
|
|
2464
2872
|
return planCopy({
|
|
@@ -2481,19 +2889,22 @@ function planIssueCopy(issue, out) {
|
|
|
2481
2889
|
attachments: issue.attachments
|
|
2482
2890
|
}, out);
|
|
2483
2891
|
}
|
|
2892
|
+
function pageFields(page) {
|
|
2893
|
+
return {
|
|
2894
|
+
title: page.title,
|
|
2895
|
+
id: page.id,
|
|
2896
|
+
space: page.spaceKey,
|
|
2897
|
+
version: page.version,
|
|
2898
|
+
author: page.author,
|
|
2899
|
+
created: page.createdAt,
|
|
2900
|
+
updated: page.updatedAt,
|
|
2901
|
+
url: page.url
|
|
2902
|
+
};
|
|
2903
|
+
}
|
|
2484
2904
|
function planPageCopy(page, out) {
|
|
2485
2905
|
return planCopy({
|
|
2486
2906
|
baseName: `${page.id}-${slugify(page.title)}`,
|
|
2487
|
-
fields:
|
|
2488
|
-
title: page.title,
|
|
2489
|
-
id: page.id,
|
|
2490
|
-
space: page.spaceKey,
|
|
2491
|
-
version: page.version,
|
|
2492
|
-
author: page.author,
|
|
2493
|
-
created: page.createdAt,
|
|
2494
|
-
updated: page.updatedAt,
|
|
2495
|
-
url: page.url
|
|
2496
|
-
},
|
|
2907
|
+
fields: pageFields(page),
|
|
2497
2908
|
title: page.title,
|
|
2498
2909
|
body: page.body,
|
|
2499
2910
|
comments: page.comments,
|
|
@@ -2564,246 +2975,240 @@ function numberedName(name, suffix) {
|
|
|
2564
2975
|
return `${dot > 0 ? name.slice(0, dot) : name}-${suffix}${dot > 0 ? name.slice(dot) : ""}`;
|
|
2565
2976
|
}
|
|
2566
2977
|
//#endregion
|
|
2567
|
-
//#region src/
|
|
2568
|
-
|
|
2569
|
-
const
|
|
2570
|
-
await env.files.writeText(plan.filePath, renderCopy(plan, landed));
|
|
2571
|
-
env.term.out(wroteLine(plan.filePath, landed.length));
|
|
2572
|
-
}
|
|
2573
|
-
async function download({ term, files, session }, plan) {
|
|
2574
|
-
const landed = [];
|
|
2575
|
-
for (const { path, ...attachment } of plan.downloads) try {
|
|
2576
|
-
await files.writeBytes(path, await session.getBinary(attachment.url));
|
|
2577
|
-
landed.push(attachment);
|
|
2578
|
-
} catch (err) {
|
|
2579
|
-
term.err(` ! could not download ${attachment.filename}: ${err.message}`);
|
|
2580
|
-
}
|
|
2581
|
-
return landed;
|
|
2582
|
-
}
|
|
2583
|
-
function wroteLine(filePath, attachmentCount) {
|
|
2584
|
-
return `Wrote ${filePath}${attachmentCount > 0 ? ` (+${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})` : ""}`;
|
|
2585
|
-
}
|
|
2586
|
-
//#endregion
|
|
2587
|
-
//#region src/adf/lossy.ts
|
|
2588
|
-
const LOSSY_LABELS = {
|
|
2589
|
-
panel: "panel",
|
|
2590
|
-
expand: "expand",
|
|
2591
|
-
nestedExpand: "expand",
|
|
2592
|
-
decisionList: "decision list",
|
|
2593
|
-
layoutSection: "layout",
|
|
2594
|
-
extension: "macro",
|
|
2595
|
-
bodiedExtension: "macro",
|
|
2596
|
-
inlineExtension: "macro"
|
|
2597
|
-
};
|
|
2598
|
-
const JIRA_LOSSY_LABELS = {
|
|
2599
|
-
...LOSSY_LABELS,
|
|
2600
|
-
media: "image",
|
|
2601
|
-
mediaInline: "image"
|
|
2602
|
-
};
|
|
2603
|
-
function findLossyNodes(node, lossyLabels = LOSSY_LABELS) {
|
|
2604
|
-
const counts = /* @__PURE__ */ new Map();
|
|
2605
|
-
const visit = (n) => {
|
|
2606
|
-
const label = lossyLabels[n.type];
|
|
2607
|
-
if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
2608
|
-
for (const child of n.content ?? []) visit(child);
|
|
2609
|
-
};
|
|
2610
|
-
if (node) visit(node);
|
|
2611
|
-
return counts;
|
|
2612
|
-
}
|
|
2613
|
-
function formatLossy(counts) {
|
|
2614
|
-
return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
|
|
2615
|
-
}
|
|
2616
|
-
//#endregion
|
|
2617
|
-
//#region src/update/plan.ts
|
|
2618
|
-
function planIssueUpdate(source, issue, options) {
|
|
2619
|
-
const images = imageHrefs(source.body).map((href) => ({
|
|
2978
|
+
//#region src/create/page-plan.ts
|
|
2979
|
+
function planPageCreate(draft, target, localImages) {
|
|
2980
|
+
const images = imageHrefs(draft.body).map((href) => ({
|
|
2620
2981
|
href,
|
|
2621
|
-
kind:
|
|
2982
|
+
kind: imageKind(href, localImages)
|
|
2622
2983
|
}));
|
|
2623
|
-
const
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
noun: "issue",
|
|
2628
|
-
id: source.key,
|
|
2629
|
-
headline: headline("summary", issue.summary, source.title, options.summary),
|
|
2630
|
-
revision: revision(source.updatedAtCopy, issue.updated),
|
|
2631
|
-
lossy: findLossyNodes(issue.description, JIRA_LOSSY_LABELS),
|
|
2984
|
+
const missing = images.filter((i) => i.kind === "missing").map((i) => i.href);
|
|
2985
|
+
return {
|
|
2986
|
+
...target,
|
|
2987
|
+
title: draft.title,
|
|
2632
2988
|
images,
|
|
2633
|
-
|
|
2634
|
-
body,
|
|
2635
|
-
refusals
|
|
2636
|
-
|
|
2989
|
+
body: markdownToAdf(draft.body, { resolveImage: externalImagesOnly }),
|
|
2990
|
+
warnings: htmlWarnings(draft.body),
|
|
2991
|
+
refusals: [
|
|
2992
|
+
...draft.title ? [] : ["No title. Add an H1 or a `title` to the frontmatter."],
|
|
2993
|
+
...spaceRefusal(target),
|
|
2994
|
+
...missingImagesRefusal(missing)
|
|
2995
|
+
]
|
|
2996
|
+
};
|
|
2637
2997
|
}
|
|
2638
|
-
function
|
|
2639
|
-
const lines = [`Dry run for
|
|
2640
|
-
if (plan.
|
|
2998
|
+
function formatCreatePlan(plan) {
|
|
2999
|
+
const lines = [`Dry run for new page "${plan.title}"`];
|
|
3000
|
+
if (plan.spaceId) lines.push(row("space", plan.space));
|
|
3001
|
+
lines.push(row("parent", plan.parentId ?? "space homepage"));
|
|
2641
3002
|
const counts = imageCounts(plan.images);
|
|
2642
3003
|
if (counts) lines.push(row("images", counts));
|
|
2643
3004
|
for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
|
|
2644
|
-
|
|
2645
|
-
if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
|
|
3005
|
+
for (const warning of plan.warnings) lines.push(row("warning", warning));
|
|
2646
3006
|
lines.push(" nothing was written (dry run)");
|
|
2647
3007
|
return lines;
|
|
2648
3008
|
}
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
["reuse", "reused"],
|
|
2653
|
-
["external", "external"],
|
|
2654
|
-
["unsupported", "unsupported"],
|
|
2655
|
-
["missing", "missing"]
|
|
2656
|
-
];
|
|
2657
|
-
function imageCounts(images) {
|
|
2658
|
-
return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
|
|
2659
|
-
n: images.filter((i) => i.kind === kind).length,
|
|
2660
|
-
label
|
|
2661
|
-
})).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
|
|
3009
|
+
function imageKind(href, localImages) {
|
|
3010
|
+
if (isExternalHref(href)) return "external";
|
|
3011
|
+
return localImages.find((l) => l.href === href)?.size === void 0 ? "missing" : "upload";
|
|
2662
3012
|
}
|
|
2663
|
-
function
|
|
2664
|
-
return `
|
|
3013
|
+
function spaceRefusal({ space, spaceId }) {
|
|
3014
|
+
if (!space) return ["No space. Pass --space or add `space` to the frontmatter."];
|
|
3015
|
+
return spaceId ? [] : [`Space "${space}" not found.`];
|
|
2665
3016
|
}
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
3017
|
+
//#endregion
|
|
3018
|
+
//#region src/commands/confluence-create.ts
|
|
3019
|
+
async function confluenceCreate(env, arg, options) {
|
|
3020
|
+
const { session, term, files } = env;
|
|
3021
|
+
const file = arg ?? await term.ask.text({
|
|
3022
|
+
message: "Path to the Markdown file:",
|
|
3023
|
+
flag: "[file]",
|
|
3024
|
+
required: true
|
|
3025
|
+
});
|
|
3026
|
+
const raw = await files.readText(file);
|
|
3027
|
+
const draft = parseDraft(raw);
|
|
3028
|
+
const existing = scalarString(draft.fields["id"]);
|
|
3029
|
+
if (existing) throw new Error(`${file} already has page id ${existing}. Use \`atlass confluence update\` instead.`);
|
|
3030
|
+
const parent = parentOf(options.parent);
|
|
3031
|
+
const space = options.space ?? scalarString(draft.fields["space"]);
|
|
3032
|
+
const spaceId = space ? await findSpaceId(session, space) : null;
|
|
3033
|
+
const localImages = await statLocalImages(files, dirname(resolve(file)), draft.body);
|
|
3034
|
+
const plan = planPageCreate(draft, {
|
|
3035
|
+
space,
|
|
3036
|
+
spaceId,
|
|
3037
|
+
...parent
|
|
3038
|
+
}, localImages);
|
|
3039
|
+
if (options.dryRun) term.out(formatCreatePlan(plan));
|
|
3040
|
+
if (plan.refusals.length > 0 || !plan.spaceId) throw new Error(plan.refusals.join("\n"));
|
|
3041
|
+
if (options.dryRun) return;
|
|
3042
|
+
for (const warning of plan.warnings) term.err(`warning: ${warning}`);
|
|
3043
|
+
const created = await createPage(session, {
|
|
3044
|
+
...plan,
|
|
3045
|
+
spaceId: plan.spaceId
|
|
3046
|
+
});
|
|
3047
|
+
const createdFields = {
|
|
3048
|
+
title: plan.title,
|
|
3049
|
+
id: created.id,
|
|
3050
|
+
space,
|
|
3051
|
+
version: created.version
|
|
2671
3052
|
};
|
|
3053
|
+
await files.writeText(file, editKeepingLayout(raw, (text) => withGeneratedMarker(withFields(text, createdFields))));
|
|
3054
|
+
if (plan.images.some((i) => i.kind === "upload")) await addImages(env, draft, created, localImages);
|
|
3055
|
+
const page = await fetchPage(session, session.site, created.id);
|
|
3056
|
+
await files.writeText(file, rewriteFields(await files.readText(file), pageFields(page)));
|
|
3057
|
+
term.out(`Created page ${page.id} in ${page.spaceKey}: ${page.url}`);
|
|
3058
|
+
if (options.star) await starCreated(env, page.id, file);
|
|
3059
|
+
}
|
|
3060
|
+
function parentOf(parent) {
|
|
3061
|
+
if (parent === void 0) return {};
|
|
3062
|
+
const parentId = parsePageId(parent);
|
|
3063
|
+
if (!parentId) throw new Error(`Could not find a page id in --parent "${parent}".`);
|
|
3064
|
+
return { parentId };
|
|
3065
|
+
}
|
|
3066
|
+
async function addImages(env, draft, created, localImages) {
|
|
3067
|
+
const { body, uploads } = planPageBody({
|
|
3068
|
+
id: created.id,
|
|
3069
|
+
body: draft.body
|
|
3070
|
+
}, [], localImages);
|
|
3071
|
+
await pushPage(env, {
|
|
3072
|
+
title: draft.title,
|
|
3073
|
+
body,
|
|
3074
|
+
uploads
|
|
3075
|
+
}, {
|
|
3076
|
+
id: created.id,
|
|
3077
|
+
nextVersion: created.version + 1,
|
|
3078
|
+
message: "Added images via atlass"
|
|
3079
|
+
});
|
|
2672
3080
|
}
|
|
2673
|
-
function
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
3081
|
+
async function starCreated(env, id, file) {
|
|
3082
|
+
try {
|
|
3083
|
+
await starById(env, id);
|
|
3084
|
+
} catch (err) {
|
|
3085
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3086
|
+
throw new Error(`Created page ${id} but could not star it: ${reason}. Run \`atlass confluence star ${file}\`.`);
|
|
3087
|
+
}
|
|
2679
3088
|
}
|
|
2680
|
-
|
|
2681
|
-
|
|
3089
|
+
//#endregion
|
|
3090
|
+
//#region src/api/query.ts
|
|
3091
|
+
function quote(value) {
|
|
3092
|
+
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
2682
3093
|
}
|
|
2683
|
-
function
|
|
2684
|
-
|
|
2685
|
-
return ["Refusing to update: the converted body is empty."];
|
|
3094
|
+
function values(given) {
|
|
3095
|
+
return (given ?? []).map((v) => v.trim()).filter((v) => v.length > 0);
|
|
2686
3096
|
}
|
|
2687
|
-
function
|
|
2688
|
-
return
|
|
2689
|
-
...plan,
|
|
2690
|
-
verdict: verdict(plan, force)
|
|
2691
|
-
};
|
|
3097
|
+
function inClause(field, given) {
|
|
3098
|
+
return renderClause(field, values(given).map(quote));
|
|
2692
3099
|
}
|
|
2693
|
-
function
|
|
2694
|
-
|
|
2695
|
-
kind: "refuse",
|
|
2696
|
-
message: plan.refusals.join("\n")
|
|
2697
|
-
};
|
|
2698
|
-
const noun = plan.noun;
|
|
2699
|
-
if (plan.revision.stale && !force) return {
|
|
2700
|
-
kind: "refuse",
|
|
2701
|
-
message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
|
|
2702
|
-
};
|
|
2703
|
-
if (plan.lossy.size > 0 && !force) return {
|
|
2704
|
-
kind: "confirm",
|
|
2705
|
-
message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
|
|
2706
|
-
};
|
|
2707
|
-
return { kind: "proceed" };
|
|
3100
|
+
function userClause(field, given) {
|
|
3101
|
+
return renderClause(field, values(given).map((v) => v === "me" ? "currentUser()" : quote(v)));
|
|
2708
3102
|
}
|
|
2709
|
-
function
|
|
2710
|
-
|
|
3103
|
+
function anyOf(terms) {
|
|
3104
|
+
if (terms.length === 0) return null;
|
|
3105
|
+
if (terms.length === 1) return terms[0];
|
|
3106
|
+
return `(${terms.join(" OR ")})`;
|
|
3107
|
+
}
|
|
3108
|
+
function renderClause(field, rendered) {
|
|
3109
|
+
if (rendered.length === 0) return null;
|
|
3110
|
+
if (rendered.length === 1) return `${field} = ${rendered[0]}`;
|
|
3111
|
+
return `${field} in (${rendered.join(", ")})`;
|
|
3112
|
+
}
|
|
3113
|
+
function joinClauses(clauses, order) {
|
|
3114
|
+
return `${clauses.filter((c) => c !== null).join(" AND ")} ORDER BY ${order}`;
|
|
2711
3115
|
}
|
|
2712
3116
|
//#endregion
|
|
2713
|
-
//#region src/
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
noun: "page",
|
|
2732
|
-
id: source.id,
|
|
2733
|
-
headline: headline("title", state.title, source.title, options.title),
|
|
2734
|
-
revision: revision(`v${source.version}`, `v${state.version}`),
|
|
2735
|
-
lossy: findLossyNodes(state.body),
|
|
2736
|
-
images: entries.map(({ href, kind }) => ({
|
|
2737
|
-
href,
|
|
2738
|
-
kind
|
|
2739
|
-
})),
|
|
2740
|
-
uploads: entries.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
|
|
2741
|
-
href: e.href,
|
|
2742
|
-
path: e.path,
|
|
2743
|
-
filename: e.filename
|
|
2744
|
-
}] : []),
|
|
2745
|
-
body,
|
|
2746
|
-
refusals
|
|
2747
|
-
}, options.force ?? false);
|
|
3117
|
+
//#region src/util/html.ts
|
|
3118
|
+
const MAX_CODE_POINT = 1114111;
|
|
3119
|
+
const NAMED = {
|
|
3120
|
+
amp: "&",
|
|
3121
|
+
lt: "<",
|
|
3122
|
+
gt: ">",
|
|
3123
|
+
quot: "\"",
|
|
3124
|
+
apos: "'",
|
|
3125
|
+
nbsp: " "
|
|
3126
|
+
};
|
|
3127
|
+
function decodeEntities(text) {
|
|
3128
|
+
return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
|
|
3129
|
+
if (body[0] === "#") {
|
|
3130
|
+
const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
|
|
3131
|
+
return isCodePoint(code) ? String.fromCodePoint(code) : match;
|
|
3132
|
+
}
|
|
3133
|
+
return NAMED[body.toLowerCase()] ?? match;
|
|
3134
|
+
});
|
|
2748
3135
|
}
|
|
2749
|
-
function
|
|
2750
|
-
|
|
2751
|
-
href,
|
|
2752
|
-
kind: "external"
|
|
2753
|
-
};
|
|
2754
|
-
const local = localImages.find((l) => l.href === href);
|
|
2755
|
-
if (!local || local.size === void 0) return {
|
|
2756
|
-
href,
|
|
2757
|
-
kind: "missing"
|
|
2758
|
-
};
|
|
2759
|
-
const existing = attachments.find((a) => a.filename === local.filename);
|
|
2760
|
-
if (existing && existing.size === local.size) return {
|
|
2761
|
-
href,
|
|
2762
|
-
kind: "reuse",
|
|
2763
|
-
fileId: existing.fileId
|
|
2764
|
-
};
|
|
2765
|
-
return {
|
|
2766
|
-
href,
|
|
2767
|
-
kind: existing ? "changed" : "upload",
|
|
2768
|
-
path: local.path,
|
|
2769
|
-
filename: local.filename
|
|
2770
|
-
};
|
|
3136
|
+
function isCodePoint(code) {
|
|
3137
|
+
return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
|
|
2771
3138
|
}
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
content: doc.content.map((n) => withUploadedId(n, ids))
|
|
2777
|
-
};
|
|
3139
|
+
//#endregion
|
|
3140
|
+
//#region src/api/confluence-search.ts
|
|
3141
|
+
async function searchPages(client, site, params) {
|
|
3142
|
+
return searchPagesByCql(client, site, buildCql(params), params.limit);
|
|
2778
3143
|
}
|
|
2779
|
-
function
|
|
2780
|
-
const
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
} : node;
|
|
2788
|
-
if (!swapped.content) return swapped;
|
|
3144
|
+
async function searchPagesByCql(client, site, cql, limit) {
|
|
3145
|
+
const query = new URLSearchParams({
|
|
3146
|
+
cql,
|
|
3147
|
+
limit: String(limit),
|
|
3148
|
+
expand: "content.space"
|
|
3149
|
+
});
|
|
3150
|
+
const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
|
|
3151
|
+
const serverPageWasFull = results.length === limit;
|
|
2789
3152
|
return {
|
|
2790
|
-
|
|
2791
|
-
|
|
3153
|
+
pages: results.filter((r) => r.content?.id).map((r) => ({
|
|
3154
|
+
id: r.content?.id ?? "",
|
|
3155
|
+
space: r.content?.space?.key ?? spaceKeyOf(r.resultGlobalContainer?.displayUrl),
|
|
3156
|
+
title: decodeEntities(r.content?.title ?? r.title ?? ""),
|
|
3157
|
+
updated: r.lastModified ?? "",
|
|
3158
|
+
url: r.url ? `${site}/wiki${r.url}` : ""
|
|
3159
|
+
})),
|
|
3160
|
+
hasMore: serverPageWasFull
|
|
2792
3161
|
};
|
|
2793
3162
|
}
|
|
3163
|
+
const SPACE_URL = /\/spaces\/([^/?#]+)/;
|
|
3164
|
+
function spaceKeyOf(displayUrl) {
|
|
3165
|
+
return SPACE_URL.exec(displayUrl ?? "")?.[1] ?? "";
|
|
3166
|
+
}
|
|
3167
|
+
function buildCql(params) {
|
|
3168
|
+
return joinClauses([
|
|
3169
|
+
"type = page",
|
|
3170
|
+
params.starred ? "favourite = currentUser()" : null,
|
|
3171
|
+
inClause("space", params.space),
|
|
3172
|
+
inClause("label", params.label),
|
|
3173
|
+
params.updatedSince ? `lastmodified >= ${quote(params.updatedSince)}` : null,
|
|
3174
|
+
params.text ? `text ~ ${quote(params.text)}` : null
|
|
3175
|
+
], "lastmodified DESC");
|
|
3176
|
+
}
|
|
3177
|
+
//#endregion
|
|
3178
|
+
//#region src/copy/run.ts
|
|
3179
|
+
async function runCopy(env, plan) {
|
|
3180
|
+
const landed = await download(env, plan);
|
|
3181
|
+
await env.files.writeText(plan.filePath, renderCopy(plan, landed));
|
|
3182
|
+
env.term.out(wroteLine(plan.filePath, landed.length));
|
|
3183
|
+
}
|
|
3184
|
+
async function download({ term, files, session }, plan) {
|
|
3185
|
+
const landed = [];
|
|
3186
|
+
for (const { path, ...attachment } of plan.downloads) try {
|
|
3187
|
+
await files.writeBytes(path, await session.getBinary(attachment.url));
|
|
3188
|
+
landed.push(attachment);
|
|
3189
|
+
} catch (err) {
|
|
3190
|
+
term.err(` ! could not download ${attachment.filename}: ${err.message}`);
|
|
3191
|
+
}
|
|
3192
|
+
return landed;
|
|
3193
|
+
}
|
|
3194
|
+
function wroteLine(filePath, attachmentCount) {
|
|
3195
|
+
return `Wrote ${filePath}${attachmentCount > 0 ? ` (+${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})` : ""}`;
|
|
3196
|
+
}
|
|
2794
3197
|
//#endregion
|
|
2795
3198
|
//#region src/update/run.ts
|
|
2796
3199
|
async function runPlan(term, plan, options, push) {
|
|
3200
|
+
const verdict = plan.verdict;
|
|
2797
3201
|
if (options.dryRun) {
|
|
2798
3202
|
term.out(formatPlan(plan));
|
|
3203
|
+
if (verdict.kind === "refuse") throw new Error(verdict.message);
|
|
3204
|
+
if (verdict.kind === "confirm" && !term.interactive) throw new NotInteractiveError(forcePrompt(verdict.message));
|
|
2799
3205
|
return;
|
|
2800
3206
|
}
|
|
2801
|
-
const
|
|
3207
|
+
for (const warning of plan.warnings) term.err(`warning: ${warning}`);
|
|
2802
3208
|
if (verdict.kind === "refuse") throw new Error(verdict.message);
|
|
2803
3209
|
if (verdict.kind === "confirm") {
|
|
2804
3210
|
if (!await term.ask.confirm({
|
|
2805
|
-
|
|
2806
|
-
flag: "--force",
|
|
3211
|
+
...forcePrompt(verdict.message),
|
|
2807
3212
|
default: false
|
|
2808
3213
|
})) {
|
|
2809
3214
|
term.out("Aborted.");
|
|
@@ -2812,6 +3217,12 @@ async function runPlan(term, plan, options, push) {
|
|
|
2812
3217
|
}
|
|
2813
3218
|
await push();
|
|
2814
3219
|
}
|
|
3220
|
+
function forcePrompt(message) {
|
|
3221
|
+
return {
|
|
3222
|
+
message,
|
|
3223
|
+
flag: "--force"
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
2815
3226
|
//#endregion
|
|
2816
3227
|
//#region src/commands/confluence.ts
|
|
2817
3228
|
async function confluenceView({ session, term }, arg, options) {
|
|
@@ -2822,7 +3233,7 @@ async function confluenceView({ session, term }, arg, options) {
|
|
|
2822
3233
|
function formatPageView(page, nowMs, allComments) {
|
|
2823
3234
|
return [
|
|
2824
3235
|
kleur.bold(page.title),
|
|
2825
|
-
...fieldLines([
|
|
3236
|
+
...fieldLines$1([
|
|
2826
3237
|
["Space", page.spaceKey],
|
|
2827
3238
|
["ID", page.id],
|
|
2828
3239
|
["Version", page.version ? String(page.version) : ""],
|
|
@@ -2839,60 +3250,44 @@ function formatPageView(page, nowMs, allComments) {
|
|
|
2839
3250
|
async function confluenceCopy(env, arg, options) {
|
|
2840
3251
|
await copyPage(env, await resolveRef(env.term.ask, arg, PAGE_REF), options.out);
|
|
2841
3252
|
}
|
|
2842
|
-
async function confluenceUpdate(
|
|
3253
|
+
async function confluenceUpdate(env, arg, options) {
|
|
3254
|
+
const { session, term, files } = env;
|
|
2843
3255
|
const file = arg ?? await term.ask.text({
|
|
2844
3256
|
message: "Path to the page Markdown file:",
|
|
2845
3257
|
flag: "[file]",
|
|
2846
3258
|
required: true
|
|
2847
3259
|
});
|
|
2848
|
-
const
|
|
3260
|
+
const raw = await files.readText(file);
|
|
3261
|
+
const src = parsePageSource(raw);
|
|
2849
3262
|
const state = await fetchPageState(session, src.id);
|
|
2850
|
-
const plan = planPageUpdate(src, state, await listAttachments(session, src.id), await statLocalImages(files, dirname(resolve(file)), src.body),
|
|
3263
|
+
const plan = planPageUpdate(src, state, await listAttachments(session, src.id), await statLocalImages(files, dirname(resolve(file)), src.body), {
|
|
3264
|
+
...options,
|
|
3265
|
+
file
|
|
3266
|
+
});
|
|
2851
3267
|
await runPlan(term, plan, options, async () => {
|
|
2852
|
-
const
|
|
2853
|
-
for (const upload of plan.uploads) {
|
|
2854
|
-
term.err(`Uploading ${upload.filename} ...`);
|
|
2855
|
-
const bytes = await files.readBytes(upload.path);
|
|
2856
|
-
ids.set(upload.href, await uploadAttachment(session, src.id, upload.filename, bytes));
|
|
2857
|
-
}
|
|
2858
|
-
const version = await updatePage(session, src.id, {
|
|
3268
|
+
const version = await pushPage(env, {
|
|
2859
3269
|
title: plan.headline.next,
|
|
3270
|
+
body: plan.body,
|
|
3271
|
+
uploads: plan.uploads
|
|
3272
|
+
}, {
|
|
3273
|
+
id: src.id,
|
|
2860
3274
|
nextVersion: state.version + 1,
|
|
2861
|
-
body: withUploadedIds(plan.body, ids),
|
|
2862
3275
|
message: options.message ?? "Updated via atlass"
|
|
2863
3276
|
});
|
|
2864
|
-
term.out(`Updated page ${src.id} to version ${version}.`);
|
|
3277
|
+
term.out(`Updated page ${src.id} to version ${version.number}.`);
|
|
3278
|
+
await files.writeText(file, rewriteFields(raw, pushedFields(plan, version)));
|
|
2865
3279
|
});
|
|
2866
3280
|
}
|
|
2867
|
-
|
|
2868
|
-
const
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
href,
|
|
2873
|
-
path,
|
|
2874
|
-
filename: basename(path),
|
|
2875
|
-
...await fileSize(files, path)
|
|
2876
|
-
};
|
|
2877
|
-
}));
|
|
2878
|
-
}
|
|
2879
|
-
async function fileSize(files, path) {
|
|
2880
|
-
try {
|
|
2881
|
-
return { size: await files.size(path) };
|
|
2882
|
-
} catch {
|
|
2883
|
-
return {};
|
|
2884
|
-
}
|
|
3281
|
+
function pushedFields(plan, version) {
|
|
3282
|
+
const fields = { version: version.number };
|
|
3283
|
+
if (version.createdAt) fields["updated"] = version.createdAt;
|
|
3284
|
+
if (plan.headline.next !== plan.headline.current) fields["title"] = plan.headline.next;
|
|
3285
|
+
return fields;
|
|
2885
3286
|
}
|
|
2886
3287
|
async function copyPage(env, id, out) {
|
|
2887
3288
|
env.term.err(`Fetching page ${id} ...`);
|
|
2888
3289
|
await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
|
|
2889
3290
|
}
|
|
2890
|
-
const PAGE_REF = {
|
|
2891
|
-
message: "Confluence page id or URL:",
|
|
2892
|
-
flag: "[page]",
|
|
2893
|
-
parse: parsePageId,
|
|
2894
|
-
notFound: (raw) => `Could not find a page id in "${raw}".`
|
|
2895
|
-
};
|
|
2896
3291
|
//#endregion
|
|
2897
3292
|
//#region src/commands/confluence-search.ts
|
|
2898
3293
|
async function confluenceSearch(env, query, options) {
|
|
@@ -2963,16 +3358,212 @@ function registerConfluence(confluence) {
|
|
|
2963
3358
|
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));
|
|
2964
3359
|
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));
|
|
2965
3360
|
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));
|
|
3361
|
+
confluence.command("create [file]").description("Create a Confluence page from a Markdown file").option("-s, --space <key>", "space to create it in (default: the frontmatter space)").option("--parent <page>", "parent page id or URL (default: the space homepage)").option("--star", "star the new page").option("--dry-run", "show what would be created without writing").action(jiraAction(confluenceCreate));
|
|
3362
|
+
confluence.command("star [page]").description("Star a Confluence page (id, URL, or copied file)").action(jiraAction(confluenceStar));
|
|
3363
|
+
confluence.command("unstar [page]").description("Unstar a Confluence page (id, URL, or copied file)").action(jiraAction(confluenceUnstar));
|
|
2966
3364
|
outputOptions(confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space")).action(jiraAction(confluenceList));
|
|
2967
3365
|
outputOptions(confluence.command("search [query]").description("Search Confluence pages by text and filters").option("-s, --space <key>", "limit to a space (repeatable)", collect).option("--label <label>", "limit to a label (repeatable)", collect).option("--starred", "limit to pages you starred").option("-u, --updated <when>", "changed since 7d, 2w, 3m, or YYYY-MM-DD").addOption(moved("--cql <cql>", "confluence cql"))).action(jiraAction(confluenceSearch));
|
|
2968
3366
|
outputOptions(confluence.command("cql <query>").description("Search Confluence pages with a raw CQL query")).action(jiraAction(confluenceCql));
|
|
2969
3367
|
return confluence;
|
|
2970
3368
|
}
|
|
2971
3369
|
//#endregion
|
|
3370
|
+
//#region src/api/jira-statuses.ts
|
|
3371
|
+
async function listStatuses(client, project) {
|
|
3372
|
+
return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await fetchAllStatuses(client)).map(toStatusSummary));
|
|
3373
|
+
}
|
|
3374
|
+
async function statusesById(client) {
|
|
3375
|
+
const raw = await fetchAllStatuses(client);
|
|
3376
|
+
return new Map(raw.map((s) => [s.id, toStatusSummary(s)]));
|
|
3377
|
+
}
|
|
3378
|
+
function fetchAllStatuses(client) {
|
|
3379
|
+
return client.getJson("/rest/api/3/status");
|
|
3380
|
+
}
|
|
3381
|
+
async function fetchProjectStatuses(client, project) {
|
|
3382
|
+
let groups;
|
|
3383
|
+
try {
|
|
3384
|
+
groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
|
|
3385
|
+
} catch (err) {
|
|
3386
|
+
if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
|
|
3387
|
+
throw err;
|
|
3388
|
+
}
|
|
3389
|
+
return groups.flatMap((g) => g.statuses ?? []);
|
|
3390
|
+
}
|
|
3391
|
+
function toStatusSummary(s) {
|
|
3392
|
+
return {
|
|
3393
|
+
name: s.name,
|
|
3394
|
+
id: s.id,
|
|
3395
|
+
category: s.statusCategory?.name ?? "",
|
|
3396
|
+
categoryKey: s.statusCategory?.key ?? ""
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
3399
|
+
const CATEGORY_ORDER = {
|
|
3400
|
+
new: 0,
|
|
3401
|
+
indeterminate: 1,
|
|
3402
|
+
done: 2
|
|
3403
|
+
};
|
|
3404
|
+
const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
|
|
3405
|
+
function dedupeAndSortStatuses(statuses) {
|
|
3406
|
+
const byNameCategory = /* @__PURE__ */ new Map();
|
|
3407
|
+
for (const s of statuses) {
|
|
3408
|
+
const key = `${s.name}\0${s.categoryKey}`;
|
|
3409
|
+
if (!byNameCategory.has(key)) byNameCategory.set(key, s);
|
|
3410
|
+
}
|
|
3411
|
+
return [...byNameCategory.values()].sort((a, b) => {
|
|
3412
|
+
const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
|
|
3413
|
+
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
3414
|
+
});
|
|
3415
|
+
}
|
|
3416
|
+
//#endregion
|
|
2972
3417
|
//#region src/api/jira-url.ts
|
|
2973
3418
|
function browseUrl(site, key) {
|
|
2974
3419
|
return `${site}/browse/${key}`;
|
|
2975
3420
|
}
|
|
3421
|
+
function boardUrl(site, id) {
|
|
3422
|
+
return `${site}/secure/RapidBoard.jspa?rapidView=${id}`;
|
|
3423
|
+
}
|
|
3424
|
+
//#endregion
|
|
3425
|
+
//#region src/api/jira-board.ts
|
|
3426
|
+
async function fetchBoard(client, site, id) {
|
|
3427
|
+
const [config, statuses] = await Promise.all([client.getJson(`/rest/agile/1.0/board/${id}/configuration`), statusesById(client)]);
|
|
3428
|
+
const statusFor = (statusId) => statuses.get(statusId) ?? {
|
|
3429
|
+
id: statusId,
|
|
3430
|
+
name: statusId,
|
|
3431
|
+
category: "",
|
|
3432
|
+
categoryKey: ""
|
|
3433
|
+
};
|
|
3434
|
+
return {
|
|
3435
|
+
id: config.id,
|
|
3436
|
+
name: config.name,
|
|
3437
|
+
type: config.type,
|
|
3438
|
+
url: boardUrl(site, config.id),
|
|
3439
|
+
estimation: estimationOf(config),
|
|
3440
|
+
filter: {
|
|
3441
|
+
id: config.filter.id,
|
|
3442
|
+
jql: await visibleFilterJql(client, config.filter.id),
|
|
3443
|
+
subQuery: config.subQuery?.query || null
|
|
3444
|
+
},
|
|
3445
|
+
columns: (config.columnConfig?.columns ?? []).map((c) => ({
|
|
3446
|
+
name: c.name,
|
|
3447
|
+
statuses: (c.statuses ?? []).map((s) => statusFor(s.id))
|
|
3448
|
+
}))
|
|
3449
|
+
};
|
|
3450
|
+
}
|
|
3451
|
+
async function visibleFilterJql(client, filterId) {
|
|
3452
|
+
try {
|
|
3453
|
+
return (await client.getJson(`/rest/api/3/filter/${filterId}`)).jql ?? null;
|
|
3454
|
+
} catch (err) {
|
|
3455
|
+
if (err instanceof HttpError && err.status === 404) return null;
|
|
3456
|
+
throw err;
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
function estimationOf(config) {
|
|
3460
|
+
const field = config.estimation?.type === "field" ? config.estimation.field : void 0;
|
|
3461
|
+
return field ? {
|
|
3462
|
+
fieldId: field.fieldId,
|
|
3463
|
+
fieldName: field.displayName
|
|
3464
|
+
} : null;
|
|
3465
|
+
}
|
|
3466
|
+
//#endregion
|
|
3467
|
+
//#region src/api/offset-pages.ts
|
|
3468
|
+
async function allOffsetPages(client, pathAt) {
|
|
3469
|
+
const all = [];
|
|
3470
|
+
for (let startAt = 0;;) {
|
|
3471
|
+
const res = await client.getJson(pathAt(startAt));
|
|
3472
|
+
const values = res.values ?? [];
|
|
3473
|
+
all.push(...values);
|
|
3474
|
+
if (res.isLast || values.length === 0) return all;
|
|
3475
|
+
startAt += values.length;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
//#endregion
|
|
3479
|
+
//#region src/api/jira-boards.ts
|
|
3480
|
+
async function listBoards(client, site) {
|
|
3481
|
+
return (await allOffsetPages(client, (startAt) => `/rest/agile/1.0/board?startAt=${startAt}&maxResults=50`)).map((b) => ({
|
|
3482
|
+
id: b.id,
|
|
3483
|
+
name: b.name,
|
|
3484
|
+
type: b.type,
|
|
3485
|
+
project: b.location?.projectKey ?? null,
|
|
3486
|
+
url: boardUrl(site, b.id)
|
|
3487
|
+
}));
|
|
3488
|
+
}
|
|
3489
|
+
//#endregion
|
|
3490
|
+
//#region src/commands/board-ref.ts
|
|
3491
|
+
async function withBoard(session, ref, load) {
|
|
3492
|
+
const id = await resolveBoardId(session, ref);
|
|
3493
|
+
try {
|
|
3494
|
+
return await load(id);
|
|
3495
|
+
} catch (err) {
|
|
3496
|
+
if (isMissingBoard(err, id)) throw unknownBoard(ref);
|
|
3497
|
+
throw err;
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
async function resolveBoardId(session, ref) {
|
|
3501
|
+
const id = parseBoardId(ref);
|
|
3502
|
+
if (id !== null) return id;
|
|
3503
|
+
const name = ref.trim().toLowerCase();
|
|
3504
|
+
const matches = (await listBoards(session, session.site)).filter((b) => b.name.toLowerCase() === name);
|
|
3505
|
+
const [only, ...others] = matches;
|
|
3506
|
+
if (!only) throw unknownBoard(ref);
|
|
3507
|
+
if (others.length === 0) return only.id;
|
|
3508
|
+
const width = Math.max(...matches.map((b) => String(b.id).length));
|
|
3509
|
+
throw new Error([`"${ref}" matches several boards. Use an id:`, ...matches.map((b) => ` ${String(b.id).padEnd(width)} ${b.name}`)].join("\n"));
|
|
3510
|
+
}
|
|
3511
|
+
function isMissingBoard(err, id) {
|
|
3512
|
+
return err instanceof HttpError && err.status === 404 && err.path.startsWith(`/rest/agile/1.0/board/${id}/`);
|
|
3513
|
+
}
|
|
3514
|
+
function unknownBoard(ref) {
|
|
3515
|
+
return /* @__PURE__ */ new Error(`No board matches "${ref}". Run \`atlass jira boards\` to list boards.`);
|
|
3516
|
+
}
|
|
3517
|
+
//#endregion
|
|
3518
|
+
//#region src/commands/jira-board.ts
|
|
3519
|
+
async function jiraBoard({ session, term }, ref, options) {
|
|
3520
|
+
const board = await withBoard(session, ref, (id) => fetchBoard(session, session.site, id));
|
|
3521
|
+
if (options.json) term.json(board);
|
|
3522
|
+
else term.out(formatBoard(board));
|
|
3523
|
+
}
|
|
3524
|
+
function formatBoard(board) {
|
|
3525
|
+
return [
|
|
3526
|
+
board.name,
|
|
3527
|
+
...fieldLines$1([
|
|
3528
|
+
["ID", String(board.id)],
|
|
3529
|
+
["Type", board.type],
|
|
3530
|
+
["Estimation", estimationLine(board)],
|
|
3531
|
+
["Filter", board.filter.jql ?? `not visible to you (filter ${board.filter.id})`],
|
|
3532
|
+
["Sub-filter", board.filter.subQuery ?? ""],
|
|
3533
|
+
["URL", board.url]
|
|
3534
|
+
]),
|
|
3535
|
+
"",
|
|
3536
|
+
...columnLines(board)
|
|
3537
|
+
];
|
|
3538
|
+
}
|
|
3539
|
+
function estimationLine({ estimation }) {
|
|
3540
|
+
return estimation ? `${estimation.fieldName} (${estimation.fieldId})` : "none, this board does not estimate with a field";
|
|
3541
|
+
}
|
|
3542
|
+
function columnLines({ columns }) {
|
|
3543
|
+
const statuses = columns.flatMap((c) => c.statuses);
|
|
3544
|
+
const width = Math.max(0, ...statuses.map((s) => s.name.length));
|
|
3545
|
+
return columns.flatMap((c) => [c.name, ...c.statuses.map((s) => ` ${s.name.padEnd(width)} ${s.category}`.trimEnd())]);
|
|
3546
|
+
}
|
|
3547
|
+
//#endregion
|
|
3548
|
+
//#region src/commands/jira-boards.ts
|
|
3549
|
+
async function jiraBoards({ session, term }, query, options) {
|
|
3550
|
+
const boards = filterBoards(await listBoards(session, session.site), query, options.project);
|
|
3551
|
+
if (options.json) term.json(boards);
|
|
3552
|
+
else if (boards.length === 0) term.out("No matching boards.");
|
|
3553
|
+
else term.out(formatBoardRows(boards));
|
|
3554
|
+
}
|
|
3555
|
+
function filterBoards(boards, query, project) {
|
|
3556
|
+
const needle = query?.toLowerCase();
|
|
3557
|
+
const key = project?.toUpperCase();
|
|
3558
|
+
return boards.filter((b) => (!needle || b.name.toLowerCase().includes(needle)) && (!key || b.project?.toUpperCase() === key));
|
|
3559
|
+
}
|
|
3560
|
+
function formatBoardRows(boards) {
|
|
3561
|
+
const width = (pick) => Math.max(...boards.map((b) => pick(b).length));
|
|
3562
|
+
const idWidth = width((b) => String(b.id));
|
|
3563
|
+
const typeWidth = width((b) => b.type);
|
|
3564
|
+
const nameWidth = width((b) => b.name);
|
|
3565
|
+
return boards.map((b) => `${String(b.id).padEnd(idWidth)} ${b.type.padEnd(typeWidth)} ${b.name.padEnd(nameWidth)} ${b.project ?? ""}`.trimEnd());
|
|
3566
|
+
}
|
|
2976
3567
|
//#endregion
|
|
2977
3568
|
//#region src/api/jira-createmeta.ts
|
|
2978
3569
|
const CREATEMETA_PAGE_SIZE = 200;
|
|
@@ -3065,28 +3656,19 @@ function toJiraUser(u) {
|
|
|
3065
3656
|
}
|
|
3066
3657
|
//#endregion
|
|
3067
3658
|
//#region src/api/jira-projects.ts
|
|
3068
|
-
const PROJECT_PAGE_SIZE = 50;
|
|
3069
3659
|
async function listProjects(client, site, query) {
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
id: p.id,
|
|
3078
|
-
type: p.projectTypeKey ?? "",
|
|
3079
|
-
url: browseUrl(site, p.key)
|
|
3080
|
-
});
|
|
3081
|
-
if (res.isLast || values.length === 0) break;
|
|
3082
|
-
startAt += values.length;
|
|
3083
|
-
}
|
|
3084
|
-
return projects;
|
|
3660
|
+
return (await allOffsetPages(client, (startAt) => `/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`)).map((p) => ({
|
|
3661
|
+
key: p.key,
|
|
3662
|
+
name: p.name,
|
|
3663
|
+
id: p.id,
|
|
3664
|
+
type: p.projectTypeKey ?? "",
|
|
3665
|
+
url: browseUrl(site, p.key)
|
|
3666
|
+
}));
|
|
3085
3667
|
}
|
|
3086
3668
|
function projectSearchQuery(query, startAt) {
|
|
3087
3669
|
const params = new URLSearchParams({
|
|
3088
3670
|
orderBy: "key",
|
|
3089
|
-
maxResults: String(
|
|
3671
|
+
maxResults: String(50),
|
|
3090
3672
|
startAt: String(startAt)
|
|
3091
3673
|
});
|
|
3092
3674
|
if (query) params.set("query", query);
|
|
@@ -3731,17 +4313,55 @@ function describeRejection(err, meta) {
|
|
|
3731
4313
|
...perField
|
|
3732
4314
|
].join("\n"));
|
|
3733
4315
|
}
|
|
3734
|
-
|
|
4316
|
+
//#endregion
|
|
4317
|
+
//#region src/api/jira-fields.ts
|
|
4318
|
+
async function listSiteFields(client) {
|
|
4319
|
+
return (await client.getJson("/rest/api/3/field")).map((f) => ({
|
|
4320
|
+
id: f.id,
|
|
4321
|
+
name: decodeEntities(f.name),
|
|
4322
|
+
custom: f.custom ?? false,
|
|
4323
|
+
schema: f.schema ?? null
|
|
4324
|
+
}));
|
|
4325
|
+
}
|
|
4326
|
+
//#endregion
|
|
4327
|
+
//#region src/commands/jira-fields.ts
|
|
4328
|
+
async function jiraFields(env, projectArg, typeArg, options) {
|
|
4329
|
+
if (!projectArg) return siteFields(env, options);
|
|
3735
4330
|
const project = projectArg.toUpperCase();
|
|
4331
|
+
if (options.search !== void 0) throw new Error(`--search lists site fields and takes no project. Drop ${project}, or run \`atlass jira fields ${project}\` alone.`);
|
|
4332
|
+
return createForm$1(env, project, typeArg, options);
|
|
4333
|
+
}
|
|
4334
|
+
async function siteFields({ session, term }, options) {
|
|
4335
|
+
const fields = matchingFields(await listSiteFields(session), options.search).sort((a, b) => a.name.localeCompare(b.name, void 0, { sensitivity: "base" }));
|
|
4336
|
+
if (options.json) term.json(fields);
|
|
4337
|
+
else if (fields.length === 0) term.out("No matching fields.");
|
|
4338
|
+
else term.out(formatSiteFieldRows(fields));
|
|
4339
|
+
}
|
|
4340
|
+
function matchingFields(fields, search) {
|
|
4341
|
+
const needle = search?.toLowerCase();
|
|
4342
|
+
return fields.filter((f) => !needle || f.name.toLowerCase().includes(needle) || f.id.toLowerCase().includes(needle));
|
|
4343
|
+
}
|
|
4344
|
+
function formatSiteFieldRows(fields) {
|
|
4345
|
+
const width = (pick) => Math.max(...fields.map((f) => pick(f).length));
|
|
4346
|
+
const idWidth = width((f) => f.id);
|
|
4347
|
+
const nameWidth = width((f) => f.name);
|
|
4348
|
+
return fields.map((f) => `${f.id.padEnd(idWidth)} ${f.name.padEnd(nameWidth)} ${shortFieldType(f)}`.trimEnd());
|
|
4349
|
+
}
|
|
4350
|
+
function shortFieldType(field) {
|
|
4351
|
+
const plugin = field.schema?.custom;
|
|
4352
|
+
if (plugin) return plugin.slice(plugin.lastIndexOf(":") + 1);
|
|
4353
|
+
return field.schema?.type ?? "";
|
|
4354
|
+
}
|
|
4355
|
+
async function createForm$1({ session, term }, project, typeArg, { json }) {
|
|
3736
4356
|
const types = await fetchCreateIssueTypes(session, project);
|
|
3737
4357
|
if (!typeArg) {
|
|
3738
|
-
if (
|
|
4358
|
+
if (json) term.json(types);
|
|
3739
4359
|
else if (types.length === 0) term.out(`You cannot create issues in ${project}.`);
|
|
3740
4360
|
else term.out(formatIssueTypeRows(types));
|
|
3741
4361
|
return;
|
|
3742
4362
|
}
|
|
3743
4363
|
const fields = await fetchCreateFields(session, project, (await resolveType(term.ask, project, types, typeArg, true)).id);
|
|
3744
|
-
if (
|
|
4364
|
+
if (json) term.json(fields);
|
|
3745
4365
|
else term.out(formatFieldRows(fields));
|
|
3746
4366
|
}
|
|
3747
4367
|
//#endregion
|
|
@@ -3749,23 +4369,23 @@ async function jiraFields({ session, term }, projectArg, typeArg, options) {
|
|
|
3749
4369
|
const RECENT_ISSUES_CLAUSE = "updated >= -30d";
|
|
3750
4370
|
const OPEN_CLAUSE = "statusCategory != Done";
|
|
3751
4371
|
const SEARCH_FIELDS = "summary,status,updated";
|
|
3752
|
-
async function searchIssues(client, site, params) {
|
|
3753
|
-
return searchIssuesByJql(client, site, buildJql(params), params.limit);
|
|
4372
|
+
async function searchIssues(client, site, params, fieldIds = []) {
|
|
4373
|
+
return searchIssuesByJql(client, site, buildJql(params), params.limit, fieldIds);
|
|
3754
4374
|
}
|
|
3755
|
-
async function searchIssuesByJql(client, site, jql, limit) {
|
|
3756
|
-
return ((await fetchSearchPage(client, jql, limit)).issues ?? []).map((i) => toIssueSummary(site, i));
|
|
4375
|
+
async function searchIssuesByJql(client, site, jql, limit, fieldIds = []) {
|
|
4376
|
+
return ((await fetchSearchPage(client, jql, limit, fieldIds)).issues ?? []).map((i) => toIssueSummary(site, i, fieldIds));
|
|
3757
4377
|
}
|
|
3758
|
-
async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
|
|
4378
|
+
async function fetchSearchPage(client, jql, maxResults, fieldIds, nextPageToken) {
|
|
3759
4379
|
const query = new URLSearchParams({
|
|
3760
4380
|
jql,
|
|
3761
4381
|
maxResults: String(maxResults),
|
|
3762
|
-
fields: SEARCH_FIELDS
|
|
4382
|
+
fields: [SEARCH_FIELDS, ...fieldIds].join(",")
|
|
3763
4383
|
});
|
|
3764
4384
|
if (nextPageToken) query.set("nextPageToken", nextPageToken);
|
|
3765
4385
|
return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
|
|
3766
4386
|
}
|
|
3767
|
-
function toIssueSummary(site, i) {
|
|
3768
|
-
|
|
4387
|
+
function toIssueSummary(site, i, fieldIds) {
|
|
4388
|
+
const summary = {
|
|
3769
4389
|
key: i.key,
|
|
3770
4390
|
status: i.fields?.status?.name ?? "",
|
|
3771
4391
|
statusCategory: i.fields?.status?.statusCategory?.key ?? "",
|
|
@@ -3773,6 +4393,11 @@ function toIssueSummary(site, i) {
|
|
|
3773
4393
|
updated: i.fields?.updated ?? "",
|
|
3774
4394
|
url: browseUrl(site, i.key)
|
|
3775
4395
|
};
|
|
4396
|
+
if (fieldIds.length === 0) return summary;
|
|
4397
|
+
return {
|
|
4398
|
+
...summary,
|
|
4399
|
+
fields: Object.fromEntries(fieldIds.map((id) => [id, i.fields?.[id] ?? null]))
|
|
4400
|
+
};
|
|
3776
4401
|
}
|
|
3777
4402
|
const ISSUE_ORDER = "updated DESC";
|
|
3778
4403
|
function buildJql(params) {
|
|
@@ -3784,30 +4409,40 @@ function buildJql(params) {
|
|
|
3784
4409
|
userClause("assignee", params.assignee),
|
|
3785
4410
|
userClause("reporter", params.reporter),
|
|
3786
4411
|
inClause("labels", params.label),
|
|
3787
|
-
|
|
3788
|
-
params.
|
|
4412
|
+
anyOf(values(params.sprint).map(sprintTerm)),
|
|
4413
|
+
params.updatedSince ? `updated >= ${quote(params.updatedSince)}` : null,
|
|
4414
|
+
params.text ? `text ~ ${quote(params.text)}` : null
|
|
3789
4415
|
].filter((c) => c !== null);
|
|
3790
4416
|
if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
|
|
3791
4417
|
return joinClauses(clauses, ISSUE_ORDER);
|
|
3792
4418
|
}
|
|
4419
|
+
const SPRINT_FUNCTIONS = {
|
|
4420
|
+
active: "openSprints()",
|
|
4421
|
+
future: "futureSprints()"
|
|
4422
|
+
};
|
|
4423
|
+
function sprintTerm(sprint) {
|
|
4424
|
+
const sprintFunction = SPRINT_FUNCTIONS[sprint];
|
|
4425
|
+
if (sprintFunction) return `sprint in ${sprintFunction}`;
|
|
4426
|
+
return `sprint = ${/^\d+$/.test(sprint) ? sprint : quote(sprint)}`;
|
|
4427
|
+
}
|
|
3793
4428
|
function buildListJql(params) {
|
|
3794
4429
|
return joinClauses([
|
|
3795
4430
|
"assignee = currentUser()",
|
|
3796
|
-
params.project ? `project = ${quote
|
|
4431
|
+
params.project ? `project = ${quote(params.project)}` : null,
|
|
3797
4432
|
params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE
|
|
3798
4433
|
], ISSUE_ORDER);
|
|
3799
4434
|
}
|
|
3800
4435
|
const LIST_PAGE_SIZE = 100;
|
|
3801
4436
|
const LIST_CAP = 500;
|
|
3802
|
-
async function listAssignedIssues(client, site, params) {
|
|
4437
|
+
async function listAssignedIssues(client, site, params, fieldIds = []) {
|
|
3803
4438
|
const jql = buildListJql(params);
|
|
3804
4439
|
const issues = [];
|
|
3805
4440
|
let nextPageToken;
|
|
3806
4441
|
for (;;) {
|
|
3807
4442
|
const room = LIST_CAP - issues.length;
|
|
3808
|
-
const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
|
|
4443
|
+
const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), fieldIds, nextPageToken);
|
|
3809
4444
|
const page = res.issues ?? [];
|
|
3810
|
-
issues.push(...page.map((i) => toIssueSummary(site, i)));
|
|
4445
|
+
issues.push(...page.map((i) => toIssueSummary(site, i, fieldIds)));
|
|
3811
4446
|
if (res.isLast || !res.nextPageToken || page.length === 0) return {
|
|
3812
4447
|
issues,
|
|
3813
4448
|
truncated: false
|
|
@@ -3831,6 +4466,54 @@ function sortByCategoryThenUpdated(issues) {
|
|
|
3831
4466
|
return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
|
|
3832
4467
|
}
|
|
3833
4468
|
//#endregion
|
|
4469
|
+
//#region src/commands/jira-field-columns.ts
|
|
4470
|
+
async function resolveFieldColumns(session, given) {
|
|
4471
|
+
const names = values(given);
|
|
4472
|
+
if (names.length === 0) return [];
|
|
4473
|
+
const fields = await listSiteFields(session);
|
|
4474
|
+
return names.map((name) => ({
|
|
4475
|
+
name,
|
|
4476
|
+
id: resolveFieldId(fields, name)
|
|
4477
|
+
}));
|
|
4478
|
+
}
|
|
4479
|
+
function resolveFieldId(fields, name) {
|
|
4480
|
+
const byId = fields.find((f) => f.id === name);
|
|
4481
|
+
if (byId) return byId.id;
|
|
4482
|
+
const lower = name.toLowerCase();
|
|
4483
|
+
const matches = fields.filter((f) => f.name.toLowerCase() === lower);
|
|
4484
|
+
const [only, ...others] = matches;
|
|
4485
|
+
if (!only) throw new Error(`No field matches "${name}". Run \`atlass jira fields --search ${shellWord(name)}\` to find it.`);
|
|
4486
|
+
if (others.length === 0) return only.id;
|
|
4487
|
+
const width = Math.max(...matches.map((f) => f.id.length));
|
|
4488
|
+
throw new Error([`"${name}" matches several fields. Use an id:`, ...matches.map((f) => ` ${f.id.padEnd(width)} ${f.name}`)].join("\n"));
|
|
4489
|
+
}
|
|
4490
|
+
function shellWord(text) {
|
|
4491
|
+
return /\s/.test(text) ? `"${text}"` : text;
|
|
4492
|
+
}
|
|
4493
|
+
function fieldIds(columns) {
|
|
4494
|
+
return [...new Set(columns.map((c) => c.id))];
|
|
4495
|
+
}
|
|
4496
|
+
function withNamedFields(issues, columns) {
|
|
4497
|
+
if (columns.length === 0) return issues;
|
|
4498
|
+
return issues.map((issue) => ({
|
|
4499
|
+
...issue,
|
|
4500
|
+
fields: Object.fromEntries(columns.map((c) => [c.name, issue.fields?.[c.id] ?? null]))
|
|
4501
|
+
}));
|
|
4502
|
+
}
|
|
4503
|
+
const EMPTY_CELL = "-";
|
|
4504
|
+
function fieldCell(value) {
|
|
4505
|
+
const text = (Array.isArray(value) ? value.map(shownValue) : [shownValue(value)]).filter((p) => p !== "").join(", ");
|
|
4506
|
+
return text === "" ? EMPTY_CELL : text;
|
|
4507
|
+
}
|
|
4508
|
+
function shownValue(value) {
|
|
4509
|
+
if (value === null || value === void 0) return "";
|
|
4510
|
+
if (typeof value === "string") return value;
|
|
4511
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
4512
|
+
const named = value;
|
|
4513
|
+
const shown = named.value ?? named.displayName ?? named.name;
|
|
4514
|
+
return shown === void 0 ? JSON.stringify(value) : shownValue(shown);
|
|
4515
|
+
}
|
|
4516
|
+
//#endregion
|
|
3834
4517
|
//#region src/commands/jira-filters.ts
|
|
3835
4518
|
const FILTER_FLAGS = [
|
|
3836
4519
|
"project",
|
|
@@ -3838,7 +4521,8 @@ const FILTER_FLAGS = [
|
|
|
3838
4521
|
"reporter",
|
|
3839
4522
|
"status",
|
|
3840
4523
|
"type",
|
|
3841
|
-
"label"
|
|
4524
|
+
"label",
|
|
4525
|
+
"sprint"
|
|
3842
4526
|
];
|
|
3843
4527
|
const ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/i;
|
|
3844
4528
|
function hasFilters(filters) {
|
|
@@ -3857,6 +4541,7 @@ async function searchParams(client, query, filters, limit, nowMs) {
|
|
|
3857
4541
|
status: filters.status,
|
|
3858
4542
|
type: filters.type,
|
|
3859
4543
|
label: filters.label,
|
|
4544
|
+
sprint: filters.sprint,
|
|
3860
4545
|
updatedSince: filters.updated ? parseSince(filters.updated, nowMs) : void 0,
|
|
3861
4546
|
open: filters.open,
|
|
3862
4547
|
limit
|
|
@@ -3928,7 +4613,9 @@ async function fetchIssue(client, site, key) {
|
|
|
3928
4613
|
async function updateIssue(client, key, update) {
|
|
3929
4614
|
const fields = { description: update.description };
|
|
3930
4615
|
if (update.summary !== void 0) fields["summary"] = update.summary;
|
|
3931
|
-
|
|
4616
|
+
const path = `/rest/api/3/issue/${encodeURIComponent(key)}`;
|
|
4617
|
+
await client.putNoContent(path, { fields });
|
|
4618
|
+
return (await client.getJson(`${path}?fields=updated`)).fields.updated ?? "";
|
|
3932
4619
|
}
|
|
3933
4620
|
async function fetchComments(client, key) {
|
|
3934
4621
|
return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
|
|
@@ -3938,46 +4625,6 @@ async function fetchComments(client, key) {
|
|
|
3938
4625
|
}));
|
|
3939
4626
|
}
|
|
3940
4627
|
//#endregion
|
|
3941
|
-
//#region src/api/jira-statuses.ts
|
|
3942
|
-
async function listStatuses(client, project) {
|
|
3943
|
-
return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await client.getJson("/rest/api/3/status")).map(toStatusSummary));
|
|
3944
|
-
}
|
|
3945
|
-
async function fetchProjectStatuses(client, project) {
|
|
3946
|
-
let groups;
|
|
3947
|
-
try {
|
|
3948
|
-
groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
|
|
3949
|
-
} catch (err) {
|
|
3950
|
-
if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
|
|
3951
|
-
throw err;
|
|
3952
|
-
}
|
|
3953
|
-
return groups.flatMap((g) => g.statuses ?? []);
|
|
3954
|
-
}
|
|
3955
|
-
function toStatusSummary(s) {
|
|
3956
|
-
return {
|
|
3957
|
-
name: s.name,
|
|
3958
|
-
id: s.id,
|
|
3959
|
-
category: s.statusCategory?.name ?? "",
|
|
3960
|
-
categoryKey: s.statusCategory?.key ?? ""
|
|
3961
|
-
};
|
|
3962
|
-
}
|
|
3963
|
-
const CATEGORY_ORDER = {
|
|
3964
|
-
new: 0,
|
|
3965
|
-
indeterminate: 1,
|
|
3966
|
-
done: 2
|
|
3967
|
-
};
|
|
3968
|
-
const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
|
|
3969
|
-
function dedupeAndSortStatuses(statuses) {
|
|
3970
|
-
const byNameCategory = /* @__PURE__ */ new Map();
|
|
3971
|
-
for (const s of statuses) {
|
|
3972
|
-
const key = `${s.name}\0${s.categoryKey}`;
|
|
3973
|
-
if (!byNameCategory.has(key)) byNameCategory.set(key, s);
|
|
3974
|
-
}
|
|
3975
|
-
return [...byNameCategory.values()].sort((a, b) => {
|
|
3976
|
-
const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
|
|
3977
|
-
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
3978
|
-
});
|
|
3979
|
-
}
|
|
3980
|
-
//#endregion
|
|
3981
4628
|
//#region src/commands/jira.ts
|
|
3982
4629
|
async function jiraProjects({ session, term }, query, options) {
|
|
3983
4630
|
const projects = await listProjects(session, session.site, query);
|
|
@@ -4020,7 +4667,7 @@ function formatIssueView(issue, nowMs, allComments) {
|
|
|
4020
4667
|
const colorStatus = colorForCategory(issue.statusCategory);
|
|
4021
4668
|
return [
|
|
4022
4669
|
`${kleur.bold(issue.key)} ${issue.summary}`,
|
|
4023
|
-
...fieldLines([
|
|
4670
|
+
...fieldLines$1([
|
|
4024
4671
|
["Type", issue.type],
|
|
4025
4672
|
["Status", issue.status && colorStatus(issue.status)],
|
|
4026
4673
|
["Assignee", issue.assignee],
|
|
@@ -4045,15 +4692,17 @@ async function jiraUpdate({ session, term, files }, arg, options) {
|
|
|
4045
4692
|
flag: "[file]",
|
|
4046
4693
|
required: true
|
|
4047
4694
|
});
|
|
4048
|
-
const
|
|
4695
|
+
const raw = await files.readText(file);
|
|
4696
|
+
const src = parseIssueSource(raw);
|
|
4049
4697
|
const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
|
|
4050
4698
|
await runPlan(term, plan, options, async () => {
|
|
4051
4699
|
const { current, next } = plan.headline;
|
|
4052
|
-
await updateIssue(session, src.key, {
|
|
4700
|
+
const updated = await updateIssue(session, src.key, {
|
|
4053
4701
|
description: plan.body,
|
|
4054
4702
|
summary: next !== current ? next : void 0
|
|
4055
4703
|
});
|
|
4056
4704
|
term.out(`Updated ${src.key}.`);
|
|
4705
|
+
if (updated) await files.writeText(file, rewriteFields(raw, { updated }));
|
|
4057
4706
|
});
|
|
4058
4707
|
}
|
|
4059
4708
|
async function copyIssue(env, key, out) {
|
|
@@ -4072,18 +4721,20 @@ async function jiraSearch(env, query, options) {
|
|
|
4072
4721
|
const { session } = env;
|
|
4073
4722
|
checkQuery(query, options);
|
|
4074
4723
|
const limit = checkedLimit(options);
|
|
4724
|
+
const columns = await resolveFieldColumns(session, options.field);
|
|
4075
4725
|
const params = await searchParams(session, query, options, limit, Date.now());
|
|
4076
|
-
await showIssues(env, await searchIssues(session, session.site, params).catch((err) => {
|
|
4726
|
+
await showIssues(env, withNamedFields(await searchIssues(session, session.site, params, fieldIds(columns)).catch((err) => {
|
|
4077
4727
|
throw searchHint(err, options.project);
|
|
4078
|
-
}), limit, options);
|
|
4728
|
+
}), columns), columns, limit, options);
|
|
4079
4729
|
}
|
|
4080
4730
|
async function jiraJql(env, query, options) {
|
|
4081
4731
|
const { session } = env;
|
|
4082
4732
|
const limit = checkedLimit(options);
|
|
4083
|
-
|
|
4733
|
+
const columns = await resolveFieldColumns(session, options.field);
|
|
4734
|
+
await showIssues(env, withNamedFields(await searchIssuesByJql(session, session.site, query, limit, fieldIds(columns)), columns), columns, limit, options);
|
|
4084
4735
|
}
|
|
4085
|
-
async function showIssues(env, issues, limit, options, empty = "No matching issues.") {
|
|
4086
|
-
await showRows(env.term, formatIssueRows(issues, Date.now()), {
|
|
4736
|
+
async function showIssues(env, issues, columns, limit, options, empty = "No matching issues.") {
|
|
4737
|
+
await showRows(env.term, formatIssueRows(issues, Date.now(), columns), {
|
|
4087
4738
|
empty,
|
|
4088
4739
|
hasMore: issues.length === limit,
|
|
4089
4740
|
limit
|
|
@@ -4091,27 +4742,36 @@ async function showIssues(env, issues, limit, options, empty = "No matching issu
|
|
|
4091
4742
|
}
|
|
4092
4743
|
async function jiraList(env, options) {
|
|
4093
4744
|
const { session, term } = env;
|
|
4094
|
-
checkedLimit(options);
|
|
4745
|
+
const limit = checkedLimit(options);
|
|
4746
|
+
const columns = await resolveFieldColumns(session, options.field);
|
|
4095
4747
|
const { issues, truncated } = await listAssignedIssues(session, session.site, {
|
|
4096
4748
|
all: options.all,
|
|
4097
4749
|
project: options.project
|
|
4098
|
-
});
|
|
4099
|
-
|
|
4750
|
+
}, fieldIds(columns));
|
|
4751
|
+
const sorted = sortByCategoryThenUpdated(withNamedFields(issues, columns));
|
|
4752
|
+
const shown = options.limit === void 0 ? sorted : sorted.slice(0, limit);
|
|
4753
|
+
await runSearch(term, formatIssueRows(shown, Date.now(), columns), {
|
|
4100
4754
|
json: options.json,
|
|
4101
4755
|
copy: options.copy,
|
|
4102
4756
|
out: options.out,
|
|
4103
4757
|
empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
|
|
4104
|
-
footer:
|
|
4758
|
+
footer: listFooter(shown.length, sorted.length, truncated, limit)
|
|
4105
4759
|
}, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
|
|
4106
4760
|
}
|
|
4107
|
-
function
|
|
4761
|
+
function listFooter(shown, fetched, truncated, limit) {
|
|
4762
|
+
if (shown < fetched) return searchFooter(limit);
|
|
4763
|
+
if (truncated) return `
|
|
4764
|
+
Showing the first ${shown}; narrow with --project.`;
|
|
4765
|
+
}
|
|
4766
|
+
function formatIssueRows(issues, nowMs, columns = []) {
|
|
4108
4767
|
return alignedRows(issues, nowMs, (i) => ({
|
|
4109
4768
|
id: i.key,
|
|
4110
4769
|
url: i.url,
|
|
4111
4770
|
label: i.status,
|
|
4112
4771
|
color: colorForCategory(i.statusCategory),
|
|
4113
4772
|
text: i.summary,
|
|
4114
|
-
timestamp: i.updated
|
|
4773
|
+
timestamp: i.updated,
|
|
4774
|
+
columns: columns.map((c) => fieldCell(i.fields?.[c.name]))
|
|
4115
4775
|
}));
|
|
4116
4776
|
}
|
|
4117
4777
|
const ISSUE_NOUN = {
|
|
@@ -4119,24 +4779,90 @@ const ISSUE_NOUN = {
|
|
|
4119
4779
|
plural: "issues"
|
|
4120
4780
|
};
|
|
4121
4781
|
//#endregion
|
|
4782
|
+
//#region src/api/jira-types.ts
|
|
4783
|
+
const SPRINT_STATES = [
|
|
4784
|
+
"active",
|
|
4785
|
+
"future",
|
|
4786
|
+
"closed"
|
|
4787
|
+
];
|
|
4788
|
+
//#endregion
|
|
4789
|
+
//#region src/api/jira-sprints.ts
|
|
4790
|
+
async function listSprints(client, boardId, states) {
|
|
4791
|
+
const state = states.join(",");
|
|
4792
|
+
return (await allOffsetPages(client, (startAt) => `/rest/agile/1.0/board/${boardId}/sprint?state=${state}&startAt=${startAt}&maxResults=50`)).toSorted(byRelevance).map((s) => ({
|
|
4793
|
+
id: s.id,
|
|
4794
|
+
state: s.state,
|
|
4795
|
+
name: s.name,
|
|
4796
|
+
start: s.startDate ?? null,
|
|
4797
|
+
end: s.endDate ?? null,
|
|
4798
|
+
boardId: s.originBoardId ?? boardId
|
|
4799
|
+
}));
|
|
4800
|
+
}
|
|
4801
|
+
function byRelevance(a, b) {
|
|
4802
|
+
const rank = SPRINT_STATES.indexOf(a.state) - SPRINT_STATES.indexOf(b.state);
|
|
4803
|
+
if (rank !== 0 || a.state !== "closed") return rank;
|
|
4804
|
+
return finishedAt(b) - finishedAt(a);
|
|
4805
|
+
}
|
|
4806
|
+
function finishedAt(sprint) {
|
|
4807
|
+
return Date.parse(sprint.completeDate ?? sprint.endDate ?? "") || 0;
|
|
4808
|
+
}
|
|
4809
|
+
//#endregion
|
|
4810
|
+
//#region src/commands/jira-sprints.ts
|
|
4811
|
+
async function jiraSprints({ session, term }, ref, options) {
|
|
4812
|
+
const chosen = parseChoices(options.state, SPRINT_STATES, "--state");
|
|
4813
|
+
const states = chosen.length > 0 ? chosen : ["active"];
|
|
4814
|
+
const limit = parseLimit(options.limit);
|
|
4815
|
+
const sprints = await withBoard(session, ref, (id) => listSprints(session, id, states).catch((err) => {
|
|
4816
|
+
throw withoutSprints(err, id);
|
|
4817
|
+
}));
|
|
4818
|
+
const shown = sprints.slice(0, limit);
|
|
4819
|
+
if (options.json) term.json(shown);
|
|
4820
|
+
else if (shown.length === 0) term.out(`No ${orList(states)} sprints.`);
|
|
4821
|
+
else if (sprints.length > limit) term.out([...formatSprintRows(shown), searchFooter(limit)]);
|
|
4822
|
+
else term.out(formatSprintRows(shown));
|
|
4823
|
+
}
|
|
4824
|
+
function withoutSprints(err, id) {
|
|
4825
|
+
if (!(err instanceof HttpError) || err.status !== 400) return err;
|
|
4826
|
+
return /* @__PURE__ */ new Error(`Board ${id} does not support sprints. Only scrum boards have them.`);
|
|
4827
|
+
}
|
|
4828
|
+
function formatSprintRows(sprints) {
|
|
4829
|
+
const cells = sprints.map((s) => [
|
|
4830
|
+
String(s.id),
|
|
4831
|
+
s.state,
|
|
4832
|
+
s.name,
|
|
4833
|
+
s.start ? formatDate$1(s.start) : "-",
|
|
4834
|
+
s.end ? formatDate$1(s.end) : "-"
|
|
4835
|
+
]);
|
|
4836
|
+
const widths = cells[0].map((_, i) => Math.max(...cells.map((row) => row[i].length)));
|
|
4837
|
+
return cells.map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd());
|
|
4838
|
+
}
|
|
4839
|
+
//#endregion
|
|
4122
4840
|
//#region src/cli/jira.ts
|
|
4841
|
+
const FIELD_OPTION = [
|
|
4842
|
+
"-f, --field <name|id>",
|
|
4843
|
+
"add a field's value to each issue (repeatable)",
|
|
4844
|
+
collect
|
|
4845
|
+
];
|
|
4123
4846
|
function registerJira(jira) {
|
|
4124
4847
|
jira.description("Jira commands");
|
|
4125
4848
|
jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(jiraAction(jiraProjects));
|
|
4126
4849
|
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));
|
|
4850
|
+
jira.command("boards [query]").description("List boards (optionally filtered by name, scoped with --project)").option("-p, --project <key>", "limit to boards located in a project").option("--json", "output results as JSON").action(jiraAction(jiraBoards));
|
|
4851
|
+
jira.command("board <ref>").description("Show a board's estimation field, filter JQL and columns (id, URL or exact name)").option("--json", "output results as JSON").action(jiraAction(jiraBoard));
|
|
4852
|
+
jira.command("sprints <ref>").description("List a board's sprints, active by default (id, URL or exact name)").option("-s, --state <state>", "active, future, or closed (repeatable)", collect).option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(jiraAction(jiraSprints));
|
|
4127
4853
|
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)", collect).option("-c, --component <name>", "component (repeatable)", 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));
|
|
4128
|
-
jira.command("fields
|
|
4854
|
+
jira.command("fields [project] [type]").description("List site fields, or the issue types a project offers, or the create form for one type").option("--search <text>", "site fields only: filter by name or id").option("--json", "output results as JSON").action(jiraAction(jiraFields));
|
|
4129
4855
|
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));
|
|
4130
4856
|
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));
|
|
4131
4857
|
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));
|
|
4132
|
-
outputOptions(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")).action(jiraAction(jiraList));
|
|
4133
|
-
outputOptions(jira.command("search [query]").description("Search Jira issues by text and filters").option("-p, --project <key>", "limit to a project (repeatable)", collect).option("-a, --assignee <who>", "limit to an assignee: me, a name, or an account id", collect).option("--reporter <who>", "limit to a reporter: me, a name, or an account id", collect).option("-s, --status <status>", "limit to a status (repeatable)", collect).option("-t, --type <type>", "limit to an issue type (repeatable)", collect).option("--label <label>", "limit to a label (repeatable)", collect).option("-u, --updated <when>", "changed since 7d, 2w, 3m, or YYYY-MM-DD").option("--open", "exclude issues in the Done category").addOption(moved("--jql <jql>", "jira jql"))).action(jiraAction(jiraSearch));
|
|
4134
|
-
outputOptions(jira.command("jql <query>").description("Search Jira issues with a raw JQL query")).action(jiraAction(jiraJql));
|
|
4858
|
+
outputOptions(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(...FIELD_OPTION), "max results (default all, max 100)").action(jiraAction(jiraList));
|
|
4859
|
+
outputOptions(jira.command("search [query]").description("Search Jira issues by text and filters").option("-p, --project <key>", "limit to a project (repeatable)", collect).option("-a, --assignee <who>", "limit to an assignee: me, a name, or an account id", collect).option("--reporter <who>", "limit to a reporter: me, a name, or an account id", collect).option("-s, --status <status>", "limit to a status (repeatable)", collect).option("-t, --type <type>", "limit to an issue type (repeatable)", collect).option("--label <label>", "limit to a label (repeatable)", collect).option("--sprint <sprint>", "limit to a sprint: active, future, an id, or a name (repeatable)", collect).option("-u, --updated <when>", "changed since 7d, 2w, 3m, or YYYY-MM-DD").option("--open", "exclude issues in the Done category").option(...FIELD_OPTION).addOption(moved("--jql <jql>", "jira jql"))).action(jiraAction(jiraSearch));
|
|
4860
|
+
outputOptions(jira.command("jql <query>").description("Search Jira issues with a raw JQL query").option(...FIELD_OPTION)).action(jiraAction(jiraJql));
|
|
4135
4861
|
return jira;
|
|
4136
4862
|
}
|
|
4137
4863
|
//#endregion
|
|
4138
4864
|
//#region package.json
|
|
4139
|
-
var version = "2.
|
|
4865
|
+
var version = "2.2.0";
|
|
4140
4866
|
//#endregion
|
|
4141
4867
|
//#region src/cli/build.ts
|
|
4142
4868
|
const PRODUCTS = {
|