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.
@@ -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)) process.exit(SIGINT_EXIT_CODE);
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.exit(1);
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
- const raw = (values ?? []).flatMap((value) => value.split(","));
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: `${row.id.padEnd(idWidth)} ${row.color(row.label.padEnd(labelWidth))} ${row.age.padEnd(ageWidth)}`,
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>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy");
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/query.ts
1895
- function quote$1(value) {
1896
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
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 values(given) {
1899
- return (given ?? []).map((v) => v.trim()).filter((v) => v.length > 0);
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
- function inClause(field, given) {
1902
- return renderClause(field, values(given).map(quote$1));
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 userClause(field, given) {
1905
- return renderClause(field, values(given).map((v) => v === "me" ? "currentUser()" : quote$1(v)));
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 renderClause(field, rendered) {
1908
- if (rendered.length === 0) return null;
1909
- if (rendered.length === 1) return `${field} = ${rendered[0]}`;
1910
- return `${field} in (${rendered.join(", ")})`;
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 joinClauses(clauses, order) {
1913
- return `${clauses.filter((c) => c !== null).join(" AND ")} ORDER BY ${order}`;
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
- //#endregion
1916
- //#region src/util/html.ts
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 isCodePoint(code) {
1936
- return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
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-search.ts
1940
- async function searchPages(client, site, params) {
1941
- return searchPagesByCql(client, site, buildCql(params), params.limit);
1942
- }
1943
- async function searchPagesByCql(client, site, cql, limit) {
1944
- const query = new URLSearchParams({
1945
- cql,
1946
- limit: String(limit),
1947
- expand: "content.space"
1948
- });
1949
- const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
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
- pages: results.filter((r) => r.content?.id).map((r) => ({
1953
- id: r.content?.id ?? "",
1954
- space: r.content?.space?.key ?? spaceKeyOf(r.resultGlobalContainer?.displayUrl),
1955
- title: decodeEntities(r.content?.title ?? r.title ?? ""),
1956
- updated: r.lastModified ?? "",
1957
- url: r.url ? `${site}/wiki${r.url}` : ""
1958
- })),
1959
- hasMore: serverPageWasFull
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
- const SPACE_URL = /\/spaces\/([^/?#]+)/;
1963
- function spaceKeyOf(displayUrl) {
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
- type: "mediaSingle",
1983
- attrs: { layout: "center" },
1984
- content: [{
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 imageHrefs(md) {
1991
- const hrefs = /* @__PURE__ */ new Set();
1992
- marked.walkTokens(marked.lexer(md), (token) => {
1993
- if (token.type === "image") hrefs.add(token.href);
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
- type: "doc",
2001
- version: 1,
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 blocks(tokens, ctx) {
2006
- const out = [];
2007
- for (const token of tokens) out.push(...block(token, ctx));
2008
- return out;
2009
- }
2010
- function block(token, ctx) {
2011
- switch (token.type) {
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": return textNode(token.text, marks);
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/api/confluence-attachments.ts
2182
- const UNKNOWN_SIZE = -1;
2183
- async function listAttachments(client, id) {
2184
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
2185
- filename: a.title ?? a.id,
2186
- fileId: mediaNodeId(a),
2187
- size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
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
- async function uploadAttachment(client, pageId, filename, bytes) {
2191
- return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
2342
+ function formatLossy(counts) {
2343
+ return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
2192
2344
  }
2193
- async function fileIdByListing(client, pageId, filename) {
2194
- const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
2195
- if (match) return match.fileId;
2196
- throw new Error(`Upload of "${filename}" did not return a fileId.`);
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
- async function fetchAttachments(client, id) {
2199
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
2200
- mediaId: mediaNodeId(a),
2201
- filename: a.title ?? a.id,
2202
- url: withWikiContextPath(a.downloadLink ?? "")
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 mediaNodeId(a) {
2206
- return a.fileId ?? a.id;
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
- function withWikiContextPath(link) {
2209
- if (link.startsWith("http") || link.startsWith("/wiki")) return link;
2210
- return `/wiki${link}`;
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
- //#endregion
2213
- //#region src/api/confluence-pages.ts
2214
- async function fetchPage(client, site, id) {
2215
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
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
- id: page.id,
2226
- title: page.title,
2227
- spaceKey,
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
- async function fetchPageState(client, id) {
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
- version: page.version?.number ?? 0,
2242
- title: page.title,
2243
- body: parseAdfJson(page.body?.atlas_doc_format?.value)
2428
+ local: local || "unknown",
2429
+ server: server || "unknown",
2430
+ stale: local !== server
2244
2431
  };
2245
2432
  }
2246
- async function updatePage(client, id, params) {
2247
- return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
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
- async function fetchSpaceKey(client, spaceId) {
2262
- if (!spaceId) return "";
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
- async function fetchComments$1(client, id, names) {
2270
- const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
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 parseAdfJson(value) {
2278
- if (!value) return null;
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 JSON.parse(value);
2595
+ return { size: await files.size(path) };
2281
2596
  } catch {
2282
- return null;
2597
+ return {};
2283
2598
  }
2284
2599
  }
2285
- var UserNames = class {
2286
- client;
2287
- cache = /* @__PURE__ */ new Map();
2288
- constructor(client) {
2289
- this.client = client;
2290
- }
2291
- async resolve(accountId) {
2292
- if (!accountId) return "";
2293
- const cached = this.cache.get(accountId);
2294
- if (cached !== void 0) return cached;
2295
- const name = await this.fetchDisplayName(accountId) ?? accountId;
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/api/attachments.ts
2321
- function mediaResolver(downloaded) {
2322
- const byMediaId = new Map(downloaded.map((d) => [d.mediaId, d.relativePath]));
2323
- const byFilename = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
2324
- return (media) => {
2325
- if (media.id && byMediaId.has(media.id)) return byMediaId.get(media.id);
2326
- if (media.alt && byFilename.has(media.alt)) return byFilename.get(media.alt);
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 FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---\n?/;
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
- frontmatter(doc.fields),
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(text) {
2348
- const match = text.match(FRONTMATTER_BLOCK);
2349
- if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
2350
- const fields = parseFrontmatter(match[1] ?? "");
2351
- const lines = text.slice(match[0].length).split("\n");
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 trailing = lines.findIndex((line, i) => i >= start && TRAILING_SECTION_HEADING.test(line));
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/copy/run.ts
2568
- async function runCopy(env, plan) {
2569
- const landed = await download(env, plan);
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: isExternalHref(href) ? "external" : "unsupported"
2982
+ kind: imageKind(href, localImages)
2622
2983
  }));
2623
- const unsupported = images.filter((i) => i.kind === "unsupported").map((i) => i.href);
2624
- const body = markdownToAdf(source.body, { resolveImage: (href, alt) => isExternalHref(href) ? externalMedia(href, alt) : void 0 });
2625
- 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)];
2626
- return withVerdict({
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
- uploads: [],
2634
- body,
2635
- refusals
2636
- }, options.force ?? false);
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 formatPlan(plan) {
2639
- const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
2640
- if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
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
- if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
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
- const IMAGE_COUNT_LABELS = [
2650
- ["upload", "new"],
2651
- ["changed", "changed"],
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 row(label, value) {
2664
- return ` ${`${label}:`.padEnd(9)}${value}`;
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
- function headline(label, current, title, useTitle) {
2667
- return {
2668
- label,
2669
- current,
2670
- next: useTitle && title ? title : current
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 revision(local, server) {
2674
- return {
2675
- local: local || "unknown",
2676
- server: server || "unknown",
2677
- stale: local !== server
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
- function revisionText(revision) {
2681
- return `copied at ${revision.local}, server now ${revision.server}`;
3089
+ //#endregion
3090
+ //#region src/api/query.ts
3091
+ function quote(value) {
3092
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
2682
3093
  }
2683
- function emptyBodyRefusal(body) {
2684
- if (body.content && body.content.length > 0) return [];
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 withVerdict(plan, force) {
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 verdict(plan, force) {
2694
- if (plan.refusals.length > 0) return {
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 capitalize(text) {
2710
- return text.charAt(0).toUpperCase() + text.slice(1);
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/update/plan-page.ts
2714
- function planPageUpdate(source, state, attachments, localImages, options) {
2715
- const collection = `contentId-${source.id}`;
2716
- const entries = imageHrefs(source.body).map((href) => pageImage(href, localImages, attachments));
2717
- const byHref = new Map(entries.map((e) => [e.href, e]));
2718
- const body = markdownToAdf(source.body, { resolveImage: (href, alt) => {
2719
- const entry = byHref.get(href);
2720
- if (!entry || entry.kind === "missing") return void 0;
2721
- if (entry.kind === "external") return externalMedia(href, alt);
2722
- return mediaNode({
2723
- type: "file",
2724
- id: entry.kind === "reuse" ? entry.fileId : href,
2725
- collection
2726
- }, alt);
2727
- } });
2728
- const missing = entries.filter((e) => e.kind === "missing").map((e) => e.href);
2729
- const refusals = [...missing.length > 0 ? [`Image file(s) not found: ${missing.join(", ")}`] : [], ...emptyBodyRefusal(body)];
2730
- return withVerdict({
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 pageImage(href, localImages, attachments) {
2750
- if (isExternalHref(href)) return {
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
- function withUploadedIds(doc, ids) {
2773
- if (!doc.content) return doc;
2774
- return {
2775
- ...doc,
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 withUploadedId(node, ids) {
2780
- const id = node.type === "media" ? node.attrs?.["id"] : void 0;
2781
- const swapped = typeof id === "string" && ids.has(id) ? {
2782
- ...node,
2783
- attrs: {
2784
- ...node.attrs,
2785
- id: ids.get(id)
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
- ...swapped,
2791
- content: swapped.content.map((n) => withUploadedId(n, ids))
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 verdict = plan.verdict;
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
- message: verdict.message,
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({ session, term, files }, arg, options) {
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 src = parsePageSource(await files.readText(file));
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), options);
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 ids = /* @__PURE__ */ new Map();
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
- async function statLocalImages(files, dir, md) {
2868
- const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
2869
- return Promise.all(hrefs.map(async (href) => {
2870
- const path = isAbsolute(href) ? href : resolve(dir, href);
2871
- return {
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
- const projects = [];
3071
- for (let startAt = 0;;) {
3072
- const res = await client.getJson(`/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`);
3073
- const values = res.values ?? [];
3074
- for (const p of values) projects.push({
3075
- key: p.key,
3076
- name: p.name,
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(PROJECT_PAGE_SIZE),
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
- async function jiraFields({ session, term }, projectArg, typeArg, options) {
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 (options.json) term.json(types);
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 (options.json) term.json(fields);
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
- return {
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
- params.updatedSince ? `updated >= ${quote$1(params.updatedSince)}` : null,
3788
- params.text ? `text ~ ${quote$1(params.text)}` : null
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$1(params.project)}` : null,
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
- await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
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 src = parseIssueSource(await files.readText(file));
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
- await showIssues(env, await searchIssuesByJql(session, session.site, query, limit), limit, options);
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
- await runSearch(term, formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
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: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
4758
+ footer: listFooter(shown.length, sorted.length, truncated, limit)
4105
4759
  }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
4106
4760
  }
4107
- function formatIssueRows(issues, nowMs) {
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 <project> [type]").description("Show the issue types you can create in a project, or the create form for one type").option("--json", "output results as JSON").action(jiraAction(jiraFields));
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.0.0";
4865
+ var version = "2.2.0";
4140
4866
  //#endregion
4141
4867
  //#region src/cli/build.ts
4142
4868
  const PRODUCTS = {