atlass 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -157,6 +157,7 @@ atlass confluence list --space DOCS
157
157
  `jira list` shows issues assigned to you as `KEY Status Age Summary`, with
158
158
  In Progress first, then To Do, and the most recently updated at the top of each
159
159
  group. Done issues are left out; `--all` adds those updated in the last 30 days.
160
+ `--limit` keeps the first rows of that order; without it, all are shown.
160
161
 
161
162
  `confluence list` shows the pages you starred as `ID SPACE Age Title`, most
162
163
  recently updated first. `--limit` works as for search. Star or unstar a page
@@ -175,10 +176,12 @@ atlass confluence search "onboarding" --space DOCS --label runbook
175
176
  ```
176
177
 
177
178
  Jira filters on `--project`, `--assignee`, `--reporter`, `--status`, `--type`,
178
- `--label`, `--updated` and `--open`; Confluence on `--space`, `--label`,
179
- `--updated` and `--starred`. Repeating a flag matches any of its values;
180
- different flags must all match. `--assignee` and `--reporter` take `me`, an
181
- account id, or a name to look up. `--updated` takes `7d`, `2w`, `3m` or
179
+ `--label`, `--sprint`, `--updated` and `--open`; Confluence on `--space`,
180
+ `--label`, `--updated` and `--starred`. Repeating a flag matches any of its
181
+ values; different flags must all match. `--assignee` and `--reporter` take `me`,
182
+ an account id, or a name to look up. `--sprint` takes `active`, `future`, a
183
+ sprint id, or a sprint name; names can repeat across boards, an id cannot.
184
+ `--updated` takes `7d`, `2w`, `3m` or
182
185
  `YYYY-MM-DD`. Results sort by most recently updated and use the same columns
183
186
  as `jira list` and `confluence list`.
184
187
 
@@ -193,6 +196,19 @@ atlass confluence cql "label = runbook AND creator = currentUser()"
193
196
  to pick results interactively and copy each one, with `--out` as the target
194
197
  directory. All four work on both search commands.
195
198
 
199
+ `--field` adds a field's value to each issue on `jira search`, `jira jql` and
200
+ `jira list`:
201
+
202
+ ```bash
203
+ atlass jira search --assignee me --status Closed --field "Story Points" --json
204
+ ```
205
+
206
+ It takes a field id or a name, ignoring case, and repeats for more fields. A
207
+ name several fields share fails and lists their ids; `atlass jira fields
208
+ --search` finds the one you want. Each field becomes a column after the status.
209
+ With `--json`, each issue gains a `fields` object of raw values keyed by the
210
+ name you passed.
211
+
196
212
  Discovery aids for the filters:
197
213
 
198
214
  ```bash
@@ -200,6 +216,42 @@ atlass jira projects [query]
200
216
  atlass jira statuses [query] [--project PROJ]
201
217
  ```
202
218
 
219
+ ## Boards
220
+
221
+ ```bash
222
+ atlass jira boards [query] [--project PROJ]
223
+ atlass jira board <id|url|name> [--json]
224
+ ```
225
+
226
+ `boards` lists every board as `ID Type Name Project`, filtered by name and
227
+ project key. Boards that span projects leave the project cell empty.
228
+
229
+ `board` shows one board: the field it estimates with, its filter JQL, and each
230
+ column's statuses with their categories. A name must match exactly, ignoring
231
+ case; if several boards share it, use the id.
232
+
233
+ ## Sprints
234
+
235
+ ```bash
236
+ atlass jira sprints <id|url|name> [--state active|future|closed ...] [--limit N] [--json]
237
+ ```
238
+
239
+ Lists a board's sprints as `ID State Name Start End`, active only unless
240
+ `--state` says otherwise. Active sprints come first, then future, then closed
241
+ newest first. The board takes the same forms as `board`.
242
+
243
+ ## Fields
244
+
245
+ ```bash
246
+ atlass jira fields [--search TEXT] [--json]
247
+ ```
248
+
249
+ With no project, `fields` lists every field on the site as `ID Name Type`,
250
+ sorted by name. `--search` filters by name or id, ignoring case. The type is the
251
+ custom field's plugin type, such as `float` or `gh-sprint`, or the schema type
252
+ for system fields. `--json` carries the full schema, plugin key included. With a
253
+ project, `fields` shows the create form instead (see Create).
254
+
203
255
  ## Bitbucket
204
256
 
205
257
  `--repo` takes `workspace/slug` or a bare slug under the configured workspace,
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as buildStandalone, r as fail } from "./build-_4VuIcqF.mjs";
2
+ import { n as buildStandalone, r as fail } from "./build-H7eTLrf3.mjs";
3
3
  //#region src/bitbucket.ts
4
4
  buildStandalone("bitbucket").parseAsync().catch(fail);
5
5
  //#endregion
@@ -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));
@@ -447,14 +447,58 @@ function guarded(fn) {
447
447
  };
448
448
  }
449
449
  function fail(err) {
450
- if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
450
+ if (isPromptInterrupt(err)) {
451
+ process.exitCode = SIGINT_EXIT_CODE;
452
+ return;
453
+ }
451
454
  ttyTerminal().err(`Error: ${err instanceof Error ? err.message : String(err)}`);
452
- process.exit(1);
455
+ process.exitCode = 1;
453
456
  }
454
457
  function isPromptInterrupt(err) {
455
458
  return err instanceof Error && err.name === "ExitPromptError";
456
459
  }
457
460
  //#endregion
461
+ //#region src/util/format.ts
462
+ function formatDuration(seconds) {
463
+ if (seconds == null) return "-";
464
+ if (seconds < 60) return `${seconds}s`;
465
+ const m = Math.floor(seconds / 60);
466
+ const s = seconds % 60;
467
+ if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
468
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
469
+ }
470
+ const pad = (n) => String(n).padStart(2, "0");
471
+ function formatDate$1(iso) {
472
+ const date = new Date(iso);
473
+ if (Number.isNaN(date.getTime())) return iso;
474
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
475
+ }
476
+ function formatDateTime(iso) {
477
+ const date = new Date(iso);
478
+ if (Number.isNaN(date.getTime())) return iso;
479
+ return `${formatDate$1(iso)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
480
+ }
481
+ const MINUTE = 60;
482
+ const HOUR = 3600;
483
+ const DAY = 24 * HOUR;
484
+ function relativeTime(iso, nowMs) {
485
+ if (!iso) return "-";
486
+ const then = Date.parse(iso);
487
+ if (Number.isNaN(then)) return "-";
488
+ const secs = Math.floor((nowMs - then) / 1e3);
489
+ if (secs < MINUTE) return "just now";
490
+ if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
491
+ if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
492
+ const days = Math.floor(secs / DAY);
493
+ if (days < 45) return `${days}d ago`;
494
+ if (days < 365) return `${Math.floor(days / 30)}mo ago`;
495
+ return `${Math.floor(days / 365)}y ago`;
496
+ }
497
+ function orList(items) {
498
+ if (items.length < 3) return items.join(" or ");
499
+ return `${items.slice(0, -1).join(", ")}, or ${items.at(-1)}`;
500
+ }
501
+ //#endregion
458
502
  //#region src/util/parse.ts
459
503
  function parseIssueKey(input) {
460
504
  const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
@@ -469,6 +513,13 @@ function parsePageId(input) {
469
513
  if (fromQuery) return fromQuery[1];
470
514
  return null;
471
515
  }
516
+ function parseBoardId(input) {
517
+ const trimmed = input.trim();
518
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
519
+ if (!/^https?:\/\//i.test(trimmed)) return null;
520
+ const match = trimmed.match(/\/boards\/(\d+)(?:[/?#]|$)/) ?? trimmed.match(/[?&]rapidView=(\d+)/);
521
+ return match ? Number(match[1]) : null;
522
+ }
472
523
  const PULL_REQUEST_HOSTS = ["bitbucket.org", "www.bitbucket.org"];
473
524
  const PULL_REQUEST_PATH = /^\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)(?:\/|$)/;
474
525
  function parsePullRequestRef(input) {
@@ -522,6 +573,19 @@ function resolveRepo(flag, config) {
522
573
  };
523
574
  throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
524
575
  }
576
+ function parseChoices(values, choices, flag) {
577
+ const picked = /* @__PURE__ */ new Set();
578
+ const raw = (values ?? []).flatMap((v) => v.split(",")).map((v) => v.trim());
579
+ for (const value of raw.filter(Boolean)) {
580
+ const choice = choices.find((c) => c.toLowerCase() === value.toLowerCase());
581
+ if (!choice) {
582
+ const expected = orList(choices.map((c) => c.toLowerCase()));
583
+ throw new Error(`Invalid ${flag} "${value}". Expected ${expected}.`);
584
+ }
585
+ picked.add(choice);
586
+ }
587
+ return choices.filter((c) => picked.has(c));
588
+ }
525
589
  const DEFAULT_LIMIT = 25;
526
590
  const MAX_LIMIT = 100;
527
591
  function parseLimit(value) {
@@ -768,15 +832,7 @@ const PULL_REQUEST_STATES = [
768
832
  "SUPERSEDED"
769
833
  ];
770
834
  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;
835
+ return parseChoices(values, PULL_REQUEST_STATES, "--state");
780
836
  }
781
837
  function allPullRequestStates() {
782
838
  return [...PULL_REQUEST_STATES];
@@ -942,43 +998,6 @@ async function listChangedFiles(client, ref, id) {
942
998
  return collectCapped(client, `${pullRequestPath(ref, id, "diffstat")}?pagelen=${PAGELEN}`, 50, toChangedFile);
943
999
  }
944
1000
  //#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
1001
  //#region src/commands/bitbucket-comments.ts
983
1002
  const MENTION = /@\{([^}]+)\}/g;
984
1003
  function renderedPrComments(comments, participants) {
@@ -1580,6 +1599,7 @@ function alignedRows(items, nowMs, cells) {
1580
1599
  const cell = cells(item);
1581
1600
  return {
1582
1601
  ...cell,
1602
+ columns: cell.columns ?? [],
1583
1603
  age: relativeTime(cell.timestamp, nowMs),
1584
1604
  json: { ...item }
1585
1605
  };
@@ -1587,11 +1607,17 @@ function alignedRows(items, nowMs, cells) {
1587
1607
  const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
1588
1608
  const idWidth = width((row) => row.id);
1589
1609
  const labelWidth = width((row) => row.label);
1610
+ const columnWidths = (rows[0]?.columns ?? []).map((_, i) => width((row) => row.columns[i]));
1590
1611
  const ageWidth = width((row) => row.age);
1591
1612
  return rows.map((row) => ({
1592
1613
  id: row.id,
1593
1614
  url: row.url,
1594
- fixedColumns: `${row.id.padEnd(idWidth)} ${row.color(row.label.padEnd(labelWidth))} ${row.age.padEnd(ageWidth)}`,
1615
+ fixedColumns: [
1616
+ row.id.padEnd(idWidth),
1617
+ row.color(row.label.padEnd(labelWidth)),
1618
+ ...row.columns.map((column, i) => column.padEnd(columnWidths[i])),
1619
+ row.age.padEnd(ageWidth)
1620
+ ].join(" "),
1595
1621
  freeText: row.text,
1596
1622
  json: row.json
1597
1623
  }));
@@ -1894,8 +1920,8 @@ function moved(flags, command) {
1894
1920
  throw new Error(`${flag} is now its own command: \`atlass ${command} "<query>"\`.`);
1895
1921
  });
1896
1922
  }
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");
1923
+ function outputOptions(command, limitHelp = "max results (default 25, max 100)") {
1924
+ return command.option("-l, --limit <n>", limitHelp).option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy");
1899
1925
  }
1900
1926
  //#endregion
1901
1927
  //#region src/api/confluence-create.ts
@@ -3074,6 +3100,11 @@ function inClause(field, given) {
3074
3100
  function userClause(field, given) {
3075
3101
  return renderClause(field, values(given).map((v) => v === "me" ? "currentUser()" : quote(v)));
3076
3102
  }
3103
+ function anyOf(terms) {
3104
+ if (terms.length === 0) return null;
3105
+ if (terms.length === 1) return terms[0];
3106
+ return `(${terms.join(" OR ")})`;
3107
+ }
3077
3108
  function renderClause(field, rendered) {
3078
3109
  if (rendered.length === 0) return null;
3079
3110
  if (rendered.length === 1) return `${field} = ${rendered[0]}`;
@@ -3336,10 +3367,203 @@ function registerConfluence(confluence) {
3336
3367
  return confluence;
3337
3368
  }
3338
3369
  //#endregion
3370
+ //#region src/api/jira-statuses.ts
3371
+ async function listStatuses(client, project) {
3372
+ return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await fetchAllStatuses(client)).map(toStatusSummary));
3373
+ }
3374
+ async function statusesById(client) {
3375
+ const raw = await fetchAllStatuses(client);
3376
+ return new Map(raw.map((s) => [s.id, toStatusSummary(s)]));
3377
+ }
3378
+ function fetchAllStatuses(client) {
3379
+ return client.getJson("/rest/api/3/status");
3380
+ }
3381
+ async function fetchProjectStatuses(client, project) {
3382
+ let groups;
3383
+ try {
3384
+ groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
3385
+ } catch (err) {
3386
+ if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
3387
+ throw err;
3388
+ }
3389
+ return groups.flatMap((g) => g.statuses ?? []);
3390
+ }
3391
+ function toStatusSummary(s) {
3392
+ return {
3393
+ name: s.name,
3394
+ id: s.id,
3395
+ category: s.statusCategory?.name ?? "",
3396
+ categoryKey: s.statusCategory?.key ?? ""
3397
+ };
3398
+ }
3399
+ const CATEGORY_ORDER = {
3400
+ new: 0,
3401
+ indeterminate: 1,
3402
+ done: 2
3403
+ };
3404
+ const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
3405
+ function dedupeAndSortStatuses(statuses) {
3406
+ const byNameCategory = /* @__PURE__ */ new Map();
3407
+ for (const s of statuses) {
3408
+ const key = `${s.name}\0${s.categoryKey}`;
3409
+ if (!byNameCategory.has(key)) byNameCategory.set(key, s);
3410
+ }
3411
+ return [...byNameCategory.values()].sort((a, b) => {
3412
+ const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
3413
+ return rank(a) - rank(b) || a.name.localeCompare(b.name);
3414
+ });
3415
+ }
3416
+ //#endregion
3339
3417
  //#region src/api/jira-url.ts
3340
3418
  function browseUrl(site, key) {
3341
3419
  return `${site}/browse/${key}`;
3342
3420
  }
3421
+ function boardUrl(site, id) {
3422
+ return `${site}/secure/RapidBoard.jspa?rapidView=${id}`;
3423
+ }
3424
+ //#endregion
3425
+ //#region src/api/jira-board.ts
3426
+ async function fetchBoard(client, site, id) {
3427
+ const [config, statuses] = await Promise.all([client.getJson(`/rest/agile/1.0/board/${id}/configuration`), statusesById(client)]);
3428
+ const statusFor = (statusId) => statuses.get(statusId) ?? {
3429
+ id: statusId,
3430
+ name: statusId,
3431
+ category: "",
3432
+ categoryKey: ""
3433
+ };
3434
+ return {
3435
+ id: config.id,
3436
+ name: config.name,
3437
+ type: config.type,
3438
+ url: boardUrl(site, config.id),
3439
+ estimation: estimationOf(config),
3440
+ filter: {
3441
+ id: config.filter.id,
3442
+ jql: await visibleFilterJql(client, config.filter.id),
3443
+ subQuery: config.subQuery?.query || null
3444
+ },
3445
+ columns: (config.columnConfig?.columns ?? []).map((c) => ({
3446
+ name: c.name,
3447
+ statuses: (c.statuses ?? []).map((s) => statusFor(s.id))
3448
+ }))
3449
+ };
3450
+ }
3451
+ async function visibleFilterJql(client, filterId) {
3452
+ try {
3453
+ return (await client.getJson(`/rest/api/3/filter/${filterId}`)).jql ?? null;
3454
+ } catch (err) {
3455
+ if (err instanceof HttpError && err.status === 404) return null;
3456
+ throw err;
3457
+ }
3458
+ }
3459
+ function estimationOf(config) {
3460
+ const field = config.estimation?.type === "field" ? config.estimation.field : void 0;
3461
+ return field ? {
3462
+ fieldId: field.fieldId,
3463
+ fieldName: field.displayName
3464
+ } : null;
3465
+ }
3466
+ //#endregion
3467
+ //#region src/api/offset-pages.ts
3468
+ async function allOffsetPages(client, pathAt) {
3469
+ const all = [];
3470
+ for (let startAt = 0;;) {
3471
+ const res = await client.getJson(pathAt(startAt));
3472
+ const values = res.values ?? [];
3473
+ all.push(...values);
3474
+ if (res.isLast || values.length === 0) return all;
3475
+ startAt += values.length;
3476
+ }
3477
+ }
3478
+ //#endregion
3479
+ //#region src/api/jira-boards.ts
3480
+ async function listBoards(client, site) {
3481
+ return (await allOffsetPages(client, (startAt) => `/rest/agile/1.0/board?startAt=${startAt}&maxResults=50`)).map((b) => ({
3482
+ id: b.id,
3483
+ name: b.name,
3484
+ type: b.type,
3485
+ project: b.location?.projectKey ?? null,
3486
+ url: boardUrl(site, b.id)
3487
+ }));
3488
+ }
3489
+ //#endregion
3490
+ //#region src/commands/board-ref.ts
3491
+ async function withBoard(session, ref, load) {
3492
+ const id = await resolveBoardId(session, ref);
3493
+ try {
3494
+ return await load(id);
3495
+ } catch (err) {
3496
+ if (isMissingBoard(err, id)) throw unknownBoard(ref);
3497
+ throw err;
3498
+ }
3499
+ }
3500
+ async function resolveBoardId(session, ref) {
3501
+ const id = parseBoardId(ref);
3502
+ if (id !== null) return id;
3503
+ const name = ref.trim().toLowerCase();
3504
+ const matches = (await listBoards(session, session.site)).filter((b) => b.name.toLowerCase() === name);
3505
+ const [only, ...others] = matches;
3506
+ if (!only) throw unknownBoard(ref);
3507
+ if (others.length === 0) return only.id;
3508
+ const width = Math.max(...matches.map((b) => String(b.id).length));
3509
+ throw new Error([`"${ref}" matches several boards. Use an id:`, ...matches.map((b) => ` ${String(b.id).padEnd(width)} ${b.name}`)].join("\n"));
3510
+ }
3511
+ function isMissingBoard(err, id) {
3512
+ return err instanceof HttpError && err.status === 404 && err.path.startsWith(`/rest/agile/1.0/board/${id}/`);
3513
+ }
3514
+ function unknownBoard(ref) {
3515
+ return /* @__PURE__ */ new Error(`No board matches "${ref}". Run \`atlass jira boards\` to list boards.`);
3516
+ }
3517
+ //#endregion
3518
+ //#region src/commands/jira-board.ts
3519
+ async function jiraBoard({ session, term }, ref, options) {
3520
+ const board = await withBoard(session, ref, (id) => fetchBoard(session, session.site, id));
3521
+ if (options.json) term.json(board);
3522
+ else term.out(formatBoard(board));
3523
+ }
3524
+ function formatBoard(board) {
3525
+ return [
3526
+ board.name,
3527
+ ...fieldLines$1([
3528
+ ["ID", String(board.id)],
3529
+ ["Type", board.type],
3530
+ ["Estimation", estimationLine(board)],
3531
+ ["Filter", board.filter.jql ?? `not visible to you (filter ${board.filter.id})`],
3532
+ ["Sub-filter", board.filter.subQuery ?? ""],
3533
+ ["URL", board.url]
3534
+ ]),
3535
+ "",
3536
+ ...columnLines(board)
3537
+ ];
3538
+ }
3539
+ function estimationLine({ estimation }) {
3540
+ return estimation ? `${estimation.fieldName} (${estimation.fieldId})` : "none, this board does not estimate with a field";
3541
+ }
3542
+ function columnLines({ columns }) {
3543
+ const statuses = columns.flatMap((c) => c.statuses);
3544
+ const width = Math.max(0, ...statuses.map((s) => s.name.length));
3545
+ return columns.flatMap((c) => [c.name, ...c.statuses.map((s) => ` ${s.name.padEnd(width)} ${s.category}`.trimEnd())]);
3546
+ }
3547
+ //#endregion
3548
+ //#region src/commands/jira-boards.ts
3549
+ async function jiraBoards({ session, term }, query, options) {
3550
+ const boards = filterBoards(await listBoards(session, session.site), query, options.project);
3551
+ if (options.json) term.json(boards);
3552
+ else if (boards.length === 0) term.out("No matching boards.");
3553
+ else term.out(formatBoardRows(boards));
3554
+ }
3555
+ function filterBoards(boards, query, project) {
3556
+ const needle = query?.toLowerCase();
3557
+ const key = project?.toUpperCase();
3558
+ return boards.filter((b) => (!needle || b.name.toLowerCase().includes(needle)) && (!key || b.project?.toUpperCase() === key));
3559
+ }
3560
+ function formatBoardRows(boards) {
3561
+ const width = (pick) => Math.max(...boards.map((b) => pick(b).length));
3562
+ const idWidth = width((b) => String(b.id));
3563
+ const typeWidth = width((b) => b.type);
3564
+ const nameWidth = width((b) => b.name);
3565
+ return boards.map((b) => `${String(b.id).padEnd(idWidth)} ${b.type.padEnd(typeWidth)} ${b.name.padEnd(nameWidth)} ${b.project ?? ""}`.trimEnd());
3566
+ }
3343
3567
  //#endregion
3344
3568
  //#region src/api/jira-createmeta.ts
3345
3569
  const CREATEMETA_PAGE_SIZE = 200;
@@ -3432,28 +3656,19 @@ function toJiraUser(u) {
3432
3656
  }
3433
3657
  //#endregion
3434
3658
  //#region src/api/jira-projects.ts
3435
- const PROJECT_PAGE_SIZE = 50;
3436
3659
  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;
3660
+ return (await allOffsetPages(client, (startAt) => `/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`)).map((p) => ({
3661
+ key: p.key,
3662
+ name: p.name,
3663
+ id: p.id,
3664
+ type: p.projectTypeKey ?? "",
3665
+ url: browseUrl(site, p.key)
3666
+ }));
3452
3667
  }
3453
3668
  function projectSearchQuery(query, startAt) {
3454
3669
  const params = new URLSearchParams({
3455
3670
  orderBy: "key",
3456
- maxResults: String(PROJECT_PAGE_SIZE),
3671
+ maxResults: String(50),
3457
3672
  startAt: String(startAt)
3458
3673
  });
3459
3674
  if (query) params.set("query", query);
@@ -4098,17 +4313,55 @@ function describeRejection(err, meta) {
4098
4313
  ...perField
4099
4314
  ].join("\n"));
4100
4315
  }
4101
- async function jiraFields({ session, term }, projectArg, typeArg, options) {
4316
+ //#endregion
4317
+ //#region src/api/jira-fields.ts
4318
+ async function listSiteFields(client) {
4319
+ return (await client.getJson("/rest/api/3/field")).map((f) => ({
4320
+ id: f.id,
4321
+ name: decodeEntities(f.name),
4322
+ custom: f.custom ?? false,
4323
+ schema: f.schema ?? null
4324
+ }));
4325
+ }
4326
+ //#endregion
4327
+ //#region src/commands/jira-fields.ts
4328
+ async function jiraFields(env, projectArg, typeArg, options) {
4329
+ if (!projectArg) return siteFields(env, options);
4102
4330
  const project = projectArg.toUpperCase();
4331
+ if (options.search !== void 0) throw new Error(`--search lists site fields and takes no project. Drop ${project}, or run \`atlass jira fields ${project}\` alone.`);
4332
+ return createForm$1(env, project, typeArg, options);
4333
+ }
4334
+ async function siteFields({ session, term }, options) {
4335
+ const fields = matchingFields(await listSiteFields(session), options.search).sort((a, b) => a.name.localeCompare(b.name, void 0, { sensitivity: "base" }));
4336
+ if (options.json) term.json(fields);
4337
+ else if (fields.length === 0) term.out("No matching fields.");
4338
+ else term.out(formatSiteFieldRows(fields));
4339
+ }
4340
+ function matchingFields(fields, search) {
4341
+ const needle = search?.toLowerCase();
4342
+ return fields.filter((f) => !needle || f.name.toLowerCase().includes(needle) || f.id.toLowerCase().includes(needle));
4343
+ }
4344
+ function formatSiteFieldRows(fields) {
4345
+ const width = (pick) => Math.max(...fields.map((f) => pick(f).length));
4346
+ const idWidth = width((f) => f.id);
4347
+ const nameWidth = width((f) => f.name);
4348
+ return fields.map((f) => `${f.id.padEnd(idWidth)} ${f.name.padEnd(nameWidth)} ${shortFieldType(f)}`.trimEnd());
4349
+ }
4350
+ function shortFieldType(field) {
4351
+ const plugin = field.schema?.custom;
4352
+ if (plugin) return plugin.slice(plugin.lastIndexOf(":") + 1);
4353
+ return field.schema?.type ?? "";
4354
+ }
4355
+ async function createForm$1({ session, term }, project, typeArg, { json }) {
4103
4356
  const types = await fetchCreateIssueTypes(session, project);
4104
4357
  if (!typeArg) {
4105
- if (options.json) term.json(types);
4358
+ if (json) term.json(types);
4106
4359
  else if (types.length === 0) term.out(`You cannot create issues in ${project}.`);
4107
4360
  else term.out(formatIssueTypeRows(types));
4108
4361
  return;
4109
4362
  }
4110
4363
  const fields = await fetchCreateFields(session, project, (await resolveType(term.ask, project, types, typeArg, true)).id);
4111
- if (options.json) term.json(fields);
4364
+ if (json) term.json(fields);
4112
4365
  else term.out(formatFieldRows(fields));
4113
4366
  }
4114
4367
  //#endregion
@@ -4116,23 +4369,23 @@ async function jiraFields({ session, term }, projectArg, typeArg, options) {
4116
4369
  const RECENT_ISSUES_CLAUSE = "updated >= -30d";
4117
4370
  const OPEN_CLAUSE = "statusCategory != Done";
4118
4371
  const SEARCH_FIELDS = "summary,status,updated";
4119
- async function searchIssues(client, site, params) {
4120
- return searchIssuesByJql(client, site, buildJql(params), params.limit);
4372
+ async function searchIssues(client, site, params, fieldIds = []) {
4373
+ return searchIssuesByJql(client, site, buildJql(params), params.limit, fieldIds);
4121
4374
  }
4122
- async function searchIssuesByJql(client, site, jql, limit) {
4123
- return ((await fetchSearchPage(client, jql, limit)).issues ?? []).map((i) => toIssueSummary(site, i));
4375
+ async function searchIssuesByJql(client, site, jql, limit, fieldIds = []) {
4376
+ return ((await fetchSearchPage(client, jql, limit, fieldIds)).issues ?? []).map((i) => toIssueSummary(site, i, fieldIds));
4124
4377
  }
4125
- async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
4378
+ async function fetchSearchPage(client, jql, maxResults, fieldIds, nextPageToken) {
4126
4379
  const query = new URLSearchParams({
4127
4380
  jql,
4128
4381
  maxResults: String(maxResults),
4129
- fields: SEARCH_FIELDS
4382
+ fields: [SEARCH_FIELDS, ...fieldIds].join(",")
4130
4383
  });
4131
4384
  if (nextPageToken) query.set("nextPageToken", nextPageToken);
4132
4385
  return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
4133
4386
  }
4134
- function toIssueSummary(site, i) {
4135
- return {
4387
+ function toIssueSummary(site, i, fieldIds) {
4388
+ const summary = {
4136
4389
  key: i.key,
4137
4390
  status: i.fields?.status?.name ?? "",
4138
4391
  statusCategory: i.fields?.status?.statusCategory?.key ?? "",
@@ -4140,6 +4393,11 @@ function toIssueSummary(site, i) {
4140
4393
  updated: i.fields?.updated ?? "",
4141
4394
  url: browseUrl(site, i.key)
4142
4395
  };
4396
+ if (fieldIds.length === 0) return summary;
4397
+ return {
4398
+ ...summary,
4399
+ fields: Object.fromEntries(fieldIds.map((id) => [id, i.fields?.[id] ?? null]))
4400
+ };
4143
4401
  }
4144
4402
  const ISSUE_ORDER = "updated DESC";
4145
4403
  function buildJql(params) {
@@ -4151,12 +4409,22 @@ function buildJql(params) {
4151
4409
  userClause("assignee", params.assignee),
4152
4410
  userClause("reporter", params.reporter),
4153
4411
  inClause("labels", params.label),
4412
+ anyOf(values(params.sprint).map(sprintTerm)),
4154
4413
  params.updatedSince ? `updated >= ${quote(params.updatedSince)}` : null,
4155
4414
  params.text ? `text ~ ${quote(params.text)}` : null
4156
4415
  ].filter((c) => c !== null);
4157
4416
  if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
4158
4417
  return joinClauses(clauses, ISSUE_ORDER);
4159
4418
  }
4419
+ const SPRINT_FUNCTIONS = {
4420
+ active: "openSprints()",
4421
+ future: "futureSprints()"
4422
+ };
4423
+ function sprintTerm(sprint) {
4424
+ const sprintFunction = SPRINT_FUNCTIONS[sprint];
4425
+ if (sprintFunction) return `sprint in ${sprintFunction}`;
4426
+ return `sprint = ${/^\d+$/.test(sprint) ? sprint : quote(sprint)}`;
4427
+ }
4160
4428
  function buildListJql(params) {
4161
4429
  return joinClauses([
4162
4430
  "assignee = currentUser()",
@@ -4166,15 +4434,15 @@ function buildListJql(params) {
4166
4434
  }
4167
4435
  const LIST_PAGE_SIZE = 100;
4168
4436
  const LIST_CAP = 500;
4169
- async function listAssignedIssues(client, site, params) {
4437
+ async function listAssignedIssues(client, site, params, fieldIds = []) {
4170
4438
  const jql = buildListJql(params);
4171
4439
  const issues = [];
4172
4440
  let nextPageToken;
4173
4441
  for (;;) {
4174
4442
  const room = LIST_CAP - issues.length;
4175
- const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
4443
+ const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), fieldIds, nextPageToken);
4176
4444
  const page = res.issues ?? [];
4177
- issues.push(...page.map((i) => toIssueSummary(site, i)));
4445
+ issues.push(...page.map((i) => toIssueSummary(site, i, fieldIds)));
4178
4446
  if (res.isLast || !res.nextPageToken || page.length === 0) return {
4179
4447
  issues,
4180
4448
  truncated: false
@@ -4198,6 +4466,54 @@ function sortByCategoryThenUpdated(issues) {
4198
4466
  return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
4199
4467
  }
4200
4468
  //#endregion
4469
+ //#region src/commands/jira-field-columns.ts
4470
+ async function resolveFieldColumns(session, given) {
4471
+ const names = values(given);
4472
+ if (names.length === 0) return [];
4473
+ const fields = await listSiteFields(session);
4474
+ return names.map((name) => ({
4475
+ name,
4476
+ id: resolveFieldId(fields, name)
4477
+ }));
4478
+ }
4479
+ function resolveFieldId(fields, name) {
4480
+ const byId = fields.find((f) => f.id === name);
4481
+ if (byId) return byId.id;
4482
+ const lower = name.toLowerCase();
4483
+ const matches = fields.filter((f) => f.name.toLowerCase() === lower);
4484
+ const [only, ...others] = matches;
4485
+ if (!only) throw new Error(`No field matches "${name}". Run \`atlass jira fields --search ${shellWord(name)}\` to find it.`);
4486
+ if (others.length === 0) return only.id;
4487
+ const width = Math.max(...matches.map((f) => f.id.length));
4488
+ throw new Error([`"${name}" matches several fields. Use an id:`, ...matches.map((f) => ` ${f.id.padEnd(width)} ${f.name}`)].join("\n"));
4489
+ }
4490
+ function shellWord(text) {
4491
+ return /\s/.test(text) ? `"${text}"` : text;
4492
+ }
4493
+ function fieldIds(columns) {
4494
+ return [...new Set(columns.map((c) => c.id))];
4495
+ }
4496
+ function withNamedFields(issues, columns) {
4497
+ if (columns.length === 0) return issues;
4498
+ return issues.map((issue) => ({
4499
+ ...issue,
4500
+ fields: Object.fromEntries(columns.map((c) => [c.name, issue.fields?.[c.id] ?? null]))
4501
+ }));
4502
+ }
4503
+ const EMPTY_CELL = "-";
4504
+ function fieldCell(value) {
4505
+ const text = (Array.isArray(value) ? value.map(shownValue) : [shownValue(value)]).filter((p) => p !== "").join(", ");
4506
+ return text === "" ? EMPTY_CELL : text;
4507
+ }
4508
+ function shownValue(value) {
4509
+ if (value === null || value === void 0) return "";
4510
+ if (typeof value === "string") return value;
4511
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
4512
+ const named = value;
4513
+ const shown = named.value ?? named.displayName ?? named.name;
4514
+ return shown === void 0 ? JSON.stringify(value) : shownValue(shown);
4515
+ }
4516
+ //#endregion
4201
4517
  //#region src/commands/jira-filters.ts
4202
4518
  const FILTER_FLAGS = [
4203
4519
  "project",
@@ -4205,7 +4521,8 @@ const FILTER_FLAGS = [
4205
4521
  "reporter",
4206
4522
  "status",
4207
4523
  "type",
4208
- "label"
4524
+ "label",
4525
+ "sprint"
4209
4526
  ];
4210
4527
  const ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/i;
4211
4528
  function hasFilters(filters) {
@@ -4224,6 +4541,7 @@ async function searchParams(client, query, filters, limit, nowMs) {
4224
4541
  status: filters.status,
4225
4542
  type: filters.type,
4226
4543
  label: filters.label,
4544
+ sprint: filters.sprint,
4227
4545
  updatedSince: filters.updated ? parseSince(filters.updated, nowMs) : void 0,
4228
4546
  open: filters.open,
4229
4547
  limit
@@ -4307,46 +4625,6 @@ async function fetchComments(client, key) {
4307
4625
  }));
4308
4626
  }
4309
4627
  //#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
4628
  //#region src/commands/jira.ts
4351
4629
  async function jiraProjects({ session, term }, query, options) {
4352
4630
  const projects = await listProjects(session, session.site, query);
@@ -4443,18 +4721,20 @@ async function jiraSearch(env, query, options) {
4443
4721
  const { session } = env;
4444
4722
  checkQuery(query, options);
4445
4723
  const limit = checkedLimit(options);
4724
+ const columns = await resolveFieldColumns(session, options.field);
4446
4725
  const params = await searchParams(session, query, options, limit, Date.now());
4447
- await showIssues(env, await searchIssues(session, session.site, params).catch((err) => {
4726
+ await showIssues(env, withNamedFields(await searchIssues(session, session.site, params, fieldIds(columns)).catch((err) => {
4448
4727
  throw searchHint(err, options.project);
4449
- }), limit, options);
4728
+ }), columns), columns, limit, options);
4450
4729
  }
4451
4730
  async function jiraJql(env, query, options) {
4452
4731
  const { session } = env;
4453
4732
  const limit = checkedLimit(options);
4454
- await showIssues(env, await searchIssuesByJql(session, session.site, query, limit), limit, options);
4733
+ const columns = await resolveFieldColumns(session, options.field);
4734
+ await showIssues(env, withNamedFields(await searchIssuesByJql(session, session.site, query, limit, fieldIds(columns)), columns), columns, limit, options);
4455
4735
  }
4456
- async function showIssues(env, issues, limit, options, empty = "No matching issues.") {
4457
- await showRows(env.term, formatIssueRows(issues, Date.now()), {
4736
+ async function showIssues(env, issues, columns, limit, options, empty = "No matching issues.") {
4737
+ await showRows(env.term, formatIssueRows(issues, Date.now(), columns), {
4458
4738
  empty,
4459
4739
  hasMore: issues.length === limit,
4460
4740
  limit
@@ -4462,27 +4742,36 @@ async function showIssues(env, issues, limit, options, empty = "No matching issu
4462
4742
  }
4463
4743
  async function jiraList(env, options) {
4464
4744
  const { session, term } = env;
4465
- checkedLimit(options);
4745
+ const limit = checkedLimit(options);
4746
+ const columns = await resolveFieldColumns(session, options.field);
4466
4747
  const { issues, truncated } = await listAssignedIssues(session, session.site, {
4467
4748
  all: options.all,
4468
4749
  project: options.project
4469
- });
4470
- await runSearch(term, formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
4750
+ }, fieldIds(columns));
4751
+ const sorted = sortByCategoryThenUpdated(withNamedFields(issues, columns));
4752
+ const shown = options.limit === void 0 ? sorted : sorted.slice(0, limit);
4753
+ await runSearch(term, formatIssueRows(shown, Date.now(), columns), {
4471
4754
  json: options.json,
4472
4755
  copy: options.copy,
4473
4756
  out: options.out,
4474
4757
  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
4758
+ footer: listFooter(shown.length, sorted.length, truncated, limit)
4476
4759
  }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
4477
4760
  }
4478
- function formatIssueRows(issues, nowMs) {
4761
+ function listFooter(shown, fetched, truncated, limit) {
4762
+ if (shown < fetched) return searchFooter(limit);
4763
+ if (truncated) return `
4764
+ Showing the first ${shown}; narrow with --project.`;
4765
+ }
4766
+ function formatIssueRows(issues, nowMs, columns = []) {
4479
4767
  return alignedRows(issues, nowMs, (i) => ({
4480
4768
  id: i.key,
4481
4769
  url: i.url,
4482
4770
  label: i.status,
4483
4771
  color: colorForCategory(i.statusCategory),
4484
4772
  text: i.summary,
4485
- timestamp: i.updated
4773
+ timestamp: i.updated,
4774
+ columns: columns.map((c) => fieldCell(i.fields?.[c.name]))
4486
4775
  }));
4487
4776
  }
4488
4777
  const ISSUE_NOUN = {
@@ -4490,24 +4779,90 @@ const ISSUE_NOUN = {
4490
4779
  plural: "issues"
4491
4780
  };
4492
4781
  //#endregion
4782
+ //#region src/api/jira-types.ts
4783
+ const SPRINT_STATES = [
4784
+ "active",
4785
+ "future",
4786
+ "closed"
4787
+ ];
4788
+ //#endregion
4789
+ //#region src/api/jira-sprints.ts
4790
+ async function listSprints(client, boardId, states) {
4791
+ const state = states.join(",");
4792
+ return (await allOffsetPages(client, (startAt) => `/rest/agile/1.0/board/${boardId}/sprint?state=${state}&startAt=${startAt}&maxResults=50`)).toSorted(byRelevance).map((s) => ({
4793
+ id: s.id,
4794
+ state: s.state,
4795
+ name: s.name,
4796
+ start: s.startDate ?? null,
4797
+ end: s.endDate ?? null,
4798
+ boardId: s.originBoardId ?? boardId
4799
+ }));
4800
+ }
4801
+ function byRelevance(a, b) {
4802
+ const rank = SPRINT_STATES.indexOf(a.state) - SPRINT_STATES.indexOf(b.state);
4803
+ if (rank !== 0 || a.state !== "closed") return rank;
4804
+ return finishedAt(b) - finishedAt(a);
4805
+ }
4806
+ function finishedAt(sprint) {
4807
+ return Date.parse(sprint.completeDate ?? sprint.endDate ?? "") || 0;
4808
+ }
4809
+ //#endregion
4810
+ //#region src/commands/jira-sprints.ts
4811
+ async function jiraSprints({ session, term }, ref, options) {
4812
+ const chosen = parseChoices(options.state, SPRINT_STATES, "--state");
4813
+ const states = chosen.length > 0 ? chosen : ["active"];
4814
+ const limit = parseLimit(options.limit);
4815
+ const sprints = await withBoard(session, ref, (id) => listSprints(session, id, states).catch((err) => {
4816
+ throw withoutSprints(err, id);
4817
+ }));
4818
+ const shown = sprints.slice(0, limit);
4819
+ if (options.json) term.json(shown);
4820
+ else if (shown.length === 0) term.out(`No ${orList(states)} sprints.`);
4821
+ else if (sprints.length > limit) term.out([...formatSprintRows(shown), searchFooter(limit)]);
4822
+ else term.out(formatSprintRows(shown));
4823
+ }
4824
+ function withoutSprints(err, id) {
4825
+ if (!(err instanceof HttpError) || err.status !== 400) return err;
4826
+ return /* @__PURE__ */ new Error(`Board ${id} does not support sprints. Only scrum boards have them.`);
4827
+ }
4828
+ function formatSprintRows(sprints) {
4829
+ const cells = sprints.map((s) => [
4830
+ String(s.id),
4831
+ s.state,
4832
+ s.name,
4833
+ s.start ? formatDate$1(s.start) : "-",
4834
+ s.end ? formatDate$1(s.end) : "-"
4835
+ ]);
4836
+ const widths = cells[0].map((_, i) => Math.max(...cells.map((row) => row[i].length)));
4837
+ return cells.map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd());
4838
+ }
4839
+ //#endregion
4493
4840
  //#region src/cli/jira.ts
4841
+ const FIELD_OPTION = [
4842
+ "-f, --field <name|id>",
4843
+ "add a field's value to each issue (repeatable)",
4844
+ collect
4845
+ ];
4494
4846
  function registerJira(jira) {
4495
4847
  jira.description("Jira commands");
4496
4848
  jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(jiraAction(jiraProjects));
4497
4849
  jira.command("statuses [query]").description("List statuses (optionally filtered by name, scoped with --project)").option("-p, --project <key>", "limit to statuses used by a project").option("--json", "output results as JSON").action(jiraAction(jiraStatuses));
4850
+ jira.command("boards [query]").description("List boards (optionally filtered by name, scoped with --project)").option("-p, --project <key>", "limit to boards located in a project").option("--json", "output results as JSON").action(jiraAction(jiraBoards));
4851
+ jira.command("board <ref>").description("Show a board's estimation field, filter JQL and columns (id, URL or exact name)").option("--json", "output results as JSON").action(jiraAction(jiraBoard));
4852
+ jira.command("sprints <ref>").description("List a board's sprints, active by default (id, URL or exact name)").option("-s, --state <state>", "active, future, or closed (repeatable)", collect).option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(jiraAction(jiraSprints));
4498
4853
  jira.command("create [project] [type]").description("Create a Jira issue, prompting for fields or taking them all as flags").option("-s, --summary <text>", "issue summary").option("-d, --description <markdown>", "description as Markdown").option("--description-file <path>", "read the description from a Markdown file").option("-a, --assignee <who>", "assignee: me, an account id, or a name to look up").option("--priority <name>", "priority name").option("-l, --label <label>", "label (repeatable)", collect).option("-c, --component <name>", "component (repeatable)", collect).option("--parent <key>", "parent issue key for subtasks").option("-f, --field <name=value>", "any other create-screen field (repeatable)", collect).option("--dry-run", "print the resolved payload instead of creating").option("--json", "print the created issue as JSON").action(jiraAction(jiraCreate));
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));
4854
+ jira.command("fields [project] [type]").description("List site fields, or the issue types a project offers, or the create form for one type").option("--search <text>", "site fields only: filter by name or id").option("--json", "output results as JSON").action(jiraAction(jiraFields));
4500
4855
  jira.command("view [issue]").description("Show a Jira issue (key or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(jiraAction(jiraView));
4501
4856
  jira.command("copy [issue]").description("Copy a Jira issue (key or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(jiraAction(jiraCopy));
4502
4857
  jira.command("update [file]").description("Update a Jira issue description from an edited Markdown file").option("--summary", "also push the H1 as the issue summary").option("-f, --force", "skip the stale-issue and data-loss checks").option("--dry-run", "show what would change without writing").action(jiraAction(jiraUpdate));
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));
4858
+ outputOptions(jira.command("list").description("List open issues assigned to you").option("-p, --project <key>", "limit to a project").option("-a, --all", "include Done issues updated in the last 30 days").option(...FIELD_OPTION), "max results (default all, max 100)").action(jiraAction(jiraList));
4859
+ outputOptions(jira.command("search [query]").description("Search Jira issues by text and filters").option("-p, --project <key>", "limit to a project (repeatable)", collect).option("-a, --assignee <who>", "limit to an assignee: me, a name, or an account id", collect).option("--reporter <who>", "limit to a reporter: me, a name, or an account id", collect).option("-s, --status <status>", "limit to a status (repeatable)", collect).option("-t, --type <type>", "limit to an issue type (repeatable)", collect).option("--label <label>", "limit to a label (repeatable)", collect).option("--sprint <sprint>", "limit to a sprint: active, future, an id, or a name (repeatable)", collect).option("-u, --updated <when>", "changed since 7d, 2w, 3m, or YYYY-MM-DD").option("--open", "exclude issues in the Done category").option(...FIELD_OPTION).addOption(moved("--jql <jql>", "jira jql"))).action(jiraAction(jiraSearch));
4860
+ outputOptions(jira.command("jql <query>").description("Search Jira issues with a raw JQL query").option(...FIELD_OPTION)).action(jiraAction(jiraJql));
4506
4861
  return jira;
4507
4862
  }
4508
4863
  //#endregion
4509
4864
  //#region package.json
4510
- var version = "2.1.0";
4865
+ var version = "2.2.0";
4511
4866
  //#endregion
4512
4867
  //#region src/cli/build.ts
4513
4868
  const PRODUCTS = {
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as fail, t as buildAtlass } from "./build-_4VuIcqF.mjs";
2
+ import { r as fail, t as buildAtlass } from "./build-H7eTLrf3.mjs";
3
3
  //#region src/cli.ts
4
4
  buildAtlass().parseAsync().catch(fail);
5
5
  //#endregion
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as buildStandalone, r as fail } from "./build-_4VuIcqF.mjs";
2
+ import { n as buildStandalone, r as fail } from "./build-H7eTLrf3.mjs";
3
3
  //#region src/confluence.ts
4
4
  buildStandalone("confluence").parseAsync().catch(fail);
5
5
  //#endregion
package/dist/jira.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as buildStandalone, r as fail } from "./build-_4VuIcqF.mjs";
2
+ import { n as buildStandalone, r as fail } from "./build-H7eTLrf3.mjs";
3
3
  //#region src/jira.ts
4
4
  buildStandalone("jira").parseAsync().catch(fail);
5
5
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "CLI to copy Jira issues and Confluence pages to Markdown, and update Confluence pages.",
5
5
  "license": "MIT",
6
6
  "repository": {