atlass 1.14.0 → 2.0.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
@@ -146,17 +146,29 @@ For both, `--json` and `--copy` work as for search.
146
146
 
147
147
  ```bash
148
148
  atlass jira search "safari login"
149
- atlass jira search --project PROJ --assignee me --status "In Progress"
150
- atlass jira search --jql "project = PROJ AND labels = regression"
151
- atlass confluence search "onboarding" --space DOCS
152
- atlass confluence search --cql "label = runbook ORDER BY created DESC"
149
+ atlass jira search --project PROJ --assignee me --open
150
+ atlass jira search --status "In Progress" --status "In Review" --updated 2w
151
+ atlass confluence search "onboarding" --space DOCS --label runbook
153
152
  ```
154
153
 
155
- Filters combine with AND and sort by most recently updated. Rows use the same
156
- columns as `jira list` and `confluence list`. `--jql` and `--cql` replace the
157
- friendly filters. `--limit` defaults to 25, max 100. `--json` prints machine
158
- output. Add `--copy` to pick results interactively and copy each one, with
159
- `--out` as the target directory.
154
+ Jira filters on `--project`, `--assignee`, `--reporter`, `--status`, `--type`,
155
+ `--label`, `--updated` and `--open`; Confluence on `--space`, `--label`,
156
+ `--updated` and `--starred`. Repeating a flag matches any of its values;
157
+ different flags must all match. `--assignee` and `--reporter` take `me`, an
158
+ account id, or a name to look up. `--updated` takes `7d`, `2w`, `3m` or
159
+ `YYYY-MM-DD`. Results sort by most recently updated and use the same columns
160
+ as `jira list` and `confluence list`.
161
+
162
+ For anything the filters do not cover, write the query yourself:
163
+
164
+ ```bash
165
+ atlass jira jql "project = PROJ AND labels = regression ORDER BY created DESC"
166
+ atlass confluence cql "label = runbook AND creator = currentUser()"
167
+ ```
168
+
169
+ `--limit` defaults to 25, max 100. `--json` prints machine output. Add `--copy`
170
+ to pick results interactively and copy each one, with `--out` as the target
171
+ directory. All four work on both search commands.
160
172
 
161
173
  Discovery aids for the filters:
162
174
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as buildStandalone, r as fail } from "./build-CFyTL_x2.mjs";
2
+ import { n as buildStandalone, r as fail } from "./build-DMLlCHdF.mjs";
3
3
  //#region src/bitbucket.ts
4
4
  buildStandalone("bitbucket").parseAsync().catch(fail);
5
5
  //#endregion
@@ -1,4 +1,4 @@
1
- import { Command } from "commander";
1
+ import { Command, Option } from "commander";
2
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { Entry } from "@napi-rs/keyring";
@@ -528,6 +528,41 @@ function siteOrigin(input) {
528
528
  if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
529
529
  return new URL(value).origin;
530
530
  }
531
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
532
+ const RELATIVE = /^(\d+)([dwm])$/i;
533
+ function parseSince(value, nowMs) {
534
+ const raw = value.trim();
535
+ const iso = ISO_DATE.exec(raw);
536
+ if (iso) {
537
+ if (!isRealDate(iso)) throw invalidSince(value);
538
+ return raw;
539
+ }
540
+ const relative = RELATIVE.exec(raw);
541
+ if (!relative) throw invalidSince(value);
542
+ const amount = Number.parseInt(relative[1], 10);
543
+ const date = new Date(nowMs);
544
+ switch (relative[2].toLowerCase()) {
545
+ case "d":
546
+ date.setDate(date.getDate() - amount);
547
+ break;
548
+ case "w":
549
+ date.setDate(date.getDate() - amount * 7);
550
+ break;
551
+ default: date.setMonth(date.getMonth() - amount);
552
+ }
553
+ return localDate(date);
554
+ }
555
+ function isRealDate([, year = "", month = "", day = ""]) {
556
+ const date = new Date(Number(year), Number(month) - 1, Number(day));
557
+ return date.getFullYear() === Number(year) && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day);
558
+ }
559
+ function localDate(date) {
560
+ const month = String(date.getMonth() + 1).padStart(2, "0");
561
+ return `${date.getFullYear()}-${month}-${String(date.getDate()).padStart(2, "0")}`;
562
+ }
563
+ function invalidSince(value) {
564
+ return /* @__PURE__ */ new Error(`Invalid --updated "${value}". Expected 7d, 2w, 3m, or YYYY-MM-DD.`);
565
+ }
531
566
  //#endregion
532
567
  //#region src/commands/auth.ts
533
568
  async function login({ term, profile }) {
@@ -1520,6 +1555,19 @@ function hyperlink(text, url) {
1520
1555
  }
1521
1556
  //#endregion
1522
1557
  //#region src/commands/search-run.ts
1558
+ function checkedLimit(options) {
1559
+ if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
1560
+ return parseLimit(options.limit);
1561
+ }
1562
+ async function showRows(term, rows, listing, options, noun, copyOne) {
1563
+ await runSearch(term, rows, {
1564
+ json: options.json,
1565
+ copy: options.copy,
1566
+ out: options.out,
1567
+ empty: listing.empty,
1568
+ footer: listing.hasMore ? searchFooter(listing.limit) : void 0
1569
+ }, noun, copyOne);
1570
+ }
1523
1571
  function alignedRows(items, nowMs, cells) {
1524
1572
  const rows = items.map((item) => {
1525
1573
  const cell = cells(item);
@@ -1829,6 +1877,103 @@ function registerBitbucket(bitbucket) {
1829
1877
  return bitbucket;
1830
1878
  }
1831
1879
  //#endregion
1880
+ //#region src/cli/search-options.ts
1881
+ function collect(value, previous = []) {
1882
+ return [...previous, value];
1883
+ }
1884
+ function moved(flags, command) {
1885
+ const flag = flags.split(" ")[0];
1886
+ return new Option(flags).hideHelp().argParser(() => {
1887
+ throw new Error(`${flag} is now its own command: \`atlass ${command} "<query>"\`.`);
1888
+ });
1889
+ }
1890
+ function outputOptions(command) {
1891
+ return command.option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy");
1892
+ }
1893
+ //#endregion
1894
+ //#region src/api/query.ts
1895
+ function quote$1(value) {
1896
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1897
+ }
1898
+ function values(given) {
1899
+ return (given ?? []).map((v) => v.trim()).filter((v) => v.length > 0);
1900
+ }
1901
+ function inClause(field, given) {
1902
+ return renderClause(field, values(given).map(quote$1));
1903
+ }
1904
+ function userClause(field, given) {
1905
+ return renderClause(field, values(given).map((v) => v === "me" ? "currentUser()" : quote$1(v)));
1906
+ }
1907
+ function renderClause(field, rendered) {
1908
+ if (rendered.length === 0) return null;
1909
+ if (rendered.length === 1) return `${field} = ${rendered[0]}`;
1910
+ return `${field} in (${rendered.join(", ")})`;
1911
+ }
1912
+ function joinClauses(clauses, order) {
1913
+ return `${clauses.filter((c) => c !== null).join(" AND ")} ORDER BY ${order}`;
1914
+ }
1915
+ //#endregion
1916
+ //#region src/util/html.ts
1917
+ const MAX_CODE_POINT = 1114111;
1918
+ const NAMED = {
1919
+ amp: "&",
1920
+ lt: "<",
1921
+ gt: ">",
1922
+ quot: "\"",
1923
+ apos: "'",
1924
+ nbsp: " "
1925
+ };
1926
+ function decodeEntities(text) {
1927
+ return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
1928
+ if (body[0] === "#") {
1929
+ const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
1930
+ return isCodePoint(code) ? String.fromCodePoint(code) : match;
1931
+ }
1932
+ return NAMED[body.toLowerCase()] ?? match;
1933
+ });
1934
+ }
1935
+ function isCodePoint(code) {
1936
+ return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
1937
+ }
1938
+ //#endregion
1939
+ //#region src/api/confluence-search.ts
1940
+ async function searchPages(client, site, params) {
1941
+ return searchPagesByCql(client, site, buildCql(params), params.limit);
1942
+ }
1943
+ async function searchPagesByCql(client, site, cql, limit) {
1944
+ const query = new URLSearchParams({
1945
+ cql,
1946
+ limit: String(limit),
1947
+ expand: "content.space"
1948
+ });
1949
+ const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
1950
+ const serverPageWasFull = results.length === limit;
1951
+ return {
1952
+ pages: results.filter((r) => r.content?.id).map((r) => ({
1953
+ id: r.content?.id ?? "",
1954
+ space: r.content?.space?.key ?? spaceKeyOf(r.resultGlobalContainer?.displayUrl),
1955
+ title: decodeEntities(r.content?.title ?? r.title ?? ""),
1956
+ updated: r.lastModified ?? "",
1957
+ url: r.url ? `${site}/wiki${r.url}` : ""
1958
+ })),
1959
+ hasMore: serverPageWasFull
1960
+ };
1961
+ }
1962
+ const SPACE_URL = /\/spaces\/([^/?#]+)/;
1963
+ function spaceKeyOf(displayUrl) {
1964
+ return SPACE_URL.exec(displayUrl ?? "")?.[1] ?? "";
1965
+ }
1966
+ function buildCql(params) {
1967
+ return joinClauses([
1968
+ "type = page",
1969
+ params.starred ? "favourite = currentUser()" : null,
1970
+ inClause("space", params.space),
1971
+ inClause("label", params.label),
1972
+ params.updatedSince ? `lastmodified >= ${quote$1(params.updatedSince)}` : null,
1973
+ params.text ? `text ~ ${quote$1(params.text)}` : null
1974
+ ], "lastmodified DESC");
1975
+ }
1976
+ //#endregion
1832
1977
  //#region src/adf/from-markdown.ts
1833
1978
  function mediaNode(source, alt) {
1834
1979
  const attrs = { ...source };
@@ -2160,62 +2305,6 @@ var UserNames = class {
2160
2305
  }
2161
2306
  };
2162
2307
  //#endregion
2163
- //#region src/util/html.ts
2164
- const MAX_CODE_POINT = 1114111;
2165
- const NAMED = {
2166
- amp: "&",
2167
- lt: "<",
2168
- gt: ">",
2169
- quot: "\"",
2170
- apos: "'",
2171
- nbsp: " "
2172
- };
2173
- function decodeEntities(text) {
2174
- return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
2175
- if (body[0] === "#") {
2176
- const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
2177
- return isCodePoint(code) ? String.fromCodePoint(code) : match;
2178
- }
2179
- return NAMED[body.toLowerCase()] ?? match;
2180
- });
2181
- }
2182
- function isCodePoint(code) {
2183
- return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
2184
- }
2185
- //#endregion
2186
- //#region src/api/confluence-search.ts
2187
- async function searchPages(client, site, params) {
2188
- const cql = buildCql(params);
2189
- const query = new URLSearchParams({
2190
- cql,
2191
- limit: String(params.limit),
2192
- expand: "space"
2193
- });
2194
- const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
2195
- const serverPageWasFull = results.length === params.limit;
2196
- return {
2197
- pages: results.filter((r) => r.content?.id).map((r) => ({
2198
- id: r.content?.id ?? "",
2199
- space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
2200
- title: decodeEntities(r.content?.title ?? r.title ?? ""),
2201
- updated: r.lastModified ?? "",
2202
- url: r.url ? `${site}/wiki${r.url}` : ""
2203
- })),
2204
- hasMore: serverPageWasFull
2205
- };
2206
- }
2207
- function buildCql(params) {
2208
- if (params.cql) return params.cql;
2209
- const clauses = ["type = page"];
2210
- if (params.starred) clauses.push("favourite = currentUser()");
2211
- if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
2212
- if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
2213
- return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
2214
- }
2215
- function cqlStringLiteral(value) {
2216
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
2217
- }
2218
- //#endregion
2219
2308
  //#region src/commands/resolve-ref.ts
2220
2309
  async function resolveRef(ask, arg, kind) {
2221
2310
  const raw = arg ?? await ask.text({
@@ -2794,35 +2883,52 @@ async function fileSize(files, path) {
2794
2883
  return {};
2795
2884
  }
2796
2885
  }
2886
+ async function copyPage(env, id, out) {
2887
+ env.term.err(`Fetching page ${id} ...`);
2888
+ await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
2889
+ }
2890
+ const PAGE_REF = {
2891
+ message: "Confluence page id or URL:",
2892
+ flag: "[page]",
2893
+ parse: parsePageId,
2894
+ notFound: (raw) => `Could not find a page id in "${raw}".`
2895
+ };
2896
+ //#endregion
2897
+ //#region src/commands/confluence-search.ts
2797
2898
  async function confluenceSearch(env, query, options) {
2798
- if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
2799
2899
  await listPages(env, {
2800
2900
  text: query,
2801
2901
  space: options.space,
2802
- cql: options.cql
2902
+ label: options.label,
2903
+ starred: options.starred,
2904
+ updatedSince: options.updated ? parseSince(options.updated, Date.now()) : void 0
2803
2905
  }, "No matching pages.", options);
2804
2906
  }
2805
2907
  async function confluenceList(env, options) {
2806
2908
  await listPages(env, {
2807
2909
  starred: true,
2808
- space: options.space
2910
+ space: options.space ? [options.space] : void 0
2809
2911
  }, options.space ? `No starred pages in ${options.space}.` : "No starred pages.", options);
2810
2912
  }
2913
+ async function confluenceCql(env, query, options) {
2914
+ const limit = checkedLimit(options);
2915
+ const { pages, hasMore } = await searchPagesByCql(env.session, env.session.site, query, limit);
2916
+ await showPages(env, pages, hasMore, limit, options);
2917
+ }
2811
2918
  async function listPages(env, filter, empty, options) {
2812
- const { session, term } = env;
2813
- if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
2814
- const limit = parseLimit(options.limit);
2815
- const { pages, hasMore } = await searchPages(session, session.site, {
2919
+ const limit = checkedLimit(options);
2920
+ const { pages, hasMore } = await searchPages(env.session, env.session.site, {
2816
2921
  ...filter,
2817
2922
  limit
2818
2923
  });
2819
- await runSearch(term, formatPageRows(pages, Date.now()), {
2820
- json: options.json,
2821
- copy: options.copy,
2822
- out: options.out,
2924
+ await showPages(env, pages, hasMore, limit, options, empty);
2925
+ }
2926
+ async function showPages(env, pages, hasMore, limit, options, empty = "No matching pages.") {
2927
+ await showRows(env.term, formatPageRows(pages, Date.now()), {
2823
2928
  empty,
2824
- footer: hasMore ? searchFooter(limit) : void 0
2825
- }, PAGE_NOUN, (id) => copyPage(env, id, options.out));
2929
+ hasMore,
2930
+ limit
2931
+ }, options, PAGE_NOUN, (id) => copyPage(env, id, options.out));
2826
2932
  }
2827
2933
  function formatPageRows(pages, nowMs) {
2828
2934
  return alignedRows(pages, nowMs, (p) => ({
@@ -2850,16 +2956,6 @@ const PAGE_NOUN = {
2850
2956
  singular: "page",
2851
2957
  plural: "pages"
2852
2958
  };
2853
- async function copyPage(env, id, out) {
2854
- env.term.err(`Fetching page ${id} ...`);
2855
- await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
2856
- }
2857
- const PAGE_REF = {
2858
- message: "Confluence page id or URL:",
2859
- flag: "[page]",
2860
- parse: parsePageId,
2861
- notFound: (raw) => `Could not find a page id in "${raw}".`
2862
- };
2863
2959
  //#endregion
2864
2960
  //#region src/cli/confluence.ts
2865
2961
  function registerConfluence(confluence) {
@@ -2867,8 +2963,9 @@ function registerConfluence(confluence) {
2867
2963
  confluence.command("view [page]").description("Show a Confluence page (id or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(jiraAction(confluenceView));
2868
2964
  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));
2869
2965
  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));
2870
- confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space").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(jiraAction(confluenceList));
2871
- confluence.command("search [query]").description("Search Confluence pages (text query, --space, or --cql)").option("-s, --space <key>", "limit to a space").option("--cql <cql>", "raw CQL 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(jiraAction(confluenceSearch));
2966
+ outputOptions(confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space")).action(jiraAction(confluenceList));
2967
+ outputOptions(confluence.command("search [query]").description("Search Confluence pages by text and filters").option("-s, --space <key>", "limit to a space (repeatable)", collect).option("--label <label>", "limit to a label (repeatable)", collect).option("--starred", "limit to pages you starred").option("-u, --updated <when>", "changed since 7d, 2w, 3m, or YYYY-MM-DD").addOption(moved("--cql <cql>", "confluence cql"))).action(jiraAction(confluenceSearch));
2968
+ outputOptions(confluence.command("cql <query>").description("Search Confluence pages with a raw CQL query")).action(jiraAction(confluenceCql));
2872
2969
  return confluence;
2873
2970
  }
2874
2971
  //#endregion
@@ -2937,6 +3034,24 @@ async function searchAssignableUsers(client, project, query) {
2937
3034
  });
2938
3035
  return (await client.getJson(`/rest/api/3/user/assignable/search?${params.toString()}`)).map(toJiraUser);
2939
3036
  }
3037
+ const ACCOUNT_ID = /^(?:[0-9a-z]{24}|[a-z0-9]+:[0-9a-f-]{36}(?::[0-9a-f-]{36})?)$/i;
3038
+ async function searchUsers(client, query) {
3039
+ const params = new URLSearchParams({
3040
+ query,
3041
+ maxResults: "20"
3042
+ });
3043
+ return (await client.getJson(`/rest/api/3/user/search?${params.toString()}`)).map(toJiraUser);
3044
+ }
3045
+ function matchUser(users, query, noun) {
3046
+ const active = users.filter((u) => u.active);
3047
+ const needle = query.toLowerCase();
3048
+ const exact = active.filter((u) => u.displayName.toLowerCase() === needle || u.email.toLowerCase() === needle);
3049
+ const candidates = exact.length === 1 ? exact : active;
3050
+ if (candidates.length === 1) return candidates[0].accountId;
3051
+ if (candidates.length === 0) throw new Error(`no ${noun} matches "${query}".`);
3052
+ const names = candidates.map((u) => u.displayName).join(", ");
3053
+ throw new Error(`"${query}" matches ${candidates.length} users: ${names}.`);
3054
+ }
2940
3055
  async function fetchMyself(client) {
2941
3056
  return toJiraUser(await client.getJson("/rest/api/3/myself"));
2942
3057
  }
@@ -3014,18 +3129,11 @@ function matchType(types, arg) {
3014
3129
  const needle = arg.trim().toLowerCase();
3015
3130
  return types.find((t) => t.id === arg.trim()) ?? types.find((t) => t.name.toLowerCase() === needle);
3016
3131
  }
3017
- const ACCOUNT_ID = /^(?:[0-9a-f]{24}|[a-z0-9]+:[0-9a-f-]{36}(?::[0-9a-f-]{36})?)$/i;
3018
3132
  function userResolver(client, project) {
3019
3133
  return async (query) => {
3020
3134
  if (query === "me") return (await fetchMyself(client)).accountId;
3021
3135
  if (ACCOUNT_ID.test(query)) return query;
3022
- const users = (await searchAssignableUsers(client, project, query)).filter((u) => u.active);
3023
- const exact = users.filter((u) => u.displayName.toLowerCase() === query.toLowerCase() || u.email.toLowerCase() === query.toLowerCase());
3024
- const candidates = exact.length === 1 ? exact : users;
3025
- if (candidates.length === 1) return candidates[0].accountId;
3026
- if (candidates.length === 0) throw new Error(`no assignable user matches "${query}".`);
3027
- const names = candidates.map((u) => u.displayName).join(", ");
3028
- throw new Error(`"${query}" matches ${candidates.length} users: ${names}.`);
3136
+ return matchUser(await searchAssignableUsers(client, project, query), query, "assignable user");
3029
3137
  };
3030
3138
  }
3031
3139
  //#endregion
@@ -3639,9 +3747,13 @@ async function jiraFields({ session, term }, projectArg, typeArg, options) {
3639
3747
  //#endregion
3640
3748
  //#region src/api/jira-search.ts
3641
3749
  const RECENT_ISSUES_CLAUSE = "updated >= -30d";
3750
+ const OPEN_CLAUSE = "statusCategory != Done";
3642
3751
  const SEARCH_FIELDS = "summary,status,updated";
3643
3752
  async function searchIssues(client, site, params) {
3644
- return ((await fetchSearchPage(client, buildJql(params), params.limit)).issues ?? []).map((i) => toIssueSummary(site, i));
3753
+ return searchIssuesByJql(client, site, buildJql(params), params.limit);
3754
+ }
3755
+ async function searchIssuesByJql(client, site, jql, limit) {
3756
+ return ((await fetchSearchPage(client, jql, limit)).issues ?? []).map((i) => toIssueSummary(site, i));
3645
3757
  }
3646
3758
  async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
3647
3759
  const query = new URLSearchParams({
@@ -3662,22 +3774,28 @@ function toIssueSummary(site, i) {
3662
3774
  url: browseUrl(site, i.key)
3663
3775
  };
3664
3776
  }
3777
+ const ISSUE_ORDER = "updated DESC";
3665
3778
  function buildJql(params) {
3666
- if (params.jql) return params.jql;
3667
- const clauses = [];
3668
- if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
3669
- if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlStringLiteral(params.assignee)}`);
3670
- if (params.status) clauses.push(`status = ${jqlStringLiteral(params.status)}`);
3671
- if (params.text) clauses.push(`text ~ ${jqlStringLiteral(params.text)}`);
3779
+ const clauses = [
3780
+ inClause("project", params.project),
3781
+ inClause("type", params.type),
3782
+ inClause("status", params.status),
3783
+ params.open ? OPEN_CLAUSE : null,
3784
+ userClause("assignee", params.assignee),
3785
+ userClause("reporter", params.reporter),
3786
+ inClause("labels", params.label),
3787
+ params.updatedSince ? `updated >= ${quote$1(params.updatedSince)}` : null,
3788
+ params.text ? `text ~ ${quote$1(params.text)}` : null
3789
+ ].filter((c) => c !== null);
3672
3790
  if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
3673
- return `${clauses.join(" AND ")} ORDER BY updated DESC`;
3791
+ return joinClauses(clauses, ISSUE_ORDER);
3674
3792
  }
3675
- const OPEN_CLAUSE = "statusCategory != Done";
3676
3793
  function buildListJql(params) {
3677
- const clauses = ["assignee = currentUser()"];
3678
- if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
3679
- clauses.push(params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE);
3680
- return `${clauses.join(" AND ")} ORDER BY updated DESC`;
3794
+ return joinClauses([
3795
+ "assignee = currentUser()",
3796
+ params.project ? `project = ${quote$1(params.project)}` : null,
3797
+ params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE
3798
+ ], ISSUE_ORDER);
3681
3799
  }
3682
3800
  const LIST_PAGE_SIZE = 100;
3683
3801
  const LIST_CAP = 500;
@@ -3712,8 +3830,59 @@ function sortByCategoryThenUpdated(issues) {
3712
3830
  const updatedMs = (i) => Date.parse(i.updated) || 0;
3713
3831
  return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
3714
3832
  }
3715
- function jqlStringLiteral(value) {
3716
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
3833
+ //#endregion
3834
+ //#region src/commands/jira-filters.ts
3835
+ const FILTER_FLAGS = [
3836
+ "project",
3837
+ "assignee",
3838
+ "reporter",
3839
+ "status",
3840
+ "type",
3841
+ "label"
3842
+ ];
3843
+ const ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/i;
3844
+ function hasFilters(filters) {
3845
+ return FILTER_FLAGS.some((flag) => values(filters[flag]).length > 0) || filters.updated !== void 0 || filters.open === true;
3846
+ }
3847
+ function checkQuery(query, filters) {
3848
+ if (query && ISSUE_KEY.test(query.trim())) throw new Error(`"${query}" looks like an issue key. Run \`jira view ${query}\` to open it.`);
3849
+ if (!query && !hasFilters(filters)) throw new Error("Give a text query or at least one filter. `jira list` shows your open issues.");
3850
+ }
3851
+ async function searchParams(client, query, filters, limit, nowMs) {
3852
+ return {
3853
+ text: query,
3854
+ project: filters.project,
3855
+ assignee: await resolveUsers(client, filters.assignee),
3856
+ reporter: await resolveUsers(client, filters.reporter),
3857
+ status: filters.status,
3858
+ type: filters.type,
3859
+ label: filters.label,
3860
+ updatedSince: filters.updated ? parseSince(filters.updated, nowMs) : void 0,
3861
+ open: filters.open,
3862
+ limit
3863
+ };
3864
+ }
3865
+ async function resolveUsers(client, given) {
3866
+ const list = values(given);
3867
+ if (list.length === 0) return void 0;
3868
+ const resolved = [];
3869
+ for (const who of list) if (who === "me" || ACCOUNT_ID.test(who)) resolved.push(who);
3870
+ else resolved.push(matchUser(await searchUsers(client, who), who, "user"));
3871
+ return resolved;
3872
+ }
3873
+ const FIELD_IN_MESSAGE = /for the field '([^']+)'/;
3874
+ const createForm = (project) => `jira fields ${project ?? "<project>"}`;
3875
+ const DISCOVERY = {
3876
+ project: () => "jira projects",
3877
+ status: (p) => p ? `jira statuses --project ${p}` : "jira statuses",
3878
+ issuetype: createForm,
3879
+ type: createForm
3880
+ };
3881
+ function searchHint(err, project) {
3882
+ if (!(err instanceof HttpError) || err.status !== 400) return err;
3883
+ const field = FIELD_IN_MESSAGE.exec(err.message)?.[1]?.toLowerCase();
3884
+ const command = field ? DISCOVERY[field]?.(values(project)[0]) : void 0;
3885
+ return command ? /* @__PURE__ */ new Error(`${err.message} Run \`${command}\` to see the options.`) : err;
3717
3886
  }
3718
3887
  //#endregion
3719
3888
  //#region src/api/jira-issues.ts
@@ -3900,29 +4069,29 @@ const ISSUE_REF = {
3900
4069
  //#endregion
3901
4070
  //#region src/commands/jira-search.ts
3902
4071
  async function jiraSearch(env, query, options) {
3903
- const { session, term } = env;
3904
- if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
3905
- if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
3906
- const limit = parseLimit(options.limit);
3907
- const issues = await searchIssues(session, session.site, {
3908
- text: query,
3909
- project: options.project,
3910
- assignee: options.assignee,
3911
- status: options.status,
3912
- jql: options.jql,
4072
+ const { session } = env;
4073
+ checkQuery(query, options);
4074
+ const limit = checkedLimit(options);
4075
+ const params = await searchParams(session, query, options, limit, Date.now());
4076
+ await showIssues(env, await searchIssues(session, session.site, params).catch((err) => {
4077
+ throw searchHint(err, options.project);
4078
+ }), limit, options);
4079
+ }
4080
+ async function jiraJql(env, query, options) {
4081
+ const { session } = env;
4082
+ const limit = checkedLimit(options);
4083
+ await showIssues(env, await searchIssuesByJql(session, session.site, query, limit), limit, options);
4084
+ }
4085
+ async function showIssues(env, issues, limit, options, empty = "No matching issues.") {
4086
+ await showRows(env.term, formatIssueRows(issues, Date.now()), {
4087
+ empty,
4088
+ hasMore: issues.length === limit,
3913
4089
  limit
3914
- });
3915
- await runSearch(term, formatIssueRows(issues, Date.now()), {
3916
- json: options.json,
3917
- copy: options.copy,
3918
- out: options.out,
3919
- empty: "No matching issues.",
3920
- footer: issues.length === limit ? searchFooter(limit) : void 0
3921
- }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
4090
+ }, options, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
3922
4091
  }
3923
4092
  async function jiraList(env, options) {
3924
4093
  const { session, term } = env;
3925
- if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
4094
+ checkedLimit(options);
3926
4095
  const { issues, truncated } = await listAssignedIssues(session, session.site, {
3927
4096
  all: options.all,
3928
4097
  project: options.project
@@ -3951,25 +4120,23 @@ const ISSUE_NOUN = {
3951
4120
  };
3952
4121
  //#endregion
3953
4122
  //#region src/cli/jira.ts
3954
- function collect(value, previous = []) {
3955
- return [...previous, value];
3956
- }
3957
4123
  function registerJira(jira) {
3958
4124
  jira.description("Jira commands");
3959
4125
  jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(jiraAction(jiraProjects));
3960
4126
  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));
3961
- 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, or comma separated)", collect).option("-c, --component <name>", "component (repeatable, or comma separated)", 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));
4127
+ 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));
3962
4128
  jira.command("fields <project> [type]").description("Show the issue types you can create in a project, or the create form for one type").option("--json", "output results as JSON").action(jiraAction(jiraFields));
3963
4129
  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));
3964
4130
  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));
3965
4131
  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));
3966
- 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(jiraAction(jiraList));
3967
- 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(jiraAction(jiraSearch));
4132
+ outputOptions(jira.command("list").description("List open issues assigned to you").option("-p, --project <key>", "limit to a project").option("-a, --all", "include Done issues updated in the last 30 days")).action(jiraAction(jiraList));
4133
+ outputOptions(jira.command("search [query]").description("Search Jira issues by text and filters").option("-p, --project <key>", "limit to a project (repeatable)", collect).option("-a, --assignee <who>", "limit to an assignee: me, a name, or an account id", collect).option("--reporter <who>", "limit to a reporter: me, a name, or an account id", collect).option("-s, --status <status>", "limit to a status (repeatable)", collect).option("-t, --type <type>", "limit to an issue type (repeatable)", collect).option("--label <label>", "limit to a label (repeatable)", collect).option("-u, --updated <when>", "changed since 7d, 2w, 3m, or YYYY-MM-DD").option("--open", "exclude issues in the Done category").addOption(moved("--jql <jql>", "jira jql"))).action(jiraAction(jiraSearch));
4134
+ outputOptions(jira.command("jql <query>").description("Search Jira issues with a raw JQL query")).action(jiraAction(jiraJql));
3968
4135
  return jira;
3969
4136
  }
3970
4137
  //#endregion
3971
4138
  //#region package.json
3972
- var version = "1.14.0";
4139
+ var version = "2.0.0";
3973
4140
  //#endregion
3974
4141
  //#region src/cli/build.ts
3975
4142
  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-CFyTL_x2.mjs";
2
+ import { r as fail, t as buildAtlass } from "./build-DMLlCHdF.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-CFyTL_x2.mjs";
2
+ import { n as buildStandalone, r as fail } from "./build-DMLlCHdF.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-CFyTL_x2.mjs";
2
+ import { n as buildStandalone, r as fail } from "./build-DMLlCHdF.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": "1.14.0",
3
+ "version": "2.0.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": {