atlass 1.0.0 → 1.1.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 +43 -0
  2. package/dist/cli.mjs +243 -11
  3. package/package.json +5 -1
package/README.md CHANGED
@@ -72,6 +72,49 @@ atlass confluence copy # prompts for the id or URL
72
72
  Accepts a numeric page id or a page URL. Writes `123456-title-slug.md` to the
73
73
  current directory.
74
74
 
75
+ ### Search Jira issues
76
+
77
+ ```bash
78
+ atlass jira search "safari login" # free text
79
+ atlass jira search --project PROJ --assignee me # my open issues in PROJ
80
+ atlass jira search --status "In Progress"
81
+ atlass jira search --jql "project = PROJ AND labels = regression"
82
+ atlass jira search # recent issues
83
+ ```
84
+
85
+ Friendly filters (`--project`, `--assignee`, `--status`, text) are AND'd
86
+ together and ordered by most recently updated. `--assignee me` maps to the
87
+ current user. `--jql` takes a raw query and cannot be combined with the friendly
88
+ filters. Prints one issue per line (`KEY status summary`); use `--json` for
89
+ machine output. Only the first `--limit` results are shown (default 25, max
90
+ 100).
91
+
92
+ ### Search Confluence pages
93
+
94
+ ```bash
95
+ atlass confluence search "onboarding"
96
+ atlass confluence search --space DOCS
97
+ atlass confluence search --cql "label = runbook ORDER BY created DESC"
98
+ atlass confluence search # recent pages
99
+ ```
100
+
101
+ Friendly mode always constrains to pages, so every result is copy-able. `--cql`
102
+ takes a raw query and cannot be combined with `--space` or text. Prints one page
103
+ per line (`id space title`); `--json` and `--limit` work as for Jira.
104
+
105
+ ### Copy from search results
106
+
107
+ Add `--copy` to any search to pick results interactively and copy each to
108
+ Markdown (multi-select, needs an interactive terminal). With `--copy`, `--out`
109
+ is a directory that every selected file is written into:
110
+
111
+ ```bash
112
+ atlass jira search --project PROJ --copy --out ./tickets/
113
+ atlass confluence search --space DOCS --copy
114
+ ```
115
+
116
+ Copying continues on failure and reports a summary at the end.
117
+
75
118
  ### Output location
76
119
 
77
120
  By default files are written to the current directory, named after the issue
package/dist/cli.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { input, password } from "@inquirer/prompts";
3
+ import { checkbox, input, password } from "@inquirer/prompts";
4
4
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { Entry } from "@napi-rs/keyring";
8
8
  //#region package.json
9
- var version = "1.0.0";
9
+ var version = "1.1.0";
10
10
  //#endregion
11
11
  //#region src/api/client.ts
12
12
  var AtlassianClient = class {
@@ -22,7 +22,10 @@ var AtlassianClient = class {
22
22
  Authorization: this.authHeader,
23
23
  Accept: accept
24
24
  } });
25
- if (!res.ok) throw httpError(res.status, path);
25
+ if (!res.ok) {
26
+ const body = await res.text().catch(() => "");
27
+ throw httpError(res.status, path, body);
28
+ }
26
29
  return res;
27
30
  }
28
31
  async getJson(path) {
@@ -34,10 +37,24 @@ var AtlassianClient = class {
34
37
  return new Uint8Array(await res.arrayBuffer());
35
38
  }
36
39
  };
37
- function httpError(status, path) {
40
+ function httpError(status, path, body = "") {
38
41
  if (status === 401 || status === 403) return /* @__PURE__ */ new Error("Authentication failed (401/403). Run `atlass auth login` to update your token.");
39
42
  if (status === 404) return /* @__PURE__ */ new Error(`Not found (404): ${path}`);
40
- return /* @__PURE__ */ new Error(`Request failed (${status}): ${path}`);
43
+ if (status === 400) {
44
+ const detail = extractError(body);
45
+ return /* @__PURE__ */ new Error(`Bad request (400): ${detail || path}`);
46
+ }
47
+ const detail = extractError(body);
48
+ return /* @__PURE__ */ new Error(`Request failed (${status}): ${detail || path}`);
49
+ }
50
+ function extractError(body) {
51
+ if (!body) return "";
52
+ try {
53
+ const json = JSON.parse(body);
54
+ if (json.errorMessages?.length) return json.errorMessages.join("; ");
55
+ if (json.message) return json.message;
56
+ } catch {}
57
+ return body.slice(0, 300);
41
58
  }
42
59
  //#endregion
43
60
  //#region src/config.ts
@@ -357,6 +374,25 @@ function uniqueName(name, used) {
357
374
  return candidate;
358
375
  }
359
376
  //#endregion
377
+ //#region src/util/html.ts
378
+ const NAMED = {
379
+ amp: "&",
380
+ lt: "<",
381
+ gt: ">",
382
+ quot: "\"",
383
+ apos: "'",
384
+ nbsp: " "
385
+ };
386
+ function decodeEntities(text) {
387
+ return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
388
+ if (body[0] === "#") {
389
+ const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
390
+ return Number.isFinite(code) && code >= 0 && code <= 1114111 ? String.fromCodePoint(code) : match;
391
+ }
392
+ return NAMED[body.toLowerCase()] ?? match;
393
+ });
394
+ }
395
+ //#endregion
360
396
  //#region src/api/confluence.ts
361
397
  async function fetchPage(client, site, id) {
362
398
  const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
@@ -382,6 +418,34 @@ async function fetchPage(client, site, id) {
382
418
  comments
383
419
  };
384
420
  }
421
+ async function searchPages(client, site, params) {
422
+ const cql = buildCql(params);
423
+ const query = new URLSearchParams({
424
+ cql,
425
+ limit: String(params.limit),
426
+ expand: "space"
427
+ });
428
+ const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
429
+ return {
430
+ pages: results.filter((r) => r.content?.id).map((r) => ({
431
+ id: r.content?.id ?? "",
432
+ space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
433
+ title: decodeEntities(r.content?.title ?? r.title ?? ""),
434
+ url: r.url ? `${site}/wiki${r.url}` : ""
435
+ })),
436
+ hasMore: results.length === params.limit
437
+ };
438
+ }
439
+ function buildCql(params) {
440
+ if (params.cql) return params.cql;
441
+ const clauses = ["type = page"];
442
+ if (params.space) clauses.push(`space = ${cqlValue(params.space)}`);
443
+ if (params.text) clauses.push(`text ~ ${cqlValue(params.text)}`);
444
+ return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
445
+ }
446
+ function cqlValue(value) {
447
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
448
+ }
385
449
  async function fetchSpaceKey(client, spaceId) {
386
450
  if (!spaceId) return "";
387
451
  try {
@@ -519,15 +583,115 @@ function parsePageId(input) {
519
583
  if (fromQuery) return fromQuery[1];
520
584
  return null;
521
585
  }
586
+ function parseLimit(value) {
587
+ if (!value) return 25;
588
+ const n = Number.parseInt(value, 10);
589
+ if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
590
+ return Math.min(n, 100);
591
+ }
592
+ //#endregion
593
+ //#region src/commands/search-run.ts
594
+ const COPY_CONCURRENCY = 5;
595
+ async function runSearch(rows, options, noun, copyOne) {
596
+ if (options.json) {
597
+ console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
598
+ return;
599
+ }
600
+ if (rows.length === 0) {
601
+ console.log(`No matching ${noun.plural}.`);
602
+ return;
603
+ }
604
+ if (options.copy) {
605
+ if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
606
+ await copySelected(rows, noun, copyOne);
607
+ return;
608
+ }
609
+ for (const row of rows) console.log(formatRow(row));
610
+ if (options.hasMore) console.log(`\nShowing first ${options.limit}; refine with flags or raise --limit.`);
611
+ }
612
+ async function copySelected(rows, noun, copyOne) {
613
+ if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
614
+ const selected = await checkbox({
615
+ message: `Select ${noun.plural} to copy:`,
616
+ choices: rows.map((r) => ({
617
+ name: formatRow(r),
618
+ value: r.id
619
+ })),
620
+ pageSize: 20
621
+ });
622
+ if (selected.length === 0) {
623
+ console.log("Nothing selected.");
624
+ return;
625
+ }
626
+ let copied = 0;
627
+ const failures = [];
628
+ const queue = [...selected];
629
+ async function worker() {
630
+ for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
631
+ await copyOne(id);
632
+ copied++;
633
+ } catch (err) {
634
+ failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
635
+ }
636
+ }
637
+ await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
638
+ const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
639
+ if (failures.length === 0) console.log(summary);
640
+ else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
641
+ }
642
+ function formatRow(row) {
643
+ const room = (process.stdout.columns ?? 80) - row.prefix.length - 2;
644
+ const text = room > 0 ? truncate(row.text, room) : "";
645
+ return text ? `${row.prefix} ${text}` : row.prefix;
646
+ }
647
+ function truncate(text, max) {
648
+ const clean = text.replace(/\s+/g, " ").trim();
649
+ return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
650
+ }
522
651
  //#endregion
523
652
  //#region src/commands/confluence.ts
524
653
  async function confluenceCopy(arg, options) {
525
654
  const auth = await requireAuth();
526
655
  const id = await resolveId(arg);
656
+ await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
657
+ }
658
+ async function confluenceSearch(query, options) {
659
+ if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
660
+ if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
661
+ const auth = await requireAuth();
527
662
  const client = new AtlassianClient(auth);
663
+ const limit = parseLimit(options.limit);
664
+ const { pages, hasMore } = await searchPages(client, auth.site, {
665
+ text: query,
666
+ space: options.space,
667
+ cql: options.cql,
668
+ limit
669
+ });
670
+ await runSearch(pages.map((p) => ({
671
+ id: p.id,
672
+ prefix: `${p.id} ${p.space}`,
673
+ text: p.title,
674
+ json: {
675
+ id: p.id,
676
+ space: p.space,
677
+ title: p.title,
678
+ url: p.url
679
+ }
680
+ })), {
681
+ json: options.json,
682
+ copy: options.copy,
683
+ limit,
684
+ hasMore,
685
+ out: options.out
686
+ }, {
687
+ singular: "page",
688
+ plural: "pages"
689
+ }, (id) => copyPage(client, auth.site, id, options.out));
690
+ }
691
+ async function copyPage(client, site, id, out) {
528
692
  console.log(`Fetching page ${id} ...`);
529
- const page = await fetchPage(client, auth.site, id);
530
- const target = resolveOutput(`${page.id}-${slugify(page.title)}`, options.out);
693
+ const page = await fetchPage(client, site, id);
694
+ const target = resolveOutput(`${page.id}-${slugify(page.title)}`, out);
531
695
  const downloaded = await downloadAttachments(client, page.attachments, target.assetsDir, target.assetsDirName);
532
696
  const resolveMedia = mediaResolver(downloaded);
533
697
  const document = joinSections([
@@ -599,6 +763,33 @@ async function fetchIssue(client, site, key) {
599
763
  }))
600
764
  };
601
765
  }
766
+ async function searchIssues(client, site, params) {
767
+ const jql = buildJql(params);
768
+ const query = new URLSearchParams({
769
+ jql,
770
+ maxResults: String(params.limit),
771
+ fields: "summary,status"
772
+ });
773
+ return ((await client.getJson(`/rest/api/3/search/jql?${query.toString()}`)).issues ?? []).map((i) => ({
774
+ key: i.key,
775
+ status: i.fields?.status?.name ?? "",
776
+ summary: decodeEntities(i.fields?.summary ?? ""),
777
+ url: `${site}/browse/${i.key}`
778
+ }));
779
+ }
780
+ function buildJql(params) {
781
+ if (params.jql) return params.jql;
782
+ const clauses = [];
783
+ if (params.project) clauses.push(`project = ${jqlValue(params.project)}`);
784
+ if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlValue(params.assignee)}`);
785
+ if (params.status) clauses.push(`status = ${jqlValue(params.status)}`);
786
+ if (params.text) clauses.push(`text ~ ${jqlValue(params.text)}`);
787
+ if (clauses.length === 0) clauses.push("updated >= -30d");
788
+ return `${clauses.join(" AND ")} ORDER BY updated DESC`;
789
+ }
790
+ function jqlValue(value) {
791
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
792
+ }
602
793
  async function fetchComments(client, key) {
603
794
  return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
604
795
  author: c.author?.displayName ?? "",
@@ -611,10 +802,47 @@ async function fetchComments(client, key) {
611
802
  async function jiraCopy(arg, options) {
612
803
  const auth = await requireAuth();
613
804
  const key = await resolveKey(arg);
805
+ await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
806
+ }
807
+ async function jiraSearch(query, options) {
808
+ if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
809
+ if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
810
+ const auth = await requireAuth();
614
811
  const client = new AtlassianClient(auth);
812
+ const limit = parseLimit(options.limit);
813
+ const issues = await searchIssues(client, auth.site, {
814
+ text: query,
815
+ project: options.project,
816
+ assignee: options.assignee,
817
+ status: options.status,
818
+ jql: options.jql,
819
+ limit
820
+ });
821
+ await runSearch(issues.map((i) => ({
822
+ id: i.key,
823
+ prefix: `${i.key} ${i.status}`,
824
+ text: i.summary,
825
+ json: {
826
+ key: i.key,
827
+ status: i.status,
828
+ summary: i.summary,
829
+ url: i.url
830
+ }
831
+ })), {
832
+ json: options.json,
833
+ copy: options.copy,
834
+ limit,
835
+ hasMore: issues.length === limit,
836
+ out: options.out
837
+ }, {
838
+ singular: "issue",
839
+ plural: "issues"
840
+ }, (key) => copyIssue(client, auth.site, key, options.out));
841
+ }
842
+ async function copyIssue(client, site, key, out) {
615
843
  console.log(`Fetching ${key} ...`);
616
- const issue = await fetchIssue(client, auth.site, key);
617
- const target = resolveOutput(issue.key, options.out);
844
+ const issue = await fetchIssue(client, site, key);
845
+ const target = resolveOutput(issue.key, out);
618
846
  const downloaded = await downloadAttachments(client, issue.attachments, target.assetsDir, target.assetsDirName);
619
847
  const resolveMedia = mediaResolver(downloaded);
620
848
  const document = joinSections([
@@ -659,8 +887,12 @@ const auth = program.command("auth").description("Manage Atlassian credentials")
659
887
  auth.command("login").description("Store site, email, and API token").action(run(login));
660
888
  auth.command("logout").description("Remove stored credentials").action(run(logout));
661
889
  auth.command("status").description("Show the current login").action(run(status));
662
- program.command("jira").description("Jira commands").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));
663
- program.command("confluence").description("Confluence commands").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));
890
+ const jira = program.command("jira").description("Jira commands");
891
+ 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));
892
+ 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));
893
+ const confluence = program.command("confluence").description("Confluence commands");
894
+ 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));
895
+ 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(run(confluenceSearch));
664
896
  program.parseAsync().catch(fail);
665
897
  function run(fn) {
666
898
  return async (...args) => {
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "CLI to copy Jira issues and Confluence pages to Markdown.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dan-livingston/atlass.git"
9
+ },
6
10
  "bin": {
7
11
  "atlass": "./dist/cli.mjs"
8
12
  },