atlass 1.2.0 → 1.4.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 +47 -0
- package/dist/cli.mjs +160 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,6 +63,40 @@ atlass jira copy # prompts for the key or URL
|
|
|
63
63
|
Accepts an issue key or any URL containing one. Writes `PROJ-123.md` to the
|
|
64
64
|
current directory.
|
|
65
65
|
|
|
66
|
+
### Update a Jira issue
|
|
67
|
+
|
|
68
|
+
Copy an issue, edit the Markdown, then push the description back:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
atlass jira update PROJ-123.md
|
|
72
|
+
atlass jira update # prompts for the file path
|
|
73
|
+
atlass jira update file.md --dry-run # show what would change, write nothing
|
|
74
|
+
atlass jira update file.md --summary # also push the H1 as the issue summary
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The issue key comes from the file's frontmatter. The body is everything between
|
|
78
|
+
the H1 and the `## Comments` section; the frontmatter, the H1, and the
|
|
79
|
+
`## Comments` / `## Attachments` sections are not sent.
|
|
80
|
+
|
|
81
|
+
Only the description is updated by default. Pass `--summary` to also push the H1
|
|
82
|
+
as the new issue summary.
|
|
83
|
+
|
|
84
|
+
Notes and safety:
|
|
85
|
+
|
|
86
|
+
- The body is converted from Markdown to ADF. Only the standard constructs the
|
|
87
|
+
copy produces round-trip (headings, lists, task lists, code, blockquotes,
|
|
88
|
+
tables, rules, inline marks, links). Jira-specific content (panels, macros)
|
|
89
|
+
was flattened to plain Markdown on copy and cannot be rebuilt. When the live
|
|
90
|
+
description still contains such content, the update warns and asks for
|
|
91
|
+
confirmation before overwriting.
|
|
92
|
+
- Jira has no page-style version number, so staleness is checked against the
|
|
93
|
+
frontmatter `updated` timestamp. If the issue changed since you copied it, the
|
|
94
|
+
update aborts so you can re-copy. `--force` overrides this and the data-loss
|
|
95
|
+
confirmation.
|
|
96
|
+
- Image changes are not supported yet. External image URLs are kept as external
|
|
97
|
+
media, but a local image reference aborts the update (edit text only), and a
|
|
98
|
+
server-side image in the description is reported before it would be removed.
|
|
99
|
+
|
|
66
100
|
### Copy a Confluence page
|
|
67
101
|
|
|
68
102
|
```bash
|
|
@@ -129,6 +163,19 @@ filters. Prints one issue per line (`KEY status summary`); use `--json` for
|
|
|
129
163
|
machine output. Only the first `--limit` results are shown (default 25, max
|
|
130
164
|
100).
|
|
131
165
|
|
|
166
|
+
### List Jira projects
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
atlass jira projects # every project you can browse
|
|
170
|
+
atlass jira projects pay # filter by key or name
|
|
171
|
+
atlass jira projects --json # machine output
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A discovery aid for the `--project` filter above: it fetches every project
|
|
175
|
+
(paginated, ordered by key) and prints one per line as an aligned `KEY Name`
|
|
176
|
+
list. An optional query filters by key or name server-side. `--json` emits
|
|
177
|
+
`{ key, name, id, type, url }` per project.
|
|
178
|
+
|
|
132
179
|
### Search Confluence pages
|
|
133
180
|
|
|
134
181
|
```bash
|
package/dist/cli.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { Entry } from "@napi-rs/keyring";
|
|
|
8
8
|
import { marked } from "marked";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
//#region package.json
|
|
11
|
-
var version = "1.
|
|
11
|
+
var version = "1.4.0";
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region src/api/client.ts
|
|
14
14
|
var AtlassianClient = class {
|
|
@@ -47,6 +47,16 @@ var AtlassianClient = class {
|
|
|
47
47
|
body: JSON.stringify(body)
|
|
48
48
|
})).json();
|
|
49
49
|
}
|
|
50
|
+
async put(path, body) {
|
|
51
|
+
await this.request(path, {
|
|
52
|
+
method: "PUT",
|
|
53
|
+
headers: {
|
|
54
|
+
Accept: "application/json",
|
|
55
|
+
"Content-Type": "application/json"
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(body)
|
|
58
|
+
});
|
|
59
|
+
}
|
|
50
60
|
async postMultipart(path, filename, bytes) {
|
|
51
61
|
const form = new FormData();
|
|
52
62
|
const blob = new Blob([bytes]);
|
|
@@ -819,14 +829,11 @@ function joinSections(sections) {
|
|
|
819
829
|
//#endregion
|
|
820
830
|
//#region src/markdown/update-source.ts
|
|
821
831
|
function parseUpdateSource(content) {
|
|
822
|
-
const
|
|
823
|
-
if (!match) throw new Error("Not an atlass page file: no YAML frontmatter found.");
|
|
824
|
-
const fields = parseFrontmatter(match[1] ?? "");
|
|
832
|
+
const { fields, bodyTitle, body } = splitFile(content);
|
|
825
833
|
const id = fields["id"];
|
|
826
834
|
if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
|
|
827
835
|
const version = Number(fields["version"]);
|
|
828
836
|
if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
|
|
829
|
-
const { bodyTitle, body } = splitBody(content.slice(match[0].length), fields["title"] ?? "");
|
|
830
837
|
return {
|
|
831
838
|
id,
|
|
832
839
|
version,
|
|
@@ -835,6 +842,28 @@ function parseUpdateSource(content) {
|
|
|
835
842
|
body
|
|
836
843
|
};
|
|
837
844
|
}
|
|
845
|
+
function parseJiraUpdateSource(content) {
|
|
846
|
+
const { fields, bodyTitle, body } = splitFile(content);
|
|
847
|
+
const key = fields["key"];
|
|
848
|
+
if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
|
|
849
|
+
return {
|
|
850
|
+
key,
|
|
851
|
+
updated: fields["updated"] ?? "",
|
|
852
|
+
bodyTitle,
|
|
853
|
+
body
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function splitFile(content) {
|
|
857
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
858
|
+
if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
|
|
859
|
+
const fields = parseFrontmatter(match[1] ?? "");
|
|
860
|
+
const { bodyTitle, body } = splitBody(content.slice(match[0].length), fields["title"] ?? "");
|
|
861
|
+
return {
|
|
862
|
+
fields,
|
|
863
|
+
bodyTitle,
|
|
864
|
+
body
|
|
865
|
+
};
|
|
866
|
+
}
|
|
838
867
|
function parseFrontmatter(block) {
|
|
839
868
|
const out = {};
|
|
840
869
|
for (const line of block.split("\n")) {
|
|
@@ -881,10 +910,15 @@ const LOSSY_LABELS = {
|
|
|
881
910
|
bodiedExtension: "macro",
|
|
882
911
|
inlineExtension: "macro"
|
|
883
912
|
};
|
|
884
|
-
|
|
913
|
+
const JIRA_LOSSY_LABELS = {
|
|
914
|
+
...LOSSY_LABELS,
|
|
915
|
+
media: "image",
|
|
916
|
+
mediaInline: "image"
|
|
917
|
+
};
|
|
918
|
+
function findLossyNodes(node, labels = LOSSY_LABELS) {
|
|
885
919
|
const counts = /* @__PURE__ */ new Map();
|
|
886
920
|
const visit = (n) => {
|
|
887
|
-
const label =
|
|
921
|
+
const label = labels[n.type];
|
|
888
922
|
if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
889
923
|
for (const child of n.content ?? []) visit(child);
|
|
890
924
|
};
|
|
@@ -1015,7 +1049,7 @@ async function confluenceUpdate(arg, options) {
|
|
|
1015
1049
|
const nextVersion = state.version + 1;
|
|
1016
1050
|
const newTitle = options.title && src.bodyTitle ? src.bodyTitle : state.title;
|
|
1017
1051
|
if (options.dryRun) {
|
|
1018
|
-
printDryRun(src.id, state.title, newTitle, state.version, nextVersion, lossy, plan);
|
|
1052
|
+
printDryRun$1(src.id, state.title, newTitle, state.version, nextVersion, lossy, plan);
|
|
1019
1053
|
return;
|
|
1020
1054
|
}
|
|
1021
1055
|
if (lossy.size > 0 && !options.force) {
|
|
@@ -1192,7 +1226,7 @@ function fileMedia(fileId, collection, alt) {
|
|
|
1192
1226
|
}]
|
|
1193
1227
|
};
|
|
1194
1228
|
}
|
|
1195
|
-
function printDryRun(id, currentTitle, newTitle, currentVersion, nextVersion, lossy, plan) {
|
|
1229
|
+
function printDryRun$1(id, currentTitle, newTitle, currentVersion, nextVersion, lossy, plan) {
|
|
1196
1230
|
const entries = [...plan.values()];
|
|
1197
1231
|
const added = entries.filter((e) => e.kind === "upload" && !e.existed).length;
|
|
1198
1232
|
const changed = entries.filter((e) => e.kind === "upload" && e.existed).length;
|
|
@@ -1220,13 +1254,16 @@ const FIELDS = [
|
|
|
1220
1254
|
"updated",
|
|
1221
1255
|
"attachment"
|
|
1222
1256
|
].join(",");
|
|
1257
|
+
function browseUrl(site, key) {
|
|
1258
|
+
return `${site}/browse/${key}`;
|
|
1259
|
+
}
|
|
1223
1260
|
async function fetchIssue(client, site, key) {
|
|
1224
1261
|
const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
|
|
1225
1262
|
const comments = await fetchComments(client, key);
|
|
1226
1263
|
const f = issue.fields;
|
|
1227
1264
|
return {
|
|
1228
1265
|
key: issue.key,
|
|
1229
|
-
url:
|
|
1266
|
+
url: browseUrl(site, issue.key),
|
|
1230
1267
|
summary: f.summary ?? "",
|
|
1231
1268
|
type: f.issuetype?.name ?? "",
|
|
1232
1269
|
status: f.status?.name ?? "",
|
|
@@ -1245,6 +1282,11 @@ async function fetchIssue(client, site, key) {
|
|
|
1245
1282
|
}))
|
|
1246
1283
|
};
|
|
1247
1284
|
}
|
|
1285
|
+
async function updateIssue(client, key, update) {
|
|
1286
|
+
const fields = { description: update.description };
|
|
1287
|
+
if (update.summary !== void 0) fields["summary"] = update.summary;
|
|
1288
|
+
await client.put(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
|
|
1289
|
+
}
|
|
1248
1290
|
async function searchIssues(client, site, params) {
|
|
1249
1291
|
const jql = buildJql(params);
|
|
1250
1292
|
const query = new URLSearchParams({
|
|
@@ -1256,7 +1298,7 @@ async function searchIssues(client, site, params) {
|
|
|
1256
1298
|
key: i.key,
|
|
1257
1299
|
status: i.fields?.status?.name ?? "",
|
|
1258
1300
|
summary: decodeEntities(i.fields?.summary ?? ""),
|
|
1259
|
-
url:
|
|
1301
|
+
url: browseUrl(site, i.key)
|
|
1260
1302
|
}));
|
|
1261
1303
|
}
|
|
1262
1304
|
function buildJql(params) {
|
|
@@ -1269,6 +1311,33 @@ function buildJql(params) {
|
|
|
1269
1311
|
if (clauses.length === 0) clauses.push("updated >= -30d");
|
|
1270
1312
|
return `${clauses.join(" AND ")} ORDER BY updated DESC`;
|
|
1271
1313
|
}
|
|
1314
|
+
const PROJECT_PAGE_SIZE = 50;
|
|
1315
|
+
async function listProjects(client, site, query) {
|
|
1316
|
+
const projects = [];
|
|
1317
|
+
for (let startAt = 0;;) {
|
|
1318
|
+
const res = await client.getJson(`/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`);
|
|
1319
|
+
const values = res.values ?? [];
|
|
1320
|
+
for (const p of values) projects.push({
|
|
1321
|
+
key: p.key,
|
|
1322
|
+
name: p.name,
|
|
1323
|
+
id: p.id,
|
|
1324
|
+
type: p.projectTypeKey ?? "",
|
|
1325
|
+
url: browseUrl(site, p.key)
|
|
1326
|
+
});
|
|
1327
|
+
if (res.isLast || values.length === 0) break;
|
|
1328
|
+
startAt += values.length;
|
|
1329
|
+
}
|
|
1330
|
+
return projects;
|
|
1331
|
+
}
|
|
1332
|
+
function projectSearchQuery(query, startAt) {
|
|
1333
|
+
const params = new URLSearchParams({
|
|
1334
|
+
orderBy: "key",
|
|
1335
|
+
maxResults: String(PROJECT_PAGE_SIZE),
|
|
1336
|
+
startAt: String(startAt)
|
|
1337
|
+
});
|
|
1338
|
+
if (query) params.set("query", query);
|
|
1339
|
+
return params.toString();
|
|
1340
|
+
}
|
|
1272
1341
|
function jqlValue(value) {
|
|
1273
1342
|
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
1274
1343
|
}
|
|
@@ -1281,11 +1350,63 @@ async function fetchComments(client, key) {
|
|
|
1281
1350
|
}
|
|
1282
1351
|
//#endregion
|
|
1283
1352
|
//#region src/commands/jira.ts
|
|
1353
|
+
async function jiraProjects(query, options) {
|
|
1354
|
+
const auth = await requireAuth();
|
|
1355
|
+
const projects = await listProjects(new AtlassianClient(auth), auth.site, query);
|
|
1356
|
+
if (options.json) {
|
|
1357
|
+
console.log(JSON.stringify(projects, null, 2));
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (projects.length === 0) {
|
|
1361
|
+
console.log("No matching projects.");
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
for (const line of formatProjectRows(projects)) console.log(line);
|
|
1365
|
+
}
|
|
1366
|
+
function formatProjectRows(projects) {
|
|
1367
|
+
const width = Math.max(...projects.map((p) => p.key.length));
|
|
1368
|
+
return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
|
|
1369
|
+
}
|
|
1284
1370
|
async function jiraCopy(arg, options) {
|
|
1285
1371
|
const auth = await requireAuth();
|
|
1286
1372
|
const key = await resolveKey(arg);
|
|
1287
1373
|
await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
|
|
1288
1374
|
}
|
|
1375
|
+
async function jiraUpdate(arg, options) {
|
|
1376
|
+
const src = parseJiraUpdateSource(await readFile(arg ?? await input({
|
|
1377
|
+
message: "Path to the issue Markdown file:",
|
|
1378
|
+
required: true
|
|
1379
|
+
}), "utf8"));
|
|
1380
|
+
const auth = await requireAuth();
|
|
1381
|
+
const client = new AtlassianClient(auth);
|
|
1382
|
+
const issue = await fetchIssue(client, auth.site, src.key);
|
|
1383
|
+
const stale = issue.updated !== src.updated;
|
|
1384
|
+
const { local, external } = classifyImages(src.body);
|
|
1385
|
+
const lossy = findLossyNodes(issue.description, JIRA_LOSSY_LABELS);
|
|
1386
|
+
const newSummary = options.summary && src.bodyTitle ? src.bodyTitle : issue.summary;
|
|
1387
|
+
if (options.dryRun) {
|
|
1388
|
+
printDryRun(src.key, issue.summary, newSummary, stale, external.length, local, lossy);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (local.length > 0) throw new Error(`jira update does not support image changes yet. Remove local image reference(s) or edit text only: ${local.join(", ")}`);
|
|
1392
|
+
if (stale && !options.force) throw new Error(`Issue changed on the server since you copied it (local ${src.updated || "unknown"}, server ${issue.updated}). Re-copy the issue or pass --force.`);
|
|
1393
|
+
if (lossy.size > 0 && !options.force) {
|
|
1394
|
+
if (!await confirm({
|
|
1395
|
+
message: `This issue's description contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
|
|
1396
|
+
default: false
|
|
1397
|
+
})) {
|
|
1398
|
+
console.log("Aborted.");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
const description = markdownToAdf(src.body);
|
|
1403
|
+
if (!description.content || description.content.length === 0) throw new Error("Refusing to update: the converted description is empty.");
|
|
1404
|
+
await updateIssue(client, src.key, {
|
|
1405
|
+
description,
|
|
1406
|
+
summary: options.summary && newSummary !== issue.summary ? newSummary : void 0
|
|
1407
|
+
});
|
|
1408
|
+
console.log(`Updated ${src.key}.`);
|
|
1409
|
+
}
|
|
1289
1410
|
async function jiraSearch(query, options) {
|
|
1290
1411
|
if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
|
|
1291
1412
|
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
@@ -1361,6 +1482,32 @@ function report(filePath, assetCount) {
|
|
|
1361
1482
|
const suffix = assetCount > 0 ? ` (+${assetCount} attachment${assetCount === 1 ? "" : "s"})` : "";
|
|
1362
1483
|
console.log(`Wrote ${filePath}${suffix}`);
|
|
1363
1484
|
}
|
|
1485
|
+
function classifyImages(md) {
|
|
1486
|
+
const local = [];
|
|
1487
|
+
const external = [];
|
|
1488
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1489
|
+
marked.walkTokens(marked.lexer(md), (token) => {
|
|
1490
|
+
if (token.type !== "image") return;
|
|
1491
|
+
const href = token.href;
|
|
1492
|
+
if (seen.has(href)) return;
|
|
1493
|
+
seen.add(href);
|
|
1494
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href)) external.push(href);
|
|
1495
|
+
else local.push(href);
|
|
1496
|
+
});
|
|
1497
|
+
return {
|
|
1498
|
+
local,
|
|
1499
|
+
external
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
function printDryRun(key, currentSummary, newSummary, stale, externalImages, localImages, lossy) {
|
|
1503
|
+
console.log(`Dry run for ${key} "${currentSummary}"`);
|
|
1504
|
+
if (newSummary !== currentSummary) console.log(` summary: "${currentSummary}" -> "${newSummary}"`);
|
|
1505
|
+
if (externalImages > 0) console.log(` images: ${externalImages} external`);
|
|
1506
|
+
if (localImages.length > 0) console.log(` blocked: ${localImages.length} local image(s) not supported (edit text only)`);
|
|
1507
|
+
if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
|
|
1508
|
+
if (stale) console.log(` stale: server changed since copy (would refuse without --force)`);
|
|
1509
|
+
console.log(" nothing was written (dry run)");
|
|
1510
|
+
}
|
|
1364
1511
|
//#endregion
|
|
1365
1512
|
//#region src/cli.ts
|
|
1366
1513
|
const program = new Command();
|
|
@@ -1370,7 +1517,9 @@ auth.command("login").description("Store site, email, and API token").action(run
|
|
|
1370
1517
|
auth.command("logout").description("Remove stored credentials").action(run(logout));
|
|
1371
1518
|
auth.command("status").description("Show the current login").action(run(status));
|
|
1372
1519
|
const jira = program.command("jira").description("Jira commands");
|
|
1520
|
+
jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(run(jiraProjects));
|
|
1373
1521
|
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));
|
|
1522
|
+
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));
|
|
1374
1523
|
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));
|
|
1375
1524
|
const confluence = program.command("confluence").description("Confluence commands");
|
|
1376
1525
|
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));
|