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.
Files changed (3) hide show
  1. package/README.md +34 -0
  2. package/dist/cli.mjs +110 -9
  3. 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.2.0";
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 match = content.match(/^---\n([\s\S]*?)\n---\n?/);
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
- function findLossyNodes(node) {
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 = LOSSY_LABELS[n.type];
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "1.2.0",
3
+ "version": "1.3.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": {