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.
Files changed (3) hide show
  1. package/README.md +47 -0
  2. package/dist/cli.mjs +160 -11
  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
@@ -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.2.0";
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 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;
@@ -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: `${site}/browse/${issue.key}`,
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: `${site}/browse/${i.key}`
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "1.2.0",
3
+ "version": "1.4.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": {