atlass 2.1.0 → 2.3.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.
@@ -1,6 +1,6 @@
1
1
  import { Command, Option } from "commander";
2
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
4
  import { Entry } from "@napi-rs/keyring";
5
5
  import { homedir } from "node:os";
6
6
  import { spawn } from "node:child_process";
@@ -29,7 +29,7 @@ var HttpError = class extends Error {
29
29
  };
30
30
  function httpError(status, path, body, loginHint) {
31
31
  if (status === 401 || status === 403) return new HttpError(status, `Authentication failed (401/403). Run \`${loginHint}\` to update your token.`, void 0, path);
32
- 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);
33
33
  if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
34
34
  if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
35
35
  if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`, jiraErrors(body));
@@ -126,10 +126,11 @@ var AtlassianClient = class {
126
126
  headers: { Accept: "application/json" }
127
127
  });
128
128
  }
129
- async postMultipart(path, filename, bytes) {
129
+ async postMultipart(path, filename, bytes, extras = {}) {
130
130
  const form = new FormData();
131
- const blob = new Blob([bytes]);
131
+ const blob = new Blob([bytes], { type: extras.type });
132
132
  form.append("file", blob, filename);
133
+ for (const [name, value] of Object.entries(extras.fields ?? {})) form.append(name, value);
133
134
  return (await this.request(path, {
134
135
  method: "POST",
135
136
  headers: {
@@ -447,14 +448,58 @@ function guarded(fn) {
447
448
  };
448
449
  }
449
450
  function fail(err) {
450
- if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
451
+ if (isPromptInterrupt(err)) {
452
+ process.exitCode = SIGINT_EXIT_CODE;
453
+ return;
454
+ }
451
455
  ttyTerminal().err(`Error: ${err instanceof Error ? err.message : String(err)}`);
452
- process.exit(1);
456
+ process.exitCode = 1;
453
457
  }
454
458
  function isPromptInterrupt(err) {
455
459
  return err instanceof Error && err.name === "ExitPromptError";
456
460
  }
457
461
  //#endregion
462
+ //#region src/util/format.ts
463
+ function formatDuration(seconds) {
464
+ if (seconds == null) return "-";
465
+ if (seconds < 60) return `${seconds}s`;
466
+ const m = Math.floor(seconds / 60);
467
+ const s = seconds % 60;
468
+ if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
469
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
470
+ }
471
+ const pad = (n) => String(n).padStart(2, "0");
472
+ function formatDate$1(iso) {
473
+ const date = new Date(iso);
474
+ if (Number.isNaN(date.getTime())) return iso;
475
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
476
+ }
477
+ function formatDateTime(iso) {
478
+ const date = new Date(iso);
479
+ if (Number.isNaN(date.getTime())) return iso;
480
+ return `${formatDate$1(iso)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
481
+ }
482
+ const MINUTE = 60;
483
+ const HOUR = 3600;
484
+ const DAY = 24 * HOUR;
485
+ function relativeTime(iso, nowMs) {
486
+ if (!iso) return "-";
487
+ const then = Date.parse(iso);
488
+ if (Number.isNaN(then)) return "-";
489
+ const secs = Math.floor((nowMs - then) / 1e3);
490
+ if (secs < MINUTE) return "just now";
491
+ if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
492
+ if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
493
+ const days = Math.floor(secs / DAY);
494
+ if (days < 45) return `${days}d ago`;
495
+ if (days < 365) return `${Math.floor(days / 30)}mo ago`;
496
+ return `${Math.floor(days / 365)}y ago`;
497
+ }
498
+ function orList(items) {
499
+ if (items.length < 3) return items.join(" or ");
500
+ return `${items.slice(0, -1).join(", ")}, or ${items.at(-1)}`;
501
+ }
502
+ //#endregion
458
503
  //#region src/util/parse.ts
459
504
  function parseIssueKey(input) {
460
505
  const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
@@ -469,6 +514,13 @@ function parsePageId(input) {
469
514
  if (fromQuery) return fromQuery[1];
470
515
  return null;
471
516
  }
517
+ function parseBoardId(input) {
518
+ const trimmed = input.trim();
519
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
520
+ if (!/^https?:\/\//i.test(trimmed)) return null;
521
+ const match = trimmed.match(/\/boards\/(\d+)(?:[/?#]|$)/) ?? trimmed.match(/[?&]rapidView=(\d+)/);
522
+ return match ? Number(match[1]) : null;
523
+ }
472
524
  const PULL_REQUEST_HOSTS = ["bitbucket.org", "www.bitbucket.org"];
473
525
  const PULL_REQUEST_PATH = /^\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)(?:\/|$)/;
474
526
  function parsePullRequestRef(input) {
@@ -522,6 +574,19 @@ function resolveRepo(flag, config) {
522
574
  };
523
575
  throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
524
576
  }
577
+ function parseChoices(values, choices, flag) {
578
+ const picked = /* @__PURE__ */ new Set();
579
+ const raw = (values ?? []).flatMap((v) => v.split(",")).map((v) => v.trim());
580
+ for (const value of raw.filter(Boolean)) {
581
+ const choice = choices.find((c) => c.toLowerCase() === value.toLowerCase());
582
+ if (!choice) {
583
+ const expected = orList(choices.map((c) => c.toLowerCase()));
584
+ throw new Error(`Invalid ${flag} "${value}". Expected ${expected}.`);
585
+ }
586
+ picked.add(choice);
587
+ }
588
+ return choices.filter((c) => picked.has(c));
589
+ }
525
590
  const DEFAULT_LIMIT = 25;
526
591
  const MAX_LIMIT = 100;
527
592
  function parseLimit(value) {
@@ -768,15 +833,7 @@ const PULL_REQUEST_STATES = [
768
833
  "SUPERSEDED"
769
834
  ];
770
835
  function parsePullRequestStates(values) {
771
- const raw = (values ?? []).flatMap((value) => value.split(","));
772
- const states = [];
773
- for (const value of raw) {
774
- const state = value.trim().toUpperCase();
775
- if (!state) continue;
776
- if (!PULL_REQUEST_STATES.includes(state)) throw new Error(`Invalid --state "${value.trim()}". Expected ${PULL_REQUEST_STATES.map((s) => s.toLowerCase()).join(", ").replace(/, (?=[^,]*$)/, ", or ")}.`);
777
- if (!states.includes(state)) states.push(state);
778
- }
779
- return states;
836
+ return parseChoices(values, PULL_REQUEST_STATES, "--state");
780
837
  }
781
838
  function allPullRequestStates() {
782
839
  return [...PULL_REQUEST_STATES];
@@ -942,43 +999,6 @@ async function listChangedFiles(client, ref, id) {
942
999
  return collectCapped(client, `${pullRequestPath(ref, id, "diffstat")}?pagelen=${PAGELEN}`, 50, toChangedFile);
943
1000
  }
944
1001
  //#endregion
945
- //#region src/util/format.ts
946
- function formatDuration(seconds) {
947
- if (seconds == null) return "-";
948
- if (seconds < 60) return `${seconds}s`;
949
- const m = Math.floor(seconds / 60);
950
- const s = seconds % 60;
951
- if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
952
- return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
953
- }
954
- const pad = (n) => String(n).padStart(2, "0");
955
- function formatDate$1(iso) {
956
- const date = new Date(iso);
957
- if (Number.isNaN(date.getTime())) return iso;
958
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
959
- }
960
- function formatDateTime(iso) {
961
- const date = new Date(iso);
962
- if (Number.isNaN(date.getTime())) return iso;
963
- return `${formatDate$1(iso)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
964
- }
965
- const MINUTE = 60;
966
- const HOUR = 3600;
967
- const DAY = 24 * HOUR;
968
- function relativeTime(iso, nowMs) {
969
- if (!iso) return "-";
970
- const then = Date.parse(iso);
971
- if (Number.isNaN(then)) return "-";
972
- const secs = Math.floor((nowMs - then) / 1e3);
973
- if (secs < MINUTE) return "just now";
974
- if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
975
- if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
976
- const days = Math.floor(secs / DAY);
977
- if (days < 45) return `${days}d ago`;
978
- if (days < 365) return `${Math.floor(days / 30)}mo ago`;
979
- return `${Math.floor(days / 365)}y ago`;
980
- }
981
- //#endregion
982
1002
  //#region src/commands/bitbucket-comments.ts
983
1003
  const MENTION = /@\{([^}]+)\}/g;
984
1004
  function renderedPrComments(comments, participants) {
@@ -1580,6 +1600,7 @@ function alignedRows(items, nowMs, cells) {
1580
1600
  const cell = cells(item);
1581
1601
  return {
1582
1602
  ...cell,
1603
+ columns: cell.columns ?? [],
1583
1604
  age: relativeTime(cell.timestamp, nowMs),
1584
1605
  json: { ...item }
1585
1606
  };
@@ -1587,11 +1608,17 @@ function alignedRows(items, nowMs, cells) {
1587
1608
  const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
1588
1609
  const idWidth = width((row) => row.id);
1589
1610
  const labelWidth = width((row) => row.label);
1611
+ const columnWidths = (rows[0]?.columns ?? []).map((_, i) => width((row) => row.columns[i]));
1590
1612
  const ageWidth = width((row) => row.age);
1591
1613
  return rows.map((row) => ({
1592
1614
  id: row.id,
1593
1615
  url: row.url,
1594
- fixedColumns: `${row.id.padEnd(idWidth)} ${row.color(row.label.padEnd(labelWidth))} ${row.age.padEnd(ageWidth)}`,
1616
+ fixedColumns: [
1617
+ row.id.padEnd(idWidth),
1618
+ row.color(row.label.padEnd(labelWidth)),
1619
+ ...row.columns.map((column, i) => column.padEnd(columnWidths[i])),
1620
+ row.age.padEnd(ageWidth)
1621
+ ].join(" "),
1595
1622
  freeText: row.text,
1596
1623
  json: row.json
1597
1624
  }));
@@ -1894,29 +1921,44 @@ function moved(flags, command) {
1894
1921
  throw new Error(`${flag} is now its own command: \`atlass ${command} "<query>"\`.`);
1895
1922
  });
1896
1923
  }
1897
- function outputOptions(command) {
1898
- 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");
1924
+ function outputOptions(command, limitHelp = "max results (default 25, max 100)") {
1925
+ 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");
1899
1926
  }
1900
1927
  //#endregion
1901
- //#region src/api/confluence-create.ts
1902
- async function findSpaceId(client, key) {
1903
- return (await client.getJson(`/wiki/api/v2/spaces?keys=${encodeURIComponent(key)}`)).results[0]?.id ?? null;
1928
+ //#region src/util/media-type.ts
1929
+ const BY_EXTENSION = {
1930
+ ".png": "image/png",
1931
+ ".jpg": "image/jpeg",
1932
+ ".jpeg": "image/jpeg",
1933
+ ".gif": "image/gif",
1934
+ ".webp": "image/webp",
1935
+ ".svg": "image/svg+xml",
1936
+ ".pdf": "application/pdf",
1937
+ ".txt": "text/plain",
1938
+ ".log": "text/plain",
1939
+ ".md": "text/markdown",
1940
+ ".csv": "text/csv",
1941
+ ".html": "text/html",
1942
+ ".json": "application/json",
1943
+ ".xml": "application/xml",
1944
+ ".zip": "application/zip",
1945
+ ".mp4": "video/mp4",
1946
+ ".m4v": "video/mp4",
1947
+ ".mov": "video/quicktime",
1948
+ ".webm": "video/webm",
1949
+ ".ogv": "video/ogg",
1950
+ ".avi": "video/x-msvideo",
1951
+ ".mkv": "video/x-matroska",
1952
+ ".wmv": "video/x-ms-wmv",
1953
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1954
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1955
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
1956
+ };
1957
+ function mediaType(filename) {
1958
+ return BY_EXTENSION[extname(filename).toLowerCase()];
1904
1959
  }
1905
- async function createPage(client, page) {
1906
- const res = await client.postJson("/wiki/api/v2/pages", {
1907
- spaceId: page.spaceId,
1908
- status: "current",
1909
- title: page.title,
1910
- ...page.parentId ? { parentId: page.parentId } : {},
1911
- body: {
1912
- representation: "atlas_doc_format",
1913
- value: JSON.stringify(page.body)
1914
- }
1915
- });
1916
- return {
1917
- id: res.id,
1918
- version: res.version?.number ?? 1
1919
- };
1960
+ function isVideo(href) {
1961
+ return mediaType(href.split(/[?#]/)[0] ?? "")?.startsWith("video/") ?? false;
1920
1962
  }
1921
1963
  //#endregion
1922
1964
  //#region src/api/confluence-attachments.ts
@@ -1928,8 +1970,23 @@ async function listAttachments(client, id) {
1928
1970
  size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
1929
1971
  }));
1930
1972
  }
1973
+ function uploadPath(pageId) {
1974
+ return `/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`;
1975
+ }
1931
1976
  async function uploadAttachment(client, pageId, filename, bytes) {
1932
- return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
1977
+ return (await client.postMultipart(uploadPath(pageId), filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
1978
+ }
1979
+ async function attachToPage(client, pageId, filename, bytes, comment) {
1980
+ const first = (await client.postMultipart(uploadPath(pageId), filename, bytes, {
1981
+ type: mediaType(filename),
1982
+ fields: comment ? { comment } : void 0
1983
+ })).results?.[0];
1984
+ if (!first?.id) throw new Error(`Upload of "${filename}" returned no attachment.`);
1985
+ return {
1986
+ filename: first.title ?? filename,
1987
+ id: first.id,
1988
+ size: first.extensions?.fileSize ?? bytes.byteLength
1989
+ };
1933
1990
  }
1934
1991
  async function fileIdByListing(client, pageId, filename) {
1935
1992
  const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
@@ -1951,154 +2008,465 @@ function withWikiContextPath(link) {
1951
2008
  return `/wiki${link}`;
1952
2009
  }
1953
2010
  //#endregion
1954
- //#region src/api/confluence-pages.ts
1955
- async function fetchPage(client, site, id) {
1956
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
1957
- const names = new UserNames(client);
1958
- const [spaceKey, attachments, comments, author] = await Promise.all([
1959
- fetchSpaceKey(client, page.spaceId),
1960
- fetchAttachments(client, id),
1961
- fetchComments$1(client, id, names),
1962
- names.resolve(page.version?.authorId ?? page.authorId)
1963
- ]);
1964
- const webui = page._links?.webui ?? "";
1965
- return {
1966
- id: page.id,
1967
- title: page.title,
1968
- spaceKey,
1969
- version: page.version?.number ?? 0,
1970
- author,
1971
- createdAt: page.createdAt ?? "",
1972
- updatedAt: page.version?.createdAt ?? "",
1973
- url: webui ? `${site}/wiki${webui}` : "",
1974
- body: parseAdfJson(page.body?.atlas_doc_format?.value),
1975
- attachments,
1976
- comments
1977
- };
1978
- }
1979
- async function fetchPageState(client, id) {
1980
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
1981
- return {
1982
- version: page.version?.number ?? 0,
1983
- title: page.title,
1984
- body: parseAdfJson(page.body?.atlas_doc_format?.value)
1985
- };
1986
- }
1987
- async function updatePage(client, id, params) {
1988
- const res = await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
1989
- id,
1990
- status: "current",
1991
- title: params.title,
1992
- body: {
1993
- representation: "atlas_doc_format",
1994
- value: JSON.stringify(params.body)
1995
- },
1996
- version: {
1997
- number: params.nextVersion,
1998
- message: params.message
1999
- }
2000
- });
2001
- return {
2002
- number: res.version?.number ?? params.nextVersion,
2003
- createdAt: res.version?.createdAt ?? ""
2004
- };
2005
- }
2006
- async function fetchSpaceKey(client, spaceId) {
2007
- if (!spaceId) return "";
2008
- try {
2009
- return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
2010
- } catch {
2011
- return "";
2012
- }
2013
- }
2014
- async function fetchComments$1(client, id, names) {
2015
- const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
2016
- return Promise.all(res.results.map(async (c) => ({
2017
- author: await names.resolve(c.version?.authorId),
2018
- created: c.version?.createdAt ?? "",
2019
- body: parseAdfJson(c.body?.atlas_doc_format?.value)
2011
+ //#region src/commands/attach.ts
2012
+ async function readLocal(files, paths) {
2013
+ const read = await Promise.all(paths.map(async (path) => ({
2014
+ path,
2015
+ bytes: await files.readBytes(path).catch(() => void 0)
2020
2016
  })));
2017
+ const unreadable = read.filter((r) => !r.bytes).map((r) => r.path);
2018
+ if (unreadable.length > 0) throw new Error(`Cannot read ${unreadable.join(", ")}.`);
2019
+ return read.map((r) => ({
2020
+ filename: basename(r.path),
2021
+ bytes: r.bytes
2022
+ }));
2021
2023
  }
2022
- function parseAdfJson(value) {
2023
- if (!value) return null;
2024
- try {
2025
- return JSON.parse(value);
2026
- } catch {
2027
- return null;
2028
- }
2029
- }
2030
- var UserNames = class {
2031
- client;
2032
- cache = /* @__PURE__ */ new Map();
2033
- constructor(client) {
2034
- this.client = client;
2035
- }
2036
- async resolve(accountId) {
2037
- if (!accountId) return "";
2038
- const cached = this.cache.get(accountId);
2039
- if (cached !== void 0) return cached;
2040
- const name = await this.fetchDisplayName(accountId) ?? accountId;
2041
- this.cache.set(accountId, name);
2042
- return name;
2043
- }
2044
- async fetchDisplayName(accountId) {
2024
+ async function attachAll(term, target, local, options, upload) {
2025
+ const attached = [];
2026
+ for (const file of local) {
2027
+ term.err(`Uploading ${file.filename} ...`);
2045
2028
  try {
2046
- return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
2047
- } catch {
2048
- return null;
2029
+ attached.push(await upload(file));
2030
+ } catch (err) {
2031
+ if (attached.length > 0) term.err(`Already attached to ${target}: ${names(attached)}.`);
2032
+ throw err;
2049
2033
  }
2050
2034
  }
2051
- };
2052
- //#endregion
2053
- //#region src/adf/html.ts
2054
- const COMMENT = /<!--[\s\S]*?-->/g;
2055
- function isOnlyComments(html) {
2056
- return html.replace(COMMENT, "").trim().length === 0;
2035
+ if (options.json) term.json(attached);
2036
+ else term.out(`Attached ${names(attached)} to ${target}.`);
2057
2037
  }
2058
- function withoutComments(md) {
2059
- const kept = [];
2060
- let skipSpace = false;
2061
- for (const token of marked.lexer(md)) {
2062
- if (skipSpace && token.type === "space") continue;
2063
- skipSpace = token.type === "html" && isOnlyComments(token.text);
2064
- if (skipSpace) continue;
2065
- kept.push(token.type === "code" ? token.raw : token.raw.replace(COMMENT, ""));
2066
- }
2067
- return kept.join("").trim();
2038
+ function names(attached) {
2039
+ return attached.map((a) => a.filename).join(", ");
2068
2040
  }
2069
- function htmlWarnings(md) {
2070
- let blocks = 0;
2071
- let inline = 0;
2072
- marked.walkTokens(marked.lexer(md), (token) => {
2073
- if (token.type !== "html" || isOnlyComments(token.text)) return;
2074
- if (token.block) blocks++;
2075
- else inline++;
2041
+ //#endregion
2042
+ //#region src/commands/resolve-ref.ts
2043
+ async function resolveRef(ask, arg, kind) {
2044
+ const raw = arg ?? await ask.text({
2045
+ message: kind.message,
2046
+ flag: kind.flag,
2047
+ required: true
2076
2048
  });
2077
- return [...blocks > 0 ? [`${counted(blocks, "HTML block")} will be dropped`] : [], ...inline > 0 ? [`${counted(inline, "inline HTML tag")} will be sent as text`] : []];
2078
- }
2079
- function counted(n, noun) {
2080
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
2049
+ const ref = kind.parse(raw);
2050
+ if (!ref) throw new Error(kind.notFound(raw));
2051
+ return ref;
2081
2052
  }
2082
- //#endregion
2083
- //#region src/adf/from-markdown.ts
2084
- function mediaNode(source, alt) {
2085
- const attrs = { ...source };
2086
- if (alt) attrs["alt"] = alt;
2053
+ function layoutOf(raw) {
2087
2054
  return {
2088
- type: "mediaSingle",
2089
- attrs: { layout: "center" },
2090
- content: [{
2091
- type: "media",
2092
- attrs
2093
- }]
2055
+ bom: raw.startsWith(""),
2056
+ crlf: /\r?\n/.exec(raw)?.[0] === "\r\n"
2094
2057
  };
2095
2058
  }
2096
- function imageHrefs(md) {
2097
- const hrefs = /* @__PURE__ */ new Set();
2098
- marked.walkTokens(marked.lexer(md), (token) => {
2099
- if (token.type === "image") hrefs.add(token.href);
2100
- });
2101
- return [...hrefs];
2059
+ function normalized(raw) {
2060
+ return (raw.startsWith("") ? raw.slice(1) : raw).replace(/\r\n/g, "\n");
2061
+ }
2062
+ function editKeepingLayout(raw, edit) {
2063
+ return withLayout(edit(normalized(raw)), layoutOf(raw));
2064
+ }
2065
+ function withLayout(text, layout) {
2066
+ const lines = layout.crlf ? text.replace(/\n/g, "\r\n") : text;
2067
+ return layout.bom ? `${lines}` : lines;
2068
+ }
2069
+ //#endregion
2070
+ //#region src/markdown/frontmatter.ts
2071
+ const BLOCK = /^---\n([\s\S]*?)\n---\n?/;
2072
+ const KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
2073
+ const LIST_ITEM = /^ {2}- (.*)$/;
2074
+ const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
2075
+ function splitFrontmatter(text) {
2076
+ const match = text.match(BLOCK);
2077
+ if (!match) return null;
2078
+ return {
2079
+ fields: parseFields(match[1] ?? ""),
2080
+ rest: text.slice(match[0].length)
2081
+ };
2082
+ }
2083
+ function formatFrontmatter(fields) {
2084
+ return [
2085
+ "---",
2086
+ ...Object.entries(fields).flatMap(([key, value]) => fieldLines(key, value)),
2087
+ "---"
2088
+ ].join("\n");
2089
+ }
2090
+ function rewriteFields(raw, fields) {
2091
+ return editKeepingLayout(raw, (text) => withFields(text, fields));
2092
+ }
2093
+ function withFields(text, fields) {
2094
+ const match = text.match(BLOCK);
2095
+ if (!match) return `${formatFrontmatter(fields)}\n\n${text}`;
2096
+ const lines = (match[1] ?? "").split("\n");
2097
+ const pending = new Map(Object.entries(fields));
2098
+ const kept = [];
2099
+ for (let i = 0; i < lines.length; i++) {
2100
+ const line = lines[i] ?? "";
2101
+ const key = line.match(KEY)?.[1];
2102
+ const value = key === void 0 ? void 0 : pending.get(key);
2103
+ if (key === void 0 || value === void 0) {
2104
+ kept.push(line);
2105
+ continue;
2106
+ }
2107
+ kept.push(...fieldLines(key, value));
2108
+ pending.delete(key);
2109
+ while (LIST_ITEM.test(lines[i + 1] ?? "")) i++;
2110
+ }
2111
+ for (const [key, value] of pending) kept.push(...fieldLines(key, value));
2112
+ const closing = match[0].endsWith("\n") ? "\n" : "";
2113
+ return `---\n${kept.join("\n")}\n---${closing}${text.slice(match[0].length)}`;
2114
+ }
2115
+ function fieldLines(key, value) {
2116
+ if (Array.isArray(value)) return value.length === 0 ? [`${key}: []`] : [`${key}:`, ...value.map((v) => ` - ${quote$1(v)}`)];
2117
+ return [`${key}: ${typeof value === "number" ? value : quote$1(value)}`];
2118
+ }
2119
+ function parseFields(block) {
2120
+ const fields = {};
2121
+ const lines = block.split("\n");
2122
+ for (let i = 0; i < lines.length; i++) {
2123
+ const m = lines[i]?.match(KEY);
2124
+ if (!m) continue;
2125
+ const key = m[1] ?? "";
2126
+ const raw = (m[2] ?? "").trim();
2127
+ if (raw === "[]") fields[key] = [];
2128
+ else if (raw === "") {
2129
+ const items = [];
2130
+ for (let item; item = lines[i + 1]?.match(LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
2131
+ fields[key] = items;
2132
+ } else fields[key] = scalar(raw);
2133
+ }
2134
+ return fields;
2135
+ }
2136
+ function scalar(raw) {
2137
+ if (raw.startsWith("\"")) return unquote(raw);
2138
+ if (BARE_NUMBER.test(raw)) return Number(raw);
2139
+ return raw;
2140
+ }
2141
+ function quote$1(value) {
2142
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
2143
+ }
2144
+ function unquote(value) {
2145
+ if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
2146
+ return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2147
+ }
2148
+ //#endregion
2149
+ //#region src/markdown/copied-document.ts
2150
+ const GENERATED_MARKER = "<!-- atlass:generated -->";
2151
+ const COMMENTS_HEADING = "## Comments";
2152
+ const ATTACHMENTS_HEADING = "## Attachments";
2153
+ const LEGACY_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
2154
+ function render(doc) {
2155
+ return `${[
2156
+ formatFrontmatter(doc.fields),
2157
+ `# ${doc.title}`,
2158
+ doc.body,
2159
+ GENERATED_MARKER,
2160
+ commentsSection(doc.comments),
2161
+ attachmentsSection(doc.attachments)
2162
+ ].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
2163
+ }
2164
+ function parse(raw) {
2165
+ const split = splitFrontmatter(normalized(raw));
2166
+ if (!split) throw new Error("Not an atlass file: no YAML frontmatter found.");
2167
+ return sourceOf(split, "marker-or-legacy-heading");
2168
+ }
2169
+ function parseDraft(raw) {
2170
+ const text = normalized(raw);
2171
+ return sourceOf(splitFrontmatter(text) ?? {
2172
+ fields: {},
2173
+ rest: text
2174
+ }, "marker");
2175
+ }
2176
+ function withGeneratedMarker(text) {
2177
+ if (text.split("\n").some(isMarker)) return text;
2178
+ return `${text.trimEnd()}\n\n${GENERATED_MARKER}\n`;
2179
+ }
2180
+ function sourceOf({ fields, rest }, ending) {
2181
+ const lines = rest.split("\n");
2182
+ const h1 = leadingH1(lines);
2183
+ const start = h1 ? h1.index + 1 : 0;
2184
+ const end = bodyEnd(lines, start, ending);
2185
+ const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
2186
+ return {
2187
+ fields,
2188
+ title: h1?.title ?? fallbackTitle,
2189
+ body: lines.slice(start, end).join("\n").trim()
2190
+ };
2191
+ }
2192
+ function parseIssueSource(text) {
2193
+ const source = parse(text);
2194
+ const key = scalarString(source.fields["key"]);
2195
+ if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
2196
+ return {
2197
+ ...source,
2198
+ key,
2199
+ updatedAtCopy: scalarString(source.fields["updated"])
2200
+ };
2201
+ }
2202
+ function parsePageSource(text) {
2203
+ const source = parse(text);
2204
+ const id = scalarString(source.fields["id"]);
2205
+ if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
2206
+ const rawVersion = scalarString(source.fields["version"]);
2207
+ const version = rawVersion === "" ? NaN : Number(rawVersion);
2208
+ if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
2209
+ return {
2210
+ ...source,
2211
+ id,
2212
+ version
2213
+ };
2214
+ }
2215
+ function scalarString(value) {
2216
+ if (typeof value === "string") return value;
2217
+ if (typeof value === "number") return String(value);
2218
+ return "";
2219
+ }
2220
+ function leadingH1(lines) {
2221
+ for (const [index, line] of lines.entries()) {
2222
+ if (line.startsWith("# ")) return {
2223
+ index,
2224
+ title: line.slice(2).trim()
2225
+ };
2226
+ if (line.trim().length > 0) return null;
2227
+ }
2228
+ return null;
2229
+ }
2230
+ function bodyEnd(lines, start, ending) {
2231
+ const after = (test) => lines.findIndex((line, i) => i >= start && test(line));
2232
+ const marker = after(isMarker);
2233
+ if (marker !== -1) return marker;
2234
+ const legacy = ending === "marker" ? -1 : after((line) => LEGACY_SECTION_HEADING.test(line));
2235
+ return legacy === -1 ? lines.length : legacy;
2236
+ }
2237
+ function isMarker(line) {
2238
+ return line.trimEnd() === GENERATED_MARKER;
2239
+ }
2240
+ function commentsSection(comments) {
2241
+ if (comments.length === 0) return "";
2242
+ const blocks = comments.map((c) => {
2243
+ const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
2244
+ return c.body ? `${heading}\n\n${c.body}` : heading;
2245
+ });
2246
+ return [
2247
+ COMMENTS_HEADING,
2248
+ "",
2249
+ blocks.join("\n\n")
2250
+ ].join("\n");
2251
+ }
2252
+ function attachmentsSection(attachments) {
2253
+ if (attachments.length === 0) return "";
2254
+ const items = attachments.map((a) => `- [${a.filename}](${a.relativePath})`);
2255
+ return [
2256
+ ATTACHMENTS_HEADING,
2257
+ "",
2258
+ ...items
2259
+ ].join("\n");
2260
+ }
2261
+ //#endregion
2262
+ //#region src/commands/page-ref.ts
2263
+ const PAGE_REF = {
2264
+ message: "Confluence page id or URL:",
2265
+ flag: "[page]",
2266
+ parse: parsePageId,
2267
+ notFound: (raw) => `Could not find a page id in "${raw}".`
2268
+ };
2269
+ async function pageIdOrFile({ term, files }, arg) {
2270
+ if (!arg?.endsWith(".md")) return resolveRef(term.ask, arg, PAGE_REF);
2271
+ const id = scalarString(parse(await files.readText(arg)).fields["id"]);
2272
+ if (!id) throw new Error(`${arg} has no page \`id\` in its frontmatter.`);
2273
+ return id;
2274
+ }
2275
+ //#endregion
2276
+ //#region src/commands/confluence-attach.ts
2277
+ async function confluenceAttach({ session, term, files }, arg, paths, options) {
2278
+ const id = await resolveRef(term.ask, arg, PAGE_REF);
2279
+ const local = await readLocal(files, paths);
2280
+ await refuseClashes(session, id, local);
2281
+ await attachAll(term, `page ${id}`, local, options, (file) => attachToPage(session, id, file.filename, file.bytes, options.comment));
2282
+ }
2283
+ async function refuseClashes(client, id, local) {
2284
+ const names = local.map((f) => f.filename);
2285
+ const repeated = names.filter((name, i) => names.indexOf(name) !== i);
2286
+ if (repeated.length > 0) throw new Error(`More than one file is named ${unique(repeated).join(", ")}.`);
2287
+ const existing = new Set((await listAttachments(client, id)).map((a) => a.filename));
2288
+ const clashes = names.filter((name) => existing.has(name));
2289
+ if (clashes.length > 0) throw new Error(`${clashes.join(", ")} already attached to page ${id}.`);
2290
+ }
2291
+ function unique(names) {
2292
+ return [...new Set(names)];
2293
+ }
2294
+ //#endregion
2295
+ //#region src/api/confluence-create.ts
2296
+ async function findSpaceId(client, key) {
2297
+ return (await client.getJson(`/wiki/api/v2/spaces?keys=${encodeURIComponent(key)}`)).results[0]?.id ?? null;
2298
+ }
2299
+ async function createPage(client, page) {
2300
+ const res = await client.postJson("/wiki/api/v2/pages", {
2301
+ spaceId: page.spaceId,
2302
+ status: "current",
2303
+ title: page.title,
2304
+ ...page.parentId ? { parentId: page.parentId } : {},
2305
+ body: {
2306
+ representation: "atlas_doc_format",
2307
+ value: JSON.stringify(page.body)
2308
+ }
2309
+ });
2310
+ return {
2311
+ id: res.id,
2312
+ version: res.version?.number ?? 1
2313
+ };
2314
+ }
2315
+ //#endregion
2316
+ //#region src/api/confluence-pages.ts
2317
+ async function fetchPage(client, site, id) {
2318
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
2319
+ const names = new UserNames(client);
2320
+ const [spaceKey, attachments, comments, author] = await Promise.all([
2321
+ fetchSpaceKey(client, page.spaceId),
2322
+ fetchAttachments(client, id),
2323
+ fetchComments$1(client, id, names),
2324
+ names.resolve(page.version?.authorId ?? page.authorId)
2325
+ ]);
2326
+ const webui = page._links?.webui ?? "";
2327
+ return {
2328
+ id: page.id,
2329
+ title: page.title,
2330
+ spaceKey,
2331
+ version: page.version?.number ?? 0,
2332
+ author,
2333
+ createdAt: page.createdAt ?? "",
2334
+ updatedAt: page.version?.createdAt ?? "",
2335
+ url: webui ? `${site}/wiki${webui}` : "",
2336
+ body: parseAdfJson(page.body?.atlas_doc_format?.value),
2337
+ attachments,
2338
+ comments
2339
+ };
2340
+ }
2341
+ async function fetchPageState(client, id) {
2342
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
2343
+ return {
2344
+ version: page.version?.number ?? 0,
2345
+ title: page.title,
2346
+ body: parseAdfJson(page.body?.atlas_doc_format?.value)
2347
+ };
2348
+ }
2349
+ async function updatePage(client, id, params) {
2350
+ const res = await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
2351
+ id,
2352
+ status: "current",
2353
+ title: params.title,
2354
+ body: {
2355
+ representation: "atlas_doc_format",
2356
+ value: JSON.stringify(params.body)
2357
+ },
2358
+ version: {
2359
+ number: params.nextVersion,
2360
+ message: params.message
2361
+ }
2362
+ });
2363
+ return {
2364
+ number: res.version?.number ?? params.nextVersion,
2365
+ createdAt: res.version?.createdAt ?? ""
2366
+ };
2367
+ }
2368
+ async function fetchSpaceKey(client, spaceId) {
2369
+ if (!spaceId) return "";
2370
+ try {
2371
+ return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
2372
+ } catch {
2373
+ return "";
2374
+ }
2375
+ }
2376
+ async function fetchComments$1(client, id, names) {
2377
+ const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
2378
+ return Promise.all(res.results.map(async (c) => ({
2379
+ author: await names.resolve(c.version?.authorId),
2380
+ created: c.version?.createdAt ?? "",
2381
+ body: parseAdfJson(c.body?.atlas_doc_format?.value)
2382
+ })));
2383
+ }
2384
+ function parseAdfJson(value) {
2385
+ if (!value) return null;
2386
+ try {
2387
+ return JSON.parse(value);
2388
+ } catch {
2389
+ return null;
2390
+ }
2391
+ }
2392
+ var UserNames = class {
2393
+ client;
2394
+ cache = /* @__PURE__ */ new Map();
2395
+ constructor(client) {
2396
+ this.client = client;
2397
+ }
2398
+ async resolve(accountId) {
2399
+ if (!accountId) return "";
2400
+ const cached = this.cache.get(accountId);
2401
+ if (cached !== void 0) return cached;
2402
+ const name = await this.fetchDisplayName(accountId) ?? accountId;
2403
+ this.cache.set(accountId, name);
2404
+ return name;
2405
+ }
2406
+ async fetchDisplayName(accountId) {
2407
+ try {
2408
+ return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
2409
+ } catch {
2410
+ return null;
2411
+ }
2412
+ }
2413
+ };
2414
+ //#endregion
2415
+ //#region src/adf/html.ts
2416
+ const COMMENT = /<!--[\s\S]*?-->/g;
2417
+ function isOnlyComments(html) {
2418
+ return html.replace(COMMENT, "").trim().length === 0;
2419
+ }
2420
+ function withoutComments(md) {
2421
+ const kept = [];
2422
+ let skipSpace = false;
2423
+ for (const token of marked.lexer(md)) {
2424
+ if (skipSpace && token.type === "space") continue;
2425
+ skipSpace = token.type === "html" && isOnlyComments(token.text);
2426
+ if (skipSpace) continue;
2427
+ kept.push(token.type === "code" ? token.raw : token.raw.replace(COMMENT, ""));
2428
+ }
2429
+ return kept.join("").trim();
2430
+ }
2431
+ function htmlWarnings(md) {
2432
+ let blocks = 0;
2433
+ let inline = 0;
2434
+ marked.walkTokens(marked.lexer(md), (token) => {
2435
+ if (token.type !== "html" || isOnlyComments(token.text)) return;
2436
+ if (token.block) blocks++;
2437
+ else inline++;
2438
+ });
2439
+ return [...blocks > 0 ? [`${counted(blocks, "HTML block")} will be dropped`] : [], ...inline > 0 ? [`${counted(inline, "inline HTML tag")} will be sent as text`] : []];
2440
+ }
2441
+ function counted(n, noun) {
2442
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
2443
+ }
2444
+ //#endregion
2445
+ //#region src/adf/from-markdown.ts
2446
+ const NATURAL_SIZE = { layout: "center" };
2447
+ const TEXT_WIDTH = {
2448
+ layout: "center",
2449
+ width: 100,
2450
+ widthType: "percentage"
2451
+ };
2452
+ function mediaNode(source, alt, href) {
2453
+ const attrs = { ...source };
2454
+ if (alt) attrs["alt"] = alt;
2455
+ return {
2456
+ type: "mediaSingle",
2457
+ attrs: isVideo(href) ? TEXT_WIDTH : NATURAL_SIZE,
2458
+ content: [{
2459
+ type: "media",
2460
+ attrs
2461
+ }]
2462
+ };
2463
+ }
2464
+ function imageHrefs(md) {
2465
+ const hrefs = /* @__PURE__ */ new Set();
2466
+ marked.walkTokens(marked.lexer(md), (token) => {
2467
+ if (token.type === "image") hrefs.add(token.href);
2468
+ });
2469
+ return [...hrefs];
2102
2470
  }
2103
2471
  function markdownToAdf(md, options = {}) {
2104
2472
  const ctx = { resolveImage: options.resolveImage ?? externalMedia };
@@ -2273,7 +2641,7 @@ function externalMedia(href, alt) {
2273
2641
  return mediaNode({
2274
2642
  type: "external",
2275
2643
  url: href
2276
- }, alt);
2644
+ }, alt, href);
2277
2645
  }
2278
2646
  function textToken(text) {
2279
2647
  return {
@@ -2458,7 +2826,7 @@ function planPageBody(page, attachments, localImages) {
2458
2826
  type: "file",
2459
2827
  id: entry.kind === "reuse" ? entry.fileId : href,
2460
2828
  collection
2461
- }, alt);
2829
+ }, alt, href);
2462
2830
  } }),
2463
2831
  uploads: images.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
2464
2832
  href: e.href,
@@ -2579,243 +2947,9 @@ function favouritePath(id) {
2579
2947
  async function starPage(client, id) {
2580
2948
  await client.putNoContent(favouritePath(id), void 0);
2581
2949
  }
2582
- async function unstarPage(client, id) {
2583
- await client.deleteNoContent(favouritePath(id));
2584
- }
2585
- //#endregion
2586
- //#region src/commands/resolve-ref.ts
2587
- async function resolveRef(ask, arg, kind) {
2588
- const raw = arg ?? await ask.text({
2589
- message: kind.message,
2590
- flag: kind.flag,
2591
- required: true
2592
- });
2593
- const ref = kind.parse(raw);
2594
- if (!ref) throw new Error(kind.notFound(raw));
2595
- return ref;
2596
- }
2597
- function layoutOf(raw) {
2598
- return {
2599
- bom: raw.startsWith(""),
2600
- crlf: /\r?\n/.exec(raw)?.[0] === "\r\n"
2601
- };
2602
- }
2603
- function normalized(raw) {
2604
- return (raw.startsWith("") ? raw.slice(1) : raw).replace(/\r\n/g, "\n");
2605
- }
2606
- function editKeepingLayout(raw, edit) {
2607
- return withLayout(edit(normalized(raw)), layoutOf(raw));
2608
- }
2609
- function withLayout(text, layout) {
2610
- const lines = layout.crlf ? text.replace(/\n/g, "\r\n") : text;
2611
- return layout.bom ? `${lines}` : lines;
2612
- }
2613
- //#endregion
2614
- //#region src/markdown/frontmatter.ts
2615
- const BLOCK = /^---\n([\s\S]*?)\n---\n?/;
2616
- const KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
2617
- const LIST_ITEM = /^ {2}- (.*)$/;
2618
- const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
2619
- function splitFrontmatter(text) {
2620
- const match = text.match(BLOCK);
2621
- if (!match) return null;
2622
- return {
2623
- fields: parseFields(match[1] ?? ""),
2624
- rest: text.slice(match[0].length)
2625
- };
2626
- }
2627
- function formatFrontmatter(fields) {
2628
- return [
2629
- "---",
2630
- ...Object.entries(fields).flatMap(([key, value]) => fieldLines(key, value)),
2631
- "---"
2632
- ].join("\n");
2633
- }
2634
- function rewriteFields(raw, fields) {
2635
- return editKeepingLayout(raw, (text) => withFields(text, fields));
2636
- }
2637
- function withFields(text, fields) {
2638
- const match = text.match(BLOCK);
2639
- if (!match) return `${formatFrontmatter(fields)}\n\n${text}`;
2640
- const lines = (match[1] ?? "").split("\n");
2641
- const pending = new Map(Object.entries(fields));
2642
- const kept = [];
2643
- for (let i = 0; i < lines.length; i++) {
2644
- const line = lines[i] ?? "";
2645
- const key = line.match(KEY)?.[1];
2646
- const value = key === void 0 ? void 0 : pending.get(key);
2647
- if (key === void 0 || value === void 0) {
2648
- kept.push(line);
2649
- continue;
2650
- }
2651
- kept.push(...fieldLines(key, value));
2652
- pending.delete(key);
2653
- while (LIST_ITEM.test(lines[i + 1] ?? "")) i++;
2654
- }
2655
- for (const [key, value] of pending) kept.push(...fieldLines(key, value));
2656
- const closing = match[0].endsWith("\n") ? "\n" : "";
2657
- return `---\n${kept.join("\n")}\n---${closing}${text.slice(match[0].length)}`;
2658
- }
2659
- function fieldLines(key, value) {
2660
- if (Array.isArray(value)) return value.length === 0 ? [`${key}: []`] : [`${key}:`, ...value.map((v) => ` - ${quote$1(v)}`)];
2661
- return [`${key}: ${typeof value === "number" ? value : quote$1(value)}`];
2662
- }
2663
- function parseFields(block) {
2664
- const fields = {};
2665
- const lines = block.split("\n");
2666
- for (let i = 0; i < lines.length; i++) {
2667
- const m = lines[i]?.match(KEY);
2668
- if (!m) continue;
2669
- const key = m[1] ?? "";
2670
- const raw = (m[2] ?? "").trim();
2671
- if (raw === "[]") fields[key] = [];
2672
- else if (raw === "") {
2673
- const items = [];
2674
- for (let item; item = lines[i + 1]?.match(LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
2675
- fields[key] = items;
2676
- } else fields[key] = scalar(raw);
2677
- }
2678
- return fields;
2679
- }
2680
- function scalar(raw) {
2681
- if (raw.startsWith("\"")) return unquote(raw);
2682
- if (BARE_NUMBER.test(raw)) return Number(raw);
2683
- return raw;
2684
- }
2685
- function quote$1(value) {
2686
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
2687
- }
2688
- function unquote(value) {
2689
- if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
2690
- return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2691
- }
2692
- //#endregion
2693
- //#region src/markdown/copied-document.ts
2694
- const GENERATED_MARKER = "<!-- atlass:generated -->";
2695
- const COMMENTS_HEADING = "## Comments";
2696
- const ATTACHMENTS_HEADING = "## Attachments";
2697
- const LEGACY_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
2698
- function render(doc) {
2699
- return `${[
2700
- formatFrontmatter(doc.fields),
2701
- `# ${doc.title}`,
2702
- doc.body,
2703
- GENERATED_MARKER,
2704
- commentsSection(doc.comments),
2705
- attachmentsSection(doc.attachments)
2706
- ].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
2707
- }
2708
- function parse(raw) {
2709
- const split = splitFrontmatter(normalized(raw));
2710
- if (!split) throw new Error("Not an atlass file: no YAML frontmatter found.");
2711
- return sourceOf(split, "marker-or-legacy-heading");
2712
- }
2713
- function parseDraft(raw) {
2714
- const text = normalized(raw);
2715
- return sourceOf(splitFrontmatter(text) ?? {
2716
- fields: {},
2717
- rest: text
2718
- }, "marker");
2719
- }
2720
- function withGeneratedMarker(text) {
2721
- if (text.split("\n").some(isMarker)) return text;
2722
- return `${text.trimEnd()}\n\n${GENERATED_MARKER}\n`;
2723
- }
2724
- function sourceOf({ fields, rest }, ending) {
2725
- const lines = rest.split("\n");
2726
- const h1 = leadingH1(lines);
2727
- const start = h1 ? h1.index + 1 : 0;
2728
- const end = bodyEnd(lines, start, ending);
2729
- const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
2730
- return {
2731
- fields,
2732
- title: h1?.title ?? fallbackTitle,
2733
- body: lines.slice(start, end).join("\n").trim()
2734
- };
2735
- }
2736
- function parseIssueSource(text) {
2737
- const source = parse(text);
2738
- const key = scalarString(source.fields["key"]);
2739
- if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
2740
- return {
2741
- ...source,
2742
- key,
2743
- updatedAtCopy: scalarString(source.fields["updated"])
2744
- };
2745
- }
2746
- function parsePageSource(text) {
2747
- const source = parse(text);
2748
- const id = scalarString(source.fields["id"]);
2749
- if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
2750
- const rawVersion = scalarString(source.fields["version"]);
2751
- const version = rawVersion === "" ? NaN : Number(rawVersion);
2752
- if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
2753
- return {
2754
- ...source,
2755
- id,
2756
- version
2757
- };
2758
- }
2759
- function scalarString(value) {
2760
- if (typeof value === "string") return value;
2761
- if (typeof value === "number") return String(value);
2762
- return "";
2763
- }
2764
- function leadingH1(lines) {
2765
- for (const [index, line] of lines.entries()) {
2766
- if (line.startsWith("# ")) return {
2767
- index,
2768
- title: line.slice(2).trim()
2769
- };
2770
- if (line.trim().length > 0) return null;
2771
- }
2772
- return null;
2773
- }
2774
- function bodyEnd(lines, start, ending) {
2775
- const after = (test) => lines.findIndex((line, i) => i >= start && test(line));
2776
- const marker = after(isMarker);
2777
- if (marker !== -1) return marker;
2778
- const legacy = ending === "marker" ? -1 : after((line) => LEGACY_SECTION_HEADING.test(line));
2779
- return legacy === -1 ? lines.length : legacy;
2780
- }
2781
- function isMarker(line) {
2782
- return line.trimEnd() === GENERATED_MARKER;
2783
- }
2784
- function commentsSection(comments) {
2785
- if (comments.length === 0) return "";
2786
- const blocks = comments.map((c) => {
2787
- const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
2788
- return c.body ? `${heading}\n\n${c.body}` : heading;
2789
- });
2790
- return [
2791
- COMMENTS_HEADING,
2792
- "",
2793
- blocks.join("\n\n")
2794
- ].join("\n");
2795
- }
2796
- function attachmentsSection(attachments) {
2797
- if (attachments.length === 0) return "";
2798
- const items = attachments.map((a) => `- [${a.filename}](${a.relativePath})`);
2799
- return [
2800
- ATTACHMENTS_HEADING,
2801
- "",
2802
- ...items
2803
- ].join("\n");
2804
- }
2805
- //#endregion
2806
- //#region src/commands/page-ref.ts
2807
- const PAGE_REF = {
2808
- message: "Confluence page id or URL:",
2809
- flag: "[page]",
2810
- parse: parsePageId,
2811
- notFound: (raw) => `Could not find a page id in "${raw}".`
2812
- };
2813
- async function pageIdOrFile({ term, files }, arg) {
2814
- if (!arg?.endsWith(".md")) return resolveRef(term.ask, arg, PAGE_REF);
2815
- const id = scalarString(parse(await files.readText(arg)).fields["id"]);
2816
- if (!id) throw new Error(`${arg} has no page \`id\` in its frontmatter.`);
2817
- return id;
2818
- }
2950
+ async function unstarPage(client, id) {
2951
+ await client.deleteNoContent(favouritePath(id));
2952
+ }
2819
2953
  //#endregion
2820
2954
  //#region src/commands/confluence-star.ts
2821
2955
  async function confluenceStar(env, arg) {
@@ -3074,6 +3208,11 @@ function inClause(field, given) {
3074
3208
  function userClause(field, given) {
3075
3209
  return renderClause(field, values(given).map((v) => v === "me" ? "currentUser()" : quote(v)));
3076
3210
  }
3211
+ function anyOf(terms) {
3212
+ if (terms.length === 0) return null;
3213
+ if (terms.length === 1) return terms[0];
3214
+ return `(${terms.join(" OR ")})`;
3215
+ }
3077
3216
  function renderClause(field, rendered) {
3078
3217
  if (rendered.length === 0) return null;
3079
3218
  if (rendered.length === 1) return `${field} = ${rendered[0]}`;
@@ -3328,6 +3467,7 @@ function registerConfluence(confluence) {
3328
3467
  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));
3329
3468
  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));
3330
3469
  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));
3470
+ confluence.command("attach <page> <files...>").description("Upload files as attachments to a Confluence page (id or URL)").option("--comment <text>", "attachment comment, applied to every file").option("--json", "print the uploaded attachments as JSON").action(jiraAction(confluenceAttach));
3331
3471
  confluence.command("star [page]").description("Star a Confluence page (id, URL, or copied file)").action(jiraAction(confluenceStar));
3332
3472
  confluence.command("unstar [page]").description("Unstar a Confluence page (id, URL, or copied file)").action(jiraAction(confluenceUnstar));
3333
3473
  outputOptions(confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space")).action(jiraAction(confluenceList));
@@ -3336,9 +3476,385 @@ function registerConfluence(confluence) {
3336
3476
  return confluence;
3337
3477
  }
3338
3478
  //#endregion
3339
- //#region src/api/jira-url.ts
3340
- function browseUrl(site, key) {
3341
- return `${site}/browse/${key}`;
3479
+ //#region src/api/jira-attachments.ts
3480
+ async function attachToIssue(client, key, filename, bytes) {
3481
+ const [first] = await client.postMultipart(`/rest/api/3/issue/${encodeURIComponent(key)}/attachments`, filename, bytes, { type: mediaType(filename) });
3482
+ if (!first) throw new Error(`Upload of "${filename}" returned no attachment.`);
3483
+ return {
3484
+ filename: first.filename,
3485
+ id: first.id,
3486
+ size: first.size
3487
+ };
3488
+ }
3489
+ //#endregion
3490
+ //#region src/api/jira-url.ts
3491
+ function browseUrl(site, key) {
3492
+ return `${site}/browse/${key}`;
3493
+ }
3494
+ function boardUrl(site, id) {
3495
+ return `${site}/secure/RapidBoard.jspa?rapidView=${id}`;
3496
+ }
3497
+ //#endregion
3498
+ //#region src/api/jira-issues.ts
3499
+ const FIELDS = [
3500
+ "summary",
3501
+ "description",
3502
+ "issuetype",
3503
+ "status",
3504
+ "assignee",
3505
+ "reporter",
3506
+ "priority",
3507
+ "labels",
3508
+ "created",
3509
+ "updated",
3510
+ "attachment"
3511
+ ].join(",");
3512
+ async function fetchIssue(client, site, key) {
3513
+ const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
3514
+ const comments = await fetchComments(client, key);
3515
+ const f = issue.fields;
3516
+ return {
3517
+ key: issue.key,
3518
+ url: browseUrl(site, issue.key),
3519
+ summary: f.summary ?? "",
3520
+ type: f.issuetype?.name ?? "",
3521
+ status: f.status?.name ?? "",
3522
+ statusCategory: f.status?.statusCategory?.key ?? "",
3523
+ assignee: f.assignee?.displayName ?? "Unassigned",
3524
+ reporter: f.reporter?.displayName ?? "",
3525
+ priority: f.priority?.name ?? "",
3526
+ labels: f.labels ?? [],
3527
+ created: f.created ?? "",
3528
+ updated: f.updated ?? "",
3529
+ description: f.description ?? null,
3530
+ comments,
3531
+ attachments: (f.attachment ?? []).map((a) => ({
3532
+ mediaId: a.id,
3533
+ filename: a.filename,
3534
+ url: a.content
3535
+ }))
3536
+ };
3537
+ }
3538
+ async function updateIssue(client, key, update) {
3539
+ const fields = { description: update.description };
3540
+ if (update.summary !== void 0) fields["summary"] = update.summary;
3541
+ const path = `/rest/api/3/issue/${encodeURIComponent(key)}`;
3542
+ await client.putNoContent(path, { fields });
3543
+ return (await client.getJson(`${path}?fields=updated`)).fields.updated ?? "";
3544
+ }
3545
+ async function fetchComments(client, key) {
3546
+ return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
3547
+ author: c.author?.displayName ?? "",
3548
+ created: c.created ?? "",
3549
+ body: c.body ?? null
3550
+ }));
3551
+ }
3552
+ //#endregion
3553
+ //#region src/api/offset-pages.ts
3554
+ async function allOffsetPages(client, pathAt) {
3555
+ const all = [];
3556
+ for (let startAt = 0;;) {
3557
+ const res = await client.getJson(pathAt(startAt));
3558
+ const values = res.values ?? [];
3559
+ all.push(...values);
3560
+ if (res.isLast || values.length === 0) return all;
3561
+ startAt += values.length;
3562
+ }
3563
+ }
3564
+ //#endregion
3565
+ //#region src/api/jira-projects.ts
3566
+ async function listProjects(client, site, query) {
3567
+ return (await allOffsetPages(client, (startAt) => `/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`)).map((p) => ({
3568
+ key: p.key,
3569
+ name: p.name,
3570
+ id: p.id,
3571
+ type: p.projectTypeKey ?? "",
3572
+ url: browseUrl(site, p.key)
3573
+ }));
3574
+ }
3575
+ function projectSearchQuery(query, startAt) {
3576
+ const params = new URLSearchParams({
3577
+ orderBy: "key",
3578
+ maxResults: String(50),
3579
+ startAt: String(startAt)
3580
+ });
3581
+ if (query) params.set("query", query);
3582
+ return params.toString();
3583
+ }
3584
+ //#endregion
3585
+ //#region src/api/jira-statuses.ts
3586
+ async function listStatuses(client, project) {
3587
+ return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await fetchAllStatuses(client)).map(toStatusSummary));
3588
+ }
3589
+ async function statusesById(client) {
3590
+ const raw = await fetchAllStatuses(client);
3591
+ return new Map(raw.map((s) => [s.id, toStatusSummary(s)]));
3592
+ }
3593
+ function fetchAllStatuses(client) {
3594
+ return client.getJson("/rest/api/3/status");
3595
+ }
3596
+ async function fetchProjectStatuses(client, project) {
3597
+ let groups;
3598
+ try {
3599
+ groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
3600
+ } catch (err) {
3601
+ if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
3602
+ throw err;
3603
+ }
3604
+ return groups.flatMap((g) => g.statuses ?? []);
3605
+ }
3606
+ function toStatusSummary(s) {
3607
+ return {
3608
+ name: s.name,
3609
+ id: s.id,
3610
+ category: s.statusCategory?.name ?? "",
3611
+ categoryKey: s.statusCategory?.key ?? ""
3612
+ };
3613
+ }
3614
+ const CATEGORY_ORDER = {
3615
+ new: 0,
3616
+ indeterminate: 1,
3617
+ done: 2
3618
+ };
3619
+ const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
3620
+ function dedupeAndSortStatuses(statuses) {
3621
+ const byNameCategory = /* @__PURE__ */ new Map();
3622
+ for (const s of statuses) {
3623
+ const key = `${s.name}\0${s.categoryKey}`;
3624
+ if (!byNameCategory.has(key)) byNameCategory.set(key, s);
3625
+ }
3626
+ return [...byNameCategory.values()].sort((a, b) => {
3627
+ const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
3628
+ return rank(a) - rank(b) || a.name.localeCompare(b.name);
3629
+ });
3630
+ }
3631
+ //#endregion
3632
+ //#region src/commands/jira.ts
3633
+ async function jiraProjects({ session, term }, query, options) {
3634
+ const projects = await listProjects(session, session.site, query);
3635
+ if (options.json) term.json(projects);
3636
+ else if (projects.length === 0) term.out("No matching projects.");
3637
+ else term.out(formatProjectRows(projects));
3638
+ }
3639
+ function formatProjectRows(projects) {
3640
+ const width = Math.max(...projects.map((p) => p.key.length));
3641
+ return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
3642
+ }
3643
+ async function jiraStatuses({ session, term }, query, options) {
3644
+ let statuses = await listStatuses(session, options.project);
3645
+ if (query) {
3646
+ const needle = query.toLowerCase();
3647
+ statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
3648
+ }
3649
+ if (options.json) term.json(statuses);
3650
+ else if (statuses.length === 0) term.out("No matching statuses.");
3651
+ else term.out(formatStatusRows(statuses));
3652
+ }
3653
+ function formatStatusRows(statuses) {
3654
+ const width = Math.max(...statuses.map((s) => s.name.length));
3655
+ return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
3656
+ }
3657
+ async function jiraView({ session, term }, arg, options) {
3658
+ const key = await resolveRef(term.ask, arg, ISSUE_REF);
3659
+ const lines = formatIssueView(await fetchIssue(session, session.site, key), Date.now(), options.allComments ?? false);
3660
+ await term.page(lines.join("\n"), { pager: options.pager });
3661
+ }
3662
+ const CATEGORY_COLORS = {
3663
+ new: kleur.cyan,
3664
+ indeterminate: kleur.yellow,
3665
+ done: kleur.green
3666
+ };
3667
+ function colorForCategory(category) {
3668
+ return CATEGORY_COLORS[category] ?? ((text) => text);
3669
+ }
3670
+ function formatIssueView(issue, nowMs, allComments) {
3671
+ const colorStatus = colorForCategory(issue.statusCategory);
3672
+ return [
3673
+ `${kleur.bold(issue.key)} ${issue.summary}`,
3674
+ ...fieldLines$1([
3675
+ ["Type", issue.type],
3676
+ ["Status", issue.status && colorStatus(issue.status)],
3677
+ ["Assignee", issue.assignee],
3678
+ ["Reporter", issue.reporter],
3679
+ ["Priority", issue.priority],
3680
+ ["Labels", issue.labels.join(", ")],
3681
+ ["Created", dateWithAge(issue.created, nowMs)],
3682
+ ["Updated", dateWithAge(issue.updated, nowMs)],
3683
+ ["URL", issue.url]
3684
+ ]),
3685
+ ...bodyLines(issue.description),
3686
+ ...commentSection(renderedComments(issue.comments), { allComments }),
3687
+ ...attachmentSection(issue.attachments)
3688
+ ];
3689
+ }
3690
+ async function jiraCopy(env, arg, options) {
3691
+ await copyIssue(env, await resolveRef(env.term.ask, arg, ISSUE_REF), options.out);
3692
+ }
3693
+ async function jiraUpdate({ session, term, files }, arg, options) {
3694
+ const file = arg ?? await term.ask.text({
3695
+ message: "Path to the issue Markdown file:",
3696
+ flag: "[file]",
3697
+ required: true
3698
+ });
3699
+ const raw = await files.readText(file);
3700
+ const src = parseIssueSource(raw);
3701
+ const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
3702
+ await runPlan(term, plan, options, async () => {
3703
+ const { current, next } = plan.headline;
3704
+ const updated = await updateIssue(session, src.key, {
3705
+ description: plan.body,
3706
+ summary: next !== current ? next : void 0
3707
+ });
3708
+ term.out(`Updated ${src.key}.`);
3709
+ if (updated) await files.writeText(file, rewriteFields(raw, { updated }));
3710
+ });
3711
+ }
3712
+ async function copyIssue(env, key, out) {
3713
+ env.term.err(`Fetching ${key} ...`);
3714
+ await runCopy(env, planIssueCopy(await fetchIssue(env.session, env.session.site, key), out));
3715
+ }
3716
+ const ISSUE_REF = {
3717
+ message: "Jira issue key or URL:",
3718
+ flag: "[issue]",
3719
+ parse: parseIssueKey,
3720
+ notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
3721
+ };
3722
+ //#endregion
3723
+ //#region src/commands/jira-attach.ts
3724
+ async function jiraAttach({ session, term, files }, arg, paths, options) {
3725
+ const key = await resolveRef(term.ask, arg, ISSUE_REF);
3726
+ await attachAll(term, key, await readLocal(files, paths), options, (file) => attachToIssue(session, key, file.filename, file.bytes));
3727
+ }
3728
+ //#endregion
3729
+ //#region src/api/jira-board.ts
3730
+ async function fetchBoard(client, site, id) {
3731
+ const [config, statuses] = await Promise.all([client.getJson(`/rest/agile/1.0/board/${id}/configuration`), statusesById(client)]);
3732
+ const statusFor = (statusId) => statuses.get(statusId) ?? {
3733
+ id: statusId,
3734
+ name: statusId,
3735
+ category: "",
3736
+ categoryKey: ""
3737
+ };
3738
+ return {
3739
+ id: config.id,
3740
+ name: config.name,
3741
+ type: config.type,
3742
+ url: boardUrl(site, config.id),
3743
+ estimation: estimationOf(config),
3744
+ filter: {
3745
+ id: config.filter.id,
3746
+ jql: await visibleFilterJql(client, config.filter.id),
3747
+ subQuery: config.subQuery?.query || null
3748
+ },
3749
+ columns: (config.columnConfig?.columns ?? []).map((c) => ({
3750
+ name: c.name,
3751
+ statuses: (c.statuses ?? []).map((s) => statusFor(s.id))
3752
+ }))
3753
+ };
3754
+ }
3755
+ async function visibleFilterJql(client, filterId) {
3756
+ try {
3757
+ return (await client.getJson(`/rest/api/3/filter/${filterId}`)).jql ?? null;
3758
+ } catch (err) {
3759
+ if (err instanceof HttpError && err.status === 404) return null;
3760
+ throw err;
3761
+ }
3762
+ }
3763
+ function estimationOf(config) {
3764
+ const field = config.estimation?.type === "field" ? config.estimation.field : void 0;
3765
+ return field ? {
3766
+ fieldId: field.fieldId,
3767
+ fieldName: field.displayName
3768
+ } : null;
3769
+ }
3770
+ //#endregion
3771
+ //#region src/api/jira-boards.ts
3772
+ async function listBoards(client, site) {
3773
+ return (await allOffsetPages(client, (startAt) => `/rest/agile/1.0/board?startAt=${startAt}&maxResults=50`)).map((b) => ({
3774
+ id: b.id,
3775
+ name: b.name,
3776
+ type: b.type,
3777
+ project: b.location?.projectKey ?? null,
3778
+ url: boardUrl(site, b.id)
3779
+ }));
3780
+ }
3781
+ //#endregion
3782
+ //#region src/commands/board-ref.ts
3783
+ async function withBoard(session, ref, load) {
3784
+ const id = await resolveBoardId(session, ref);
3785
+ try {
3786
+ return await load(id);
3787
+ } catch (err) {
3788
+ if (isMissingBoard(err, id)) throw unknownBoard(ref);
3789
+ throw err;
3790
+ }
3791
+ }
3792
+ async function resolveBoardId(session, ref) {
3793
+ const id = parseBoardId(ref);
3794
+ if (id !== null) return id;
3795
+ const name = ref.trim().toLowerCase();
3796
+ const matches = (await listBoards(session, session.site)).filter((b) => b.name.toLowerCase() === name);
3797
+ const [only, ...others] = matches;
3798
+ if (!only) throw unknownBoard(ref);
3799
+ if (others.length === 0) return only.id;
3800
+ const width = Math.max(...matches.map((b) => String(b.id).length));
3801
+ throw new Error([`"${ref}" matches several boards. Use an id:`, ...matches.map((b) => ` ${String(b.id).padEnd(width)} ${b.name}`)].join("\n"));
3802
+ }
3803
+ function isMissingBoard(err, id) {
3804
+ return err instanceof HttpError && err.status === 404 && err.path.startsWith(`/rest/agile/1.0/board/${id}/`);
3805
+ }
3806
+ function unknownBoard(ref) {
3807
+ return /* @__PURE__ */ new Error(`No board matches "${ref}". Run \`atlass jira boards\` to list boards.`);
3808
+ }
3809
+ //#endregion
3810
+ //#region src/commands/jira-board.ts
3811
+ async function jiraBoard({ session, term }, ref, options) {
3812
+ const board = await withBoard(session, ref, (id) => fetchBoard(session, session.site, id));
3813
+ if (options.json) term.json(board);
3814
+ else term.out(formatBoard(board));
3815
+ }
3816
+ function formatBoard(board) {
3817
+ return [
3818
+ board.name,
3819
+ ...fieldLines$1([
3820
+ ["ID", String(board.id)],
3821
+ ["Type", board.type],
3822
+ ["Estimation", estimationLine(board)],
3823
+ ["Filter", board.filter.jql ?? `not visible to you (filter ${board.filter.id})`],
3824
+ ["Sub-filter", board.filter.subQuery ?? ""],
3825
+ ["URL", board.url]
3826
+ ]),
3827
+ "",
3828
+ ...columnLines(board)
3829
+ ];
3830
+ }
3831
+ function estimationLine({ estimation }) {
3832
+ return estimation ? `${estimation.fieldName} (${estimation.fieldId})` : "none, this board does not estimate with a field";
3833
+ }
3834
+ function columnLines({ columns }) {
3835
+ const statuses = columns.flatMap((c) => c.statuses);
3836
+ const width = Math.max(0, ...statuses.map((s) => s.name.length));
3837
+ return columns.flatMap((c) => [c.name, ...c.statuses.map((s) => ` ${s.name.padEnd(width)} ${s.category}`.trimEnd())]);
3838
+ }
3839
+ //#endregion
3840
+ //#region src/commands/jira-boards.ts
3841
+ async function jiraBoards({ session, term }, query, options) {
3842
+ const boards = filterBoards(await listBoards(session, session.site), query, options.project);
3843
+ if (options.json) term.json(boards);
3844
+ else if (boards.length === 0) term.out("No matching boards.");
3845
+ else term.out(formatBoardRows(boards));
3846
+ }
3847
+ function filterBoards(boards, query, project) {
3848
+ const needle = query?.toLowerCase();
3849
+ const key = project?.toUpperCase();
3850
+ return boards.filter((b) => (!needle || b.name.toLowerCase().includes(needle)) && (!key || b.project?.toUpperCase() === key));
3851
+ }
3852
+ function formatBoardRows(boards) {
3853
+ const width = (pick) => Math.max(...boards.map((b) => pick(b).length));
3854
+ const idWidth = width((b) => String(b.id));
3855
+ const typeWidth = width((b) => b.type);
3856
+ const nameWidth = width((b) => b.name);
3857
+ return boards.map((b) => `${String(b.id).padEnd(idWidth)} ${b.type.padEnd(typeWidth)} ${b.name.padEnd(nameWidth)} ${b.project ?? ""}`.trimEnd());
3342
3858
  }
3343
3859
  //#endregion
3344
3860
  //#region src/api/jira-createmeta.ts
@@ -3431,35 +3947,6 @@ function toJiraUser(u) {
3431
3947
  };
3432
3948
  }
3433
3949
  //#endregion
3434
- //#region src/api/jira-projects.ts
3435
- const PROJECT_PAGE_SIZE = 50;
3436
- async function listProjects(client, site, query) {
3437
- const projects = [];
3438
- for (let startAt = 0;;) {
3439
- const res = await client.getJson(`/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`);
3440
- const values = res.values ?? [];
3441
- for (const p of values) projects.push({
3442
- key: p.key,
3443
- name: p.name,
3444
- id: p.id,
3445
- type: p.projectTypeKey ?? "",
3446
- url: browseUrl(site, p.key)
3447
- });
3448
- if (res.isLast || values.length === 0) break;
3449
- startAt += values.length;
3450
- }
3451
- return projects;
3452
- }
3453
- function projectSearchQuery(query, startAt) {
3454
- const params = new URLSearchParams({
3455
- orderBy: "key",
3456
- maxResults: String(PROJECT_PAGE_SIZE),
3457
- startAt: String(startAt)
3458
- });
3459
- if (query) params.set("query", query);
3460
- return params.toString();
3461
- }
3462
- //#endregion
3463
3950
  //#region src/commands/jira-create-resolve.ts
3464
3951
  async function resolveProject(ask, client, site, arg, strict) {
3465
3952
  if (arg) return arg.toUpperCase();
@@ -4098,17 +4585,55 @@ function describeRejection(err, meta) {
4098
4585
  ...perField
4099
4586
  ].join("\n"));
4100
4587
  }
4101
- async function jiraFields({ session, term }, projectArg, typeArg, options) {
4588
+ //#endregion
4589
+ //#region src/api/jira-fields.ts
4590
+ async function listSiteFields(client) {
4591
+ return (await client.getJson("/rest/api/3/field")).map((f) => ({
4592
+ id: f.id,
4593
+ name: decodeEntities(f.name),
4594
+ custom: f.custom ?? false,
4595
+ schema: f.schema ?? null
4596
+ }));
4597
+ }
4598
+ //#endregion
4599
+ //#region src/commands/jira-fields.ts
4600
+ async function jiraFields(env, projectArg, typeArg, options) {
4601
+ if (!projectArg) return siteFields(env, options);
4102
4602
  const project = projectArg.toUpperCase();
4603
+ 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.`);
4604
+ return createForm$1(env, project, typeArg, options);
4605
+ }
4606
+ async function siteFields({ session, term }, options) {
4607
+ const fields = matchingFields(await listSiteFields(session), options.search).sort((a, b) => a.name.localeCompare(b.name, void 0, { sensitivity: "base" }));
4608
+ if (options.json) term.json(fields);
4609
+ else if (fields.length === 0) term.out("No matching fields.");
4610
+ else term.out(formatSiteFieldRows(fields));
4611
+ }
4612
+ function matchingFields(fields, search) {
4613
+ const needle = search?.toLowerCase();
4614
+ return fields.filter((f) => !needle || f.name.toLowerCase().includes(needle) || f.id.toLowerCase().includes(needle));
4615
+ }
4616
+ function formatSiteFieldRows(fields) {
4617
+ const width = (pick) => Math.max(...fields.map((f) => pick(f).length));
4618
+ const idWidth = width((f) => f.id);
4619
+ const nameWidth = width((f) => f.name);
4620
+ return fields.map((f) => `${f.id.padEnd(idWidth)} ${f.name.padEnd(nameWidth)} ${shortFieldType(f)}`.trimEnd());
4621
+ }
4622
+ function shortFieldType(field) {
4623
+ const plugin = field.schema?.custom;
4624
+ if (plugin) return plugin.slice(plugin.lastIndexOf(":") + 1);
4625
+ return field.schema?.type ?? "";
4626
+ }
4627
+ async function createForm$1({ session, term }, project, typeArg, { json }) {
4103
4628
  const types = await fetchCreateIssueTypes(session, project);
4104
4629
  if (!typeArg) {
4105
- if (options.json) term.json(types);
4630
+ if (json) term.json(types);
4106
4631
  else if (types.length === 0) term.out(`You cannot create issues in ${project}.`);
4107
4632
  else term.out(formatIssueTypeRows(types));
4108
4633
  return;
4109
4634
  }
4110
4635
  const fields = await fetchCreateFields(session, project, (await resolveType(term.ask, project, types, typeArg, true)).id);
4111
- if (options.json) term.json(fields);
4636
+ if (json) term.json(fields);
4112
4637
  else term.out(formatFieldRows(fields));
4113
4638
  }
4114
4639
  //#endregion
@@ -4116,23 +4641,23 @@ async function jiraFields({ session, term }, projectArg, typeArg, options) {
4116
4641
  const RECENT_ISSUES_CLAUSE = "updated >= -30d";
4117
4642
  const OPEN_CLAUSE = "statusCategory != Done";
4118
4643
  const SEARCH_FIELDS = "summary,status,updated";
4119
- async function searchIssues(client, site, params) {
4120
- return searchIssuesByJql(client, site, buildJql(params), params.limit);
4644
+ async function searchIssues(client, site, params, fieldIds = []) {
4645
+ return searchIssuesByJql(client, site, buildJql(params), params.limit, fieldIds);
4121
4646
  }
4122
- async function searchIssuesByJql(client, site, jql, limit) {
4123
- return ((await fetchSearchPage(client, jql, limit)).issues ?? []).map((i) => toIssueSummary(site, i));
4647
+ async function searchIssuesByJql(client, site, jql, limit, fieldIds = []) {
4648
+ return ((await fetchSearchPage(client, jql, limit, fieldIds)).issues ?? []).map((i) => toIssueSummary(site, i, fieldIds));
4124
4649
  }
4125
- async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
4650
+ async function fetchSearchPage(client, jql, maxResults, fieldIds, nextPageToken) {
4126
4651
  const query = new URLSearchParams({
4127
4652
  jql,
4128
4653
  maxResults: String(maxResults),
4129
- fields: SEARCH_FIELDS
4654
+ fields: [SEARCH_FIELDS, ...fieldIds].join(",")
4130
4655
  });
4131
4656
  if (nextPageToken) query.set("nextPageToken", nextPageToken);
4132
4657
  return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
4133
4658
  }
4134
- function toIssueSummary(site, i) {
4135
- return {
4659
+ function toIssueSummary(site, i, fieldIds) {
4660
+ const summary = {
4136
4661
  key: i.key,
4137
4662
  status: i.fields?.status?.name ?? "",
4138
4663
  statusCategory: i.fields?.status?.statusCategory?.key ?? "",
@@ -4140,6 +4665,11 @@ function toIssueSummary(site, i) {
4140
4665
  updated: i.fields?.updated ?? "",
4141
4666
  url: browseUrl(site, i.key)
4142
4667
  };
4668
+ if (fieldIds.length === 0) return summary;
4669
+ return {
4670
+ ...summary,
4671
+ fields: Object.fromEntries(fieldIds.map((id) => [id, i.fields?.[id] ?? null]))
4672
+ };
4143
4673
  }
4144
4674
  const ISSUE_ORDER = "updated DESC";
4145
4675
  function buildJql(params) {
@@ -4151,12 +4681,22 @@ function buildJql(params) {
4151
4681
  userClause("assignee", params.assignee),
4152
4682
  userClause("reporter", params.reporter),
4153
4683
  inClause("labels", params.label),
4684
+ anyOf(values(params.sprint).map(sprintTerm)),
4154
4685
  params.updatedSince ? `updated >= ${quote(params.updatedSince)}` : null,
4155
4686
  params.text ? `text ~ ${quote(params.text)}` : null
4156
4687
  ].filter((c) => c !== null);
4157
4688
  if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
4158
4689
  return joinClauses(clauses, ISSUE_ORDER);
4159
4690
  }
4691
+ const SPRINT_FUNCTIONS = {
4692
+ active: "openSprints()",
4693
+ future: "futureSprints()"
4694
+ };
4695
+ function sprintTerm(sprint) {
4696
+ const sprintFunction = SPRINT_FUNCTIONS[sprint];
4697
+ if (sprintFunction) return `sprint in ${sprintFunction}`;
4698
+ return `sprint = ${/^\d+$/.test(sprint) ? sprint : quote(sprint)}`;
4699
+ }
4160
4700
  function buildListJql(params) {
4161
4701
  return joinClauses([
4162
4702
  "assignee = currentUser()",
@@ -4166,15 +4706,15 @@ function buildListJql(params) {
4166
4706
  }
4167
4707
  const LIST_PAGE_SIZE = 100;
4168
4708
  const LIST_CAP = 500;
4169
- async function listAssignedIssues(client, site, params) {
4709
+ async function listAssignedIssues(client, site, params, fieldIds = []) {
4170
4710
  const jql = buildListJql(params);
4171
4711
  const issues = [];
4172
4712
  let nextPageToken;
4173
4713
  for (;;) {
4174
4714
  const room = LIST_CAP - issues.length;
4175
- const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
4715
+ const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), fieldIds, nextPageToken);
4176
4716
  const page = res.issues ?? [];
4177
- issues.push(...page.map((i) => toIssueSummary(site, i)));
4717
+ issues.push(...page.map((i) => toIssueSummary(site, i, fieldIds)));
4178
4718
  if (res.isLast || !res.nextPageToken || page.length === 0) return {
4179
4719
  issues,
4180
4720
  truncated: false
@@ -4198,6 +4738,54 @@ function sortByCategoryThenUpdated(issues) {
4198
4738
  return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
4199
4739
  }
4200
4740
  //#endregion
4741
+ //#region src/commands/jira-field-columns.ts
4742
+ async function resolveFieldColumns(session, given) {
4743
+ const names = values(given);
4744
+ if (names.length === 0) return [];
4745
+ const fields = await listSiteFields(session);
4746
+ return names.map((name) => ({
4747
+ name,
4748
+ id: resolveFieldId(fields, name)
4749
+ }));
4750
+ }
4751
+ function resolveFieldId(fields, name) {
4752
+ const byId = fields.find((f) => f.id === name);
4753
+ if (byId) return byId.id;
4754
+ const lower = name.toLowerCase();
4755
+ const matches = fields.filter((f) => f.name.toLowerCase() === lower);
4756
+ const [only, ...others] = matches;
4757
+ if (!only) throw new Error(`No field matches "${name}". Run \`atlass jira fields --search ${shellWord(name)}\` to find it.`);
4758
+ if (others.length === 0) return only.id;
4759
+ const width = Math.max(...matches.map((f) => f.id.length));
4760
+ throw new Error([`"${name}" matches several fields. Use an id:`, ...matches.map((f) => ` ${f.id.padEnd(width)} ${f.name}`)].join("\n"));
4761
+ }
4762
+ function shellWord(text) {
4763
+ return /\s/.test(text) ? `"${text}"` : text;
4764
+ }
4765
+ function fieldIds(columns) {
4766
+ return [...new Set(columns.map((c) => c.id))];
4767
+ }
4768
+ function withNamedFields(issues, columns) {
4769
+ if (columns.length === 0) return issues;
4770
+ return issues.map((issue) => ({
4771
+ ...issue,
4772
+ fields: Object.fromEntries(columns.map((c) => [c.name, issue.fields?.[c.id] ?? null]))
4773
+ }));
4774
+ }
4775
+ const EMPTY_CELL = "-";
4776
+ function fieldCell(value) {
4777
+ const text = (Array.isArray(value) ? value.map(shownValue) : [shownValue(value)]).filter((p) => p !== "").join(", ");
4778
+ return text === "" ? EMPTY_CELL : text;
4779
+ }
4780
+ function shownValue(value) {
4781
+ if (value === null || value === void 0) return "";
4782
+ if (typeof value === "string") return value;
4783
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
4784
+ const named = value;
4785
+ const shown = named.value ?? named.displayName ?? named.name;
4786
+ return shown === void 0 ? JSON.stringify(value) : shownValue(shown);
4787
+ }
4788
+ //#endregion
4201
4789
  //#region src/commands/jira-filters.ts
4202
4790
  const FILTER_FLAGS = [
4203
4791
  "project",
@@ -4205,7 +4793,8 @@ const FILTER_FLAGS = [
4205
4793
  "reporter",
4206
4794
  "status",
4207
4795
  "type",
4208
- "label"
4796
+ "label",
4797
+ "sprint"
4209
4798
  ];
4210
4799
  const ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/i;
4211
4800
  function hasFilters(filters) {
@@ -4224,6 +4813,7 @@ async function searchParams(client, query, filters, limit, nowMs) {
4224
4813
  status: filters.status,
4225
4814
  type: filters.type,
4226
4815
  label: filters.label,
4816
+ sprint: filters.sprint,
4227
4817
  updatedSince: filters.updated ? parseSince(filters.updated, nowMs) : void 0,
4228
4818
  open: filters.open,
4229
4819
  limit
@@ -4252,209 +4842,25 @@ function searchHint(err, project) {
4252
4842
  return command ? /* @__PURE__ */ new Error(`${err.message} Run \`${command}\` to see the options.`) : err;
4253
4843
  }
4254
4844
  //#endregion
4255
- //#region src/api/jira-issues.ts
4256
- const FIELDS = [
4257
- "summary",
4258
- "description",
4259
- "issuetype",
4260
- "status",
4261
- "assignee",
4262
- "reporter",
4263
- "priority",
4264
- "labels",
4265
- "created",
4266
- "updated",
4267
- "attachment"
4268
- ].join(",");
4269
- async function fetchIssue(client, site, key) {
4270
- const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
4271
- const comments = await fetchComments(client, key);
4272
- const f = issue.fields;
4273
- return {
4274
- key: issue.key,
4275
- url: browseUrl(site, issue.key),
4276
- summary: f.summary ?? "",
4277
- type: f.issuetype?.name ?? "",
4278
- status: f.status?.name ?? "",
4279
- statusCategory: f.status?.statusCategory?.key ?? "",
4280
- assignee: f.assignee?.displayName ?? "Unassigned",
4281
- reporter: f.reporter?.displayName ?? "",
4282
- priority: f.priority?.name ?? "",
4283
- labels: f.labels ?? [],
4284
- created: f.created ?? "",
4285
- updated: f.updated ?? "",
4286
- description: f.description ?? null,
4287
- comments,
4288
- attachments: (f.attachment ?? []).map((a) => ({
4289
- mediaId: a.id,
4290
- filename: a.filename,
4291
- url: a.content
4292
- }))
4293
- };
4294
- }
4295
- async function updateIssue(client, key, update) {
4296
- const fields = { description: update.description };
4297
- if (update.summary !== void 0) fields["summary"] = update.summary;
4298
- const path = `/rest/api/3/issue/${encodeURIComponent(key)}`;
4299
- await client.putNoContent(path, { fields });
4300
- return (await client.getJson(`${path}?fields=updated`)).fields.updated ?? "";
4301
- }
4302
- async function fetchComments(client, key) {
4303
- return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
4304
- author: c.author?.displayName ?? "",
4305
- created: c.created ?? "",
4306
- body: c.body ?? null
4307
- }));
4308
- }
4309
- //#endregion
4310
- //#region src/api/jira-statuses.ts
4311
- async function listStatuses(client, project) {
4312
- return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await client.getJson("/rest/api/3/status")).map(toStatusSummary));
4313
- }
4314
- async function fetchProjectStatuses(client, project) {
4315
- let groups;
4316
- try {
4317
- groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
4318
- } catch (err) {
4319
- if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
4320
- throw err;
4321
- }
4322
- return groups.flatMap((g) => g.statuses ?? []);
4323
- }
4324
- function toStatusSummary(s) {
4325
- return {
4326
- name: s.name,
4327
- id: s.id,
4328
- category: s.statusCategory?.name ?? "",
4329
- categoryKey: s.statusCategory?.key ?? ""
4330
- };
4331
- }
4332
- const CATEGORY_ORDER = {
4333
- new: 0,
4334
- indeterminate: 1,
4335
- done: 2
4336
- };
4337
- const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
4338
- function dedupeAndSortStatuses(statuses) {
4339
- const byNameCategory = /* @__PURE__ */ new Map();
4340
- for (const s of statuses) {
4341
- const key = `${s.name}\0${s.categoryKey}`;
4342
- if (!byNameCategory.has(key)) byNameCategory.set(key, s);
4343
- }
4344
- return [...byNameCategory.values()].sort((a, b) => {
4345
- const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
4346
- return rank(a) - rank(b) || a.name.localeCompare(b.name);
4347
- });
4348
- }
4349
- //#endregion
4350
- //#region src/commands/jira.ts
4351
- async function jiraProjects({ session, term }, query, options) {
4352
- const projects = await listProjects(session, session.site, query);
4353
- if (options.json) term.json(projects);
4354
- else if (projects.length === 0) term.out("No matching projects.");
4355
- else term.out(formatProjectRows(projects));
4356
- }
4357
- function formatProjectRows(projects) {
4358
- const width = Math.max(...projects.map((p) => p.key.length));
4359
- return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
4360
- }
4361
- async function jiraStatuses({ session, term }, query, options) {
4362
- let statuses = await listStatuses(session, options.project);
4363
- if (query) {
4364
- const needle = query.toLowerCase();
4365
- statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
4366
- }
4367
- if (options.json) term.json(statuses);
4368
- else if (statuses.length === 0) term.out("No matching statuses.");
4369
- else term.out(formatStatusRows(statuses));
4370
- }
4371
- function formatStatusRows(statuses) {
4372
- const width = Math.max(...statuses.map((s) => s.name.length));
4373
- return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
4374
- }
4375
- async function jiraView({ session, term }, arg, options) {
4376
- const key = await resolveRef(term.ask, arg, ISSUE_REF);
4377
- const lines = formatIssueView(await fetchIssue(session, session.site, key), Date.now(), options.allComments ?? false);
4378
- await term.page(lines.join("\n"), { pager: options.pager });
4379
- }
4380
- const CATEGORY_COLORS = {
4381
- new: kleur.cyan,
4382
- indeterminate: kleur.yellow,
4383
- done: kleur.green
4384
- };
4385
- function colorForCategory(category) {
4386
- return CATEGORY_COLORS[category] ?? ((text) => text);
4387
- }
4388
- function formatIssueView(issue, nowMs, allComments) {
4389
- const colorStatus = colorForCategory(issue.statusCategory);
4390
- return [
4391
- `${kleur.bold(issue.key)} ${issue.summary}`,
4392
- ...fieldLines$1([
4393
- ["Type", issue.type],
4394
- ["Status", issue.status && colorStatus(issue.status)],
4395
- ["Assignee", issue.assignee],
4396
- ["Reporter", issue.reporter],
4397
- ["Priority", issue.priority],
4398
- ["Labels", issue.labels.join(", ")],
4399
- ["Created", dateWithAge(issue.created, nowMs)],
4400
- ["Updated", dateWithAge(issue.updated, nowMs)],
4401
- ["URL", issue.url]
4402
- ]),
4403
- ...bodyLines(issue.description),
4404
- ...commentSection(renderedComments(issue.comments), { allComments }),
4405
- ...attachmentSection(issue.attachments)
4406
- ];
4407
- }
4408
- async function jiraCopy(env, arg, options) {
4409
- await copyIssue(env, await resolveRef(env.term.ask, arg, ISSUE_REF), options.out);
4410
- }
4411
- async function jiraUpdate({ session, term, files }, arg, options) {
4412
- const file = arg ?? await term.ask.text({
4413
- message: "Path to the issue Markdown file:",
4414
- flag: "[file]",
4415
- required: true
4416
- });
4417
- const raw = await files.readText(file);
4418
- const src = parseIssueSource(raw);
4419
- const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
4420
- await runPlan(term, plan, options, async () => {
4421
- const { current, next } = plan.headline;
4422
- const updated = await updateIssue(session, src.key, {
4423
- description: plan.body,
4424
- summary: next !== current ? next : void 0
4425
- });
4426
- term.out(`Updated ${src.key}.`);
4427
- if (updated) await files.writeText(file, rewriteFields(raw, { updated }));
4428
- });
4429
- }
4430
- async function copyIssue(env, key, out) {
4431
- env.term.err(`Fetching ${key} ...`);
4432
- await runCopy(env, planIssueCopy(await fetchIssue(env.session, env.session.site, key), out));
4433
- }
4434
- const ISSUE_REF = {
4435
- message: "Jira issue key or URL:",
4436
- flag: "[issue]",
4437
- parse: parseIssueKey,
4438
- notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
4439
- };
4440
- //#endregion
4441
4845
  //#region src/commands/jira-search.ts
4442
4846
  async function jiraSearch(env, query, options) {
4443
4847
  const { session } = env;
4444
4848
  checkQuery(query, options);
4445
4849
  const limit = checkedLimit(options);
4850
+ const columns = await resolveFieldColumns(session, options.field);
4446
4851
  const params = await searchParams(session, query, options, limit, Date.now());
4447
- await showIssues(env, await searchIssues(session, session.site, params).catch((err) => {
4852
+ await showIssues(env, withNamedFields(await searchIssues(session, session.site, params, fieldIds(columns)).catch((err) => {
4448
4853
  throw searchHint(err, options.project);
4449
- }), limit, options);
4854
+ }), columns), columns, limit, options);
4450
4855
  }
4451
4856
  async function jiraJql(env, query, options) {
4452
4857
  const { session } = env;
4453
4858
  const limit = checkedLimit(options);
4454
- await showIssues(env, await searchIssuesByJql(session, session.site, query, limit), limit, options);
4859
+ const columns = await resolveFieldColumns(session, options.field);
4860
+ await showIssues(env, withNamedFields(await searchIssuesByJql(session, session.site, query, limit, fieldIds(columns)), columns), columns, limit, options);
4455
4861
  }
4456
- async function showIssues(env, issues, limit, options, empty = "No matching issues.") {
4457
- await showRows(env.term, formatIssueRows(issues, Date.now()), {
4862
+ async function showIssues(env, issues, columns, limit, options, empty = "No matching issues.") {
4863
+ await showRows(env.term, formatIssueRows(issues, Date.now(), columns), {
4458
4864
  empty,
4459
4865
  hasMore: issues.length === limit,
4460
4866
  limit
@@ -4462,27 +4868,36 @@ async function showIssues(env, issues, limit, options, empty = "No matching issu
4462
4868
  }
4463
4869
  async function jiraList(env, options) {
4464
4870
  const { session, term } = env;
4465
- checkedLimit(options);
4871
+ const limit = checkedLimit(options);
4872
+ const columns = await resolveFieldColumns(session, options.field);
4466
4873
  const { issues, truncated } = await listAssignedIssues(session, session.site, {
4467
4874
  all: options.all,
4468
4875
  project: options.project
4469
- });
4470
- await runSearch(term, formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
4876
+ }, fieldIds(columns));
4877
+ const sorted = sortByCategoryThenUpdated(withNamedFields(issues, columns));
4878
+ const shown = options.limit === void 0 ? sorted : sorted.slice(0, limit);
4879
+ await runSearch(term, formatIssueRows(shown, Date.now(), columns), {
4471
4880
  json: options.json,
4472
4881
  copy: options.copy,
4473
4882
  out: options.out,
4474
4883
  empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
4475
- footer: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
4884
+ footer: listFooter(shown.length, sorted.length, truncated, limit)
4476
4885
  }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
4477
4886
  }
4478
- function formatIssueRows(issues, nowMs) {
4887
+ function listFooter(shown, fetched, truncated, limit) {
4888
+ if (shown < fetched) return searchFooter(limit);
4889
+ if (truncated) return `
4890
+ Showing the first ${shown}; narrow with --project.`;
4891
+ }
4892
+ function formatIssueRows(issues, nowMs, columns = []) {
4479
4893
  return alignedRows(issues, nowMs, (i) => ({
4480
4894
  id: i.key,
4481
4895
  url: i.url,
4482
4896
  label: i.status,
4483
4897
  color: colorForCategory(i.statusCategory),
4484
4898
  text: i.summary,
4485
- timestamp: i.updated
4899
+ timestamp: i.updated,
4900
+ columns: columns.map((c) => fieldCell(i.fields?.[c.name]))
4486
4901
  }));
4487
4902
  }
4488
4903
  const ISSUE_NOUN = {
@@ -4490,24 +4905,91 @@ const ISSUE_NOUN = {
4490
4905
  plural: "issues"
4491
4906
  };
4492
4907
  //#endregion
4908
+ //#region src/api/jira-types.ts
4909
+ const SPRINT_STATES = [
4910
+ "active",
4911
+ "future",
4912
+ "closed"
4913
+ ];
4914
+ //#endregion
4915
+ //#region src/api/jira-sprints.ts
4916
+ async function listSprints(client, boardId, states) {
4917
+ const state = states.join(",");
4918
+ return (await allOffsetPages(client, (startAt) => `/rest/agile/1.0/board/${boardId}/sprint?state=${state}&startAt=${startAt}&maxResults=50`)).toSorted(byRelevance).map((s) => ({
4919
+ id: s.id,
4920
+ state: s.state,
4921
+ name: s.name,
4922
+ start: s.startDate ?? null,
4923
+ end: s.endDate ?? null,
4924
+ boardId: s.originBoardId ?? boardId
4925
+ }));
4926
+ }
4927
+ function byRelevance(a, b) {
4928
+ const rank = SPRINT_STATES.indexOf(a.state) - SPRINT_STATES.indexOf(b.state);
4929
+ if (rank !== 0 || a.state !== "closed") return rank;
4930
+ return finishedAt(b) - finishedAt(a);
4931
+ }
4932
+ function finishedAt(sprint) {
4933
+ return Date.parse(sprint.completeDate ?? sprint.endDate ?? "") || 0;
4934
+ }
4935
+ //#endregion
4936
+ //#region src/commands/jira-sprints.ts
4937
+ async function jiraSprints({ session, term }, ref, options) {
4938
+ const chosen = parseChoices(options.state, SPRINT_STATES, "--state");
4939
+ const states = chosen.length > 0 ? chosen : ["active"];
4940
+ const limit = parseLimit(options.limit);
4941
+ const sprints = await withBoard(session, ref, (id) => listSprints(session, id, states).catch((err) => {
4942
+ throw withoutSprints(err, id);
4943
+ }));
4944
+ const shown = sprints.slice(0, limit);
4945
+ if (options.json) term.json(shown);
4946
+ else if (shown.length === 0) term.out(`No ${orList(states)} sprints.`);
4947
+ else if (sprints.length > limit) term.out([...formatSprintRows(shown), searchFooter(limit)]);
4948
+ else term.out(formatSprintRows(shown));
4949
+ }
4950
+ function withoutSprints(err, id) {
4951
+ if (!(err instanceof HttpError) || err.status !== 400) return err;
4952
+ return /* @__PURE__ */ new Error(`Board ${id} does not support sprints. Only scrum boards have them.`);
4953
+ }
4954
+ function formatSprintRows(sprints) {
4955
+ const cells = sprints.map((s) => [
4956
+ String(s.id),
4957
+ s.state,
4958
+ s.name,
4959
+ s.start ? formatDate$1(s.start) : "-",
4960
+ s.end ? formatDate$1(s.end) : "-"
4961
+ ]);
4962
+ const widths = cells[0].map((_, i) => Math.max(...cells.map((row) => row[i].length)));
4963
+ return cells.map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd());
4964
+ }
4965
+ //#endregion
4493
4966
  //#region src/cli/jira.ts
4967
+ const FIELD_OPTION = [
4968
+ "-f, --field <name|id>",
4969
+ "add a field's value to each issue (repeatable)",
4970
+ collect
4971
+ ];
4494
4972
  function registerJira(jira) {
4495
4973
  jira.description("Jira commands");
4496
4974
  jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(jiraAction(jiraProjects));
4497
4975
  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));
4976
+ 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));
4977
+ 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));
4978
+ 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));
4498
4979
  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));
4499
- 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));
4980
+ 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));
4500
4981
  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));
4501
4982
  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));
4502
4983
  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));
4503
- 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));
4504
- 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));
4505
- outputOptions(jira.command("jql <query>").description("Search Jira issues with a raw JQL query")).action(jiraAction(jiraJql));
4984
+ jira.command("attach <issue> <files...>").description("Upload files as attachments to a Jira issue (key or URL)").option("--json", "print the uploaded attachments as JSON").action(jiraAction(jiraAttach));
4985
+ 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));
4986
+ 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));
4987
+ outputOptions(jira.command("jql <query>").description("Search Jira issues with a raw JQL query").option(...FIELD_OPTION)).action(jiraAction(jiraJql));
4506
4988
  return jira;
4507
4989
  }
4508
4990
  //#endregion
4509
4991
  //#region package.json
4510
- var version = "2.1.0";
4992
+ var version = "2.3.0";
4511
4993
  //#endregion
4512
4994
  //#region src/cli/build.ts
4513
4995
  const PRODUCTS = {