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.
- package/README.md +43 -0
- package/dist/cli.mjs +243 -11
- 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.
|
|
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)
|
|
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
|
-
|
|
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,
|
|
530
|
-
const target = resolveOutput(`${page.id}-${slugify(page.title)}`,
|
|
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,
|
|
617
|
-
const target = resolveOutput(issue.key,
|
|
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")
|
|
663
|
-
|
|
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.
|
|
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
|
},
|