atlass 1.7.0 → 1.8.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.
Files changed (3) hide show
  1. package/README.md +13 -0
  2. package/dist/cli.mjs +111 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -86,6 +86,19 @@ body sent is everything between the H1 and `## Comments`.
86
86
  - Confluence uploads local images referenced in the body as attachments. Jira
87
87
  update does not support image changes yet.
88
88
 
89
+ ## List
90
+
91
+ ```bash
92
+ atlass jira list
93
+ atlass jira list --project PROJ
94
+ atlass jira list --all
95
+ ```
96
+
97
+ Lists issues assigned to you as `KEY Status Age Summary`, with In Progress
98
+ first, then To Do, and the most recently updated at the top of each group. Done
99
+ issues are left out; `--all` adds those updated in the last 30 days. `--json`
100
+ and `--copy` work as for search.
101
+
89
102
  ## Search
90
103
 
91
104
  ```bash
package/dist/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { Entry } from "@napi-rs/keyring";
8
8
  import { marked } from "marked";
9
9
  import { randomUUID } from "node:crypto";
10
+ import { stripVTControlCharacters } from "node:util";
10
11
  import kleur from "kleur";
11
12
  import { common, createLowlight } from "lowlight";
12
13
  //#region src/api/client.ts
@@ -980,7 +981,7 @@ async function runSearch(rows, options, noun, copyOne) {
980
981
  return;
981
982
  }
982
983
  if (rows.length === 0) {
983
- console.log(`No matching ${noun.plural}.`);
984
+ console.log(options.empty);
984
985
  return;
985
986
  }
986
987
  if (options.copy) {
@@ -989,7 +990,11 @@ async function runSearch(rows, options, noun, copyOne) {
989
990
  return;
990
991
  }
991
992
  for (const row of rows) console.log(formatRow(row));
992
- if (options.hasMore) console.log(`\nShowing first ${options.limit}; refine with flags or raise --limit.`);
993
+ if (options.footer) console.log(options.footer);
994
+ }
995
+ function searchFooter(limit) {
996
+ return `
997
+ Showing first ${limit}; refine with flags or raise --limit.`;
993
998
  }
994
999
  async function copySelected(rows, noun, copyOne) {
995
1000
  if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
@@ -1022,7 +1027,7 @@ async function copySelected(rows, noun, copyOne) {
1022
1027
  else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
1023
1028
  }
1024
1029
  function formatRow(row) {
1025
- const room = (process.stdout.columns ?? 80) - row.fixedColumns.length - 2;
1030
+ const room = (process.stdout.columns ?? 80) - stripVTControlCharacters(row.fixedColumns).length - 2;
1026
1031
  const text = room > 0 ? truncate(row.freeText, room) : "";
1027
1032
  return text ? `${row.fixedColumns} ${text}` : row.fixedColumns;
1028
1033
  }
@@ -1765,9 +1770,9 @@ async function confluenceSearch(query, options) {
1765
1770
  })), {
1766
1771
  json: options.json,
1767
1772
  copy: options.copy,
1768
- limit,
1769
- hasMore,
1770
- out: options.out
1773
+ out: options.out,
1774
+ empty: "No matching pages.",
1775
+ footer: hasMore ? searchFooter(limit) : void 0
1771
1776
  }, {
1772
1777
  singular: "page",
1773
1778
  plural: "pages"
@@ -1832,19 +1837,28 @@ async function updateIssue(client, key, update) {
1832
1837
  if (update.summary !== void 0) fields["summary"] = update.summary;
1833
1838
  await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
1834
1839
  }
1840
+ const SEARCH_FIELDS = "summary,status,updated";
1835
1841
  async function searchIssues(client, site, params) {
1836
- const jql = buildJql(params);
1842
+ return ((await fetchSearchPage(client, buildJql(params), params.limit)).issues ?? []).map((i) => toIssueSummary(site, i));
1843
+ }
1844
+ async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
1837
1845
  const query = new URLSearchParams({
1838
1846
  jql,
1839
- maxResults: String(params.limit),
1840
- fields: "summary,status"
1847
+ maxResults: String(maxResults),
1848
+ fields: SEARCH_FIELDS
1841
1849
  });
1842
- return ((await client.getJson(`/rest/api/3/search/jql?${query.toString()}`)).issues ?? []).map((i) => ({
1850
+ if (nextPageToken) query.set("nextPageToken", nextPageToken);
1851
+ return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
1852
+ }
1853
+ function toIssueSummary(site, i) {
1854
+ return {
1843
1855
  key: i.key,
1844
1856
  status: i.fields?.status?.name ?? "",
1857
+ statusCategory: i.fields?.status?.statusCategory?.key ?? "",
1845
1858
  summary: decodeEntities(i.fields?.summary ?? ""),
1859
+ updated: i.fields?.updated ?? "",
1846
1860
  url: browseUrl(site, i.key)
1847
- }));
1861
+ };
1848
1862
  }
1849
1863
  function buildJql(params) {
1850
1864
  if (params.jql) return params.jql;
@@ -1856,6 +1870,46 @@ function buildJql(params) {
1856
1870
  if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
1857
1871
  return `${clauses.join(" AND ")} ORDER BY updated DESC`;
1858
1872
  }
1873
+ const OPEN_CLAUSE = "statusCategory != Done";
1874
+ function buildListJql(params) {
1875
+ const clauses = ["assignee = currentUser()"];
1876
+ if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
1877
+ clauses.push(params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE);
1878
+ return `${clauses.join(" AND ")} ORDER BY updated DESC`;
1879
+ }
1880
+ const LIST_PAGE_SIZE = 100;
1881
+ const LIST_CAP = 500;
1882
+ async function listAssignedIssues(client, site, params) {
1883
+ const jql = buildListJql(params);
1884
+ const issues = [];
1885
+ let nextPageToken;
1886
+ for (;;) {
1887
+ const room = LIST_CAP - issues.length;
1888
+ const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
1889
+ const page = res.issues ?? [];
1890
+ issues.push(...page.map((i) => toIssueSummary(site, i)));
1891
+ if (res.isLast || !res.nextPageToken || page.length === 0) return {
1892
+ issues,
1893
+ truncated: false
1894
+ };
1895
+ if (issues.length >= LIST_CAP) return {
1896
+ issues,
1897
+ truncated: true
1898
+ };
1899
+ nextPageToken = res.nextPageToken;
1900
+ }
1901
+ }
1902
+ const LIST_CATEGORY_ORDER = {
1903
+ indeterminate: 0,
1904
+ new: 1,
1905
+ done: 2
1906
+ };
1907
+ const LIST_UNKNOWN_CATEGORY_RANK = Object.keys(LIST_CATEGORY_ORDER).length;
1908
+ function sortByCategoryThenUpdated(issues) {
1909
+ const rank = (i) => LIST_CATEGORY_ORDER[i.statusCategory] ?? LIST_UNKNOWN_CATEGORY_RANK;
1910
+ const updatedMs = (i) => Date.parse(i.updated) || 0;
1911
+ return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
1912
+ }
1859
1913
  const PROJECT_PAGE_SIZE = 50;
1860
1914
  async function listProjects(client, site, query) {
1861
1915
  const projects = [];
@@ -2123,6 +2177,9 @@ const CATEGORY_COLORS = {
2123
2177
  indeterminate: kleur.yellow,
2124
2178
  done: kleur.green
2125
2179
  };
2180
+ function colorForCategory(category) {
2181
+ return CATEGORY_COLORS[category] ?? ((text) => text);
2182
+ }
2126
2183
  function formatIssueView(issue, nowMs, allComments) {
2127
2184
  return [
2128
2185
  `${kleur.bold(issue.key)} ${issue.summary}`,
@@ -2133,7 +2190,7 @@ function formatIssueView(issue, nowMs, allComments) {
2133
2190
  ];
2134
2191
  }
2135
2192
  function fieldLines(issue, nowMs) {
2136
- const colorStatus = CATEGORY_COLORS[issue.statusCategory] ?? ((text) => text);
2193
+ const colorStatus = colorForCategory(issue.statusCategory);
2137
2194
  const kept = [
2138
2195
  ["Type", issue.type],
2139
2196
  ["Status", issue.status && colorStatus(issue.status)],
@@ -2226,14 +2283,47 @@ async function jiraSearch(query, options) {
2226
2283
  })), {
2227
2284
  json: options.json,
2228
2285
  copy: options.copy,
2229
- limit,
2230
- hasMore: issues.length === limit,
2231
- out: options.out
2232
- }, {
2233
- singular: "issue",
2234
- plural: "issues"
2235
- }, (key) => copyIssue(client, auth.site, key, options.out));
2286
+ out: options.out,
2287
+ empty: "No matching issues.",
2288
+ footer: issues.length === limit ? searchFooter(limit) : void 0
2289
+ }, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
2290
+ }
2291
+ async function jiraList(options) {
2292
+ if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
2293
+ const auth = await requireAuth();
2294
+ const client = new AtlassianClient(auth);
2295
+ const { issues, truncated } = await listAssignedIssues(client, auth.site, {
2296
+ all: options.all,
2297
+ project: options.project
2298
+ });
2299
+ await runSearch(formatListRows(sortByCategoryThenUpdated(issues), Date.now()), {
2300
+ json: options.json,
2301
+ copy: options.copy,
2302
+ out: options.out,
2303
+ empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
2304
+ footer: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
2305
+ }, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
2306
+ }
2307
+ function formatListRows(issues, nowMs) {
2308
+ const ages = issues.map((i) => relativeTime(i.updated, nowMs));
2309
+ const width = (values) => Math.max(...values.map((v) => v.length));
2310
+ const keyWidth = width(issues.map((i) => i.key));
2311
+ const statusWidth = width(issues.map((i) => i.status));
2312
+ const ageWidth = width(ages);
2313
+ return issues.map((i, n) => {
2314
+ const status = colorForCategory(i.statusCategory)(i.status.padEnd(statusWidth));
2315
+ return {
2316
+ id: i.key,
2317
+ fixedColumns: `${i.key.padEnd(keyWidth)} ${status} ${(ages[n] ?? "").padEnd(ageWidth)}`,
2318
+ freeText: i.summary,
2319
+ json: { ...i }
2320
+ };
2321
+ });
2236
2322
  }
2323
+ const ISSUE_NOUN = {
2324
+ singular: "issue",
2325
+ plural: "issues"
2326
+ };
2237
2327
  async function copyIssue(client, site, key, out) {
2238
2328
  console.log(`Fetching ${key} ...`);
2239
2329
  await runCopy(planIssueCopy(await fetchIssue(client, site, key), out), (url) => client.getBinary(url));
@@ -2245,7 +2335,7 @@ const ISSUE_REF = {
2245
2335
  };
2246
2336
  //#endregion
2247
2337
  //#region package.json
2248
- var version = "1.7.0";
2338
+ var version = "1.8.0";
2249
2339
  //#endregion
2250
2340
  //#region src/cli.ts
2251
2341
  const program = new Command();
@@ -2260,6 +2350,7 @@ jira.command("statuses [query]").description("List statuses (optionally filtered
2260
2350
  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").action(run(jiraView));
2261
2351
  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(run(jiraCopy));
2262
2352
  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(run(jiraUpdate));
2353
+ jira.command("list").description("List open issues assigned to you").option("-p, --project <key>", "limit to a project").option("-a, --all", "include Done issues updated in the last 30 days").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(jiraList));
2263
2354
  jira.command("search [query]").description("Search Jira issues (text query, filters, or --jql)").option("-p, --project <key>", "limit to a project").option("-a, --assignee <who>", "limit to an assignee (or 'me')").option("-s, --status <status>", "limit to a status").option("--jql <jql>", "raw JQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(jiraSearch));
2264
2355
  const confluence = program.command("confluence").description("Confluence commands");
2265
2356
  confluence.command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(confluenceCopy));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "1.7.0",
3
+ "version": "1.8.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": {