atlass 1.2.0 → 1.3.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 +34 -0
- package/dist/cli.mjs +110 -9
- 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
|
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.3.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;
|
|
@@ -1245,6 +1279,11 @@ async function fetchIssue(client, site, key) {
|
|
|
1245
1279
|
}))
|
|
1246
1280
|
};
|
|
1247
1281
|
}
|
|
1282
|
+
async function updateIssue(client, key, update) {
|
|
1283
|
+
const fields = { description: update.description };
|
|
1284
|
+
if (update.summary !== void 0) fields["summary"] = update.summary;
|
|
1285
|
+
await client.put(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
|
|
1286
|
+
}
|
|
1248
1287
|
async function searchIssues(client, site, params) {
|
|
1249
1288
|
const jql = buildJql(params);
|
|
1250
1289
|
const query = new URLSearchParams({
|
|
@@ -1286,6 +1325,41 @@ async function jiraCopy(arg, options) {
|
|
|
1286
1325
|
const key = await resolveKey(arg);
|
|
1287
1326
|
await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
|
|
1288
1327
|
}
|
|
1328
|
+
async function jiraUpdate(arg, options) {
|
|
1329
|
+
const src = parseJiraUpdateSource(await readFile(arg ?? await input({
|
|
1330
|
+
message: "Path to the issue Markdown file:",
|
|
1331
|
+
required: true
|
|
1332
|
+
}), "utf8"));
|
|
1333
|
+
const auth = await requireAuth();
|
|
1334
|
+
const client = new AtlassianClient(auth);
|
|
1335
|
+
const issue = await fetchIssue(client, auth.site, src.key);
|
|
1336
|
+
const stale = issue.updated !== src.updated;
|
|
1337
|
+
const { local, external } = classifyImages(src.body);
|
|
1338
|
+
const lossy = findLossyNodes(issue.description, JIRA_LOSSY_LABELS);
|
|
1339
|
+
const newSummary = options.summary && src.bodyTitle ? src.bodyTitle : issue.summary;
|
|
1340
|
+
if (options.dryRun) {
|
|
1341
|
+
printDryRun(src.key, issue.summary, newSummary, stale, external.length, local, lossy);
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
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(", ")}`);
|
|
1345
|
+
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.`);
|
|
1346
|
+
if (lossy.size > 0 && !options.force) {
|
|
1347
|
+
if (!await confirm({
|
|
1348
|
+
message: `This issue's description contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
|
|
1349
|
+
default: false
|
|
1350
|
+
})) {
|
|
1351
|
+
console.log("Aborted.");
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
const description = markdownToAdf(src.body);
|
|
1356
|
+
if (!description.content || description.content.length === 0) throw new Error("Refusing to update: the converted description is empty.");
|
|
1357
|
+
await updateIssue(client, src.key, {
|
|
1358
|
+
description,
|
|
1359
|
+
summary: options.summary && newSummary !== issue.summary ? newSummary : void 0
|
|
1360
|
+
});
|
|
1361
|
+
console.log(`Updated ${src.key}.`);
|
|
1362
|
+
}
|
|
1289
1363
|
async function jiraSearch(query, options) {
|
|
1290
1364
|
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
1365
|
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
@@ -1361,6 +1435,32 @@ function report(filePath, assetCount) {
|
|
|
1361
1435
|
const suffix = assetCount > 0 ? ` (+${assetCount} attachment${assetCount === 1 ? "" : "s"})` : "";
|
|
1362
1436
|
console.log(`Wrote ${filePath}${suffix}`);
|
|
1363
1437
|
}
|
|
1438
|
+
function classifyImages(md) {
|
|
1439
|
+
const local = [];
|
|
1440
|
+
const external = [];
|
|
1441
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1442
|
+
marked.walkTokens(marked.lexer(md), (token) => {
|
|
1443
|
+
if (token.type !== "image") return;
|
|
1444
|
+
const href = token.href;
|
|
1445
|
+
if (seen.has(href)) return;
|
|
1446
|
+
seen.add(href);
|
|
1447
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href)) external.push(href);
|
|
1448
|
+
else local.push(href);
|
|
1449
|
+
});
|
|
1450
|
+
return {
|
|
1451
|
+
local,
|
|
1452
|
+
external
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
function printDryRun(key, currentSummary, newSummary, stale, externalImages, localImages, lossy) {
|
|
1456
|
+
console.log(`Dry run for ${key} "${currentSummary}"`);
|
|
1457
|
+
if (newSummary !== currentSummary) console.log(` summary: "${currentSummary}" -> "${newSummary}"`);
|
|
1458
|
+
if (externalImages > 0) console.log(` images: ${externalImages} external`);
|
|
1459
|
+
if (localImages.length > 0) console.log(` blocked: ${localImages.length} local image(s) not supported (edit text only)`);
|
|
1460
|
+
if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
|
|
1461
|
+
if (stale) console.log(` stale: server changed since copy (would refuse without --force)`);
|
|
1462
|
+
console.log(" nothing was written (dry run)");
|
|
1463
|
+
}
|
|
1364
1464
|
//#endregion
|
|
1365
1465
|
//#region src/cli.ts
|
|
1366
1466
|
const program = new Command();
|
|
@@ -1371,6 +1471,7 @@ auth.command("logout").description("Remove stored credentials").action(run(logou
|
|
|
1371
1471
|
auth.command("status").description("Show the current login").action(run(status));
|
|
1372
1472
|
const jira = program.command("jira").description("Jira commands");
|
|
1373
1473
|
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));
|
|
1474
|
+
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
1475
|
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
1476
|
const confluence = program.command("confluence").description("Confluence commands");
|
|
1376
1477
|
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));
|