atlass 1.3.0 → 1.5.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 +30 -0
  2. package/dist/cli.mjs +124 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -163,6 +163,36 @@ filters. Prints one issue per line (`KEY status summary`); use `--json` for
163
163
  machine output. Only the first `--limit` results are shown (default 25, max
164
164
  100).
165
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
+
179
+ ### List Jira statuses
180
+
181
+ ```bash
182
+ atlass jira statuses # every status on the site
183
+ atlass jira statuses progress # filter by name
184
+ atlass jira statuses --project PROJ # statuses used by one project
185
+ atlass jira statuses --json # machine output
186
+ ```
187
+
188
+ A discovery aid for the `--status` filter above. Without `--project` it lists
189
+ every status on the site; with `--project` it lists the statuses that project's
190
+ issue types use (flattened to one list). An optional query filters by name
191
+ (case-insensitive substring). Statuses are collapsed by name and category, then
192
+ ordered by workflow lifecycle (To Do, then In Progress, then Done) and name.
193
+ Prints an aligned `Name Category` list; `--json` emits
194
+ `{ name, id, category, categoryKey }` per status.
195
+
166
196
  ### Search Confluence pages
167
197
 
168
198
  ```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.3.0";
11
+ var version = "1.5.0";
12
12
  //#endregion
13
13
  //#region src/api/client.ts
14
14
  var AtlassianClient = class {
@@ -76,20 +76,21 @@ var AtlassianClient = class {
76
76
  return new Uint8Array(await res.arrayBuffer());
77
77
  }
78
78
  };
79
- function httpError(status, path, body = "") {
80
- if (status === 401 || status === 403) return /* @__PURE__ */ new Error("Authentication failed (401/403). Run `atlass auth login` to update your token.");
81
- if (status === 404) return /* @__PURE__ */ new Error(`Not found (404): ${path}`);
82
- if (status === 409) {
83
- const detail = extractError(body);
84
- return /* @__PURE__ */ new Error(`Conflict (409): ${detail || "the page changed on the server"}`);
85
- }
86
- if (status === 413) return /* @__PURE__ */ new Error("Payload too large (413): the page or an attachment exceeds the size limit.");
87
- if (status === 400) {
88
- const detail = extractError(body);
89
- return /* @__PURE__ */ new Error(`Bad request (400): ${detail || path}`);
79
+ var HttpError = class extends Error {
80
+ status;
81
+ constructor(status, message) {
82
+ super(message);
83
+ this.status = status;
84
+ this.name = "HttpError";
90
85
  }
91
- const detail = extractError(body);
92
- return /* @__PURE__ */ new Error(`Request failed (${status}): ${detail || path}`);
86
+ };
87
+ function httpError(status, path, body = "") {
88
+ if (status === 401 || status === 403) return new HttpError(status, "Authentication failed (401/403). Run `atlass auth login` to update your token.");
89
+ if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
90
+ if (status === 409) return new HttpError(status, `Conflict (409): ${extractError(body) || "the page changed on the server"}`);
91
+ if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
92
+ if (status === 400) return new HttpError(status, `Bad request (400): ${extractError(body) || path}`);
93
+ return new HttpError(status, `Request failed (${status}): ${extractError(body) || path}`);
93
94
  }
94
95
  function extractError(body) {
95
96
  if (!body) return "";
@@ -1254,13 +1255,16 @@ const FIELDS = [
1254
1255
  "updated",
1255
1256
  "attachment"
1256
1257
  ].join(",");
1258
+ function browseUrl(site, key) {
1259
+ return `${site}/browse/${key}`;
1260
+ }
1257
1261
  async function fetchIssue(client, site, key) {
1258
1262
  const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
1259
1263
  const comments = await fetchComments(client, key);
1260
1264
  const f = issue.fields;
1261
1265
  return {
1262
1266
  key: issue.key,
1263
- url: `${site}/browse/${issue.key}`,
1267
+ url: browseUrl(site, issue.key),
1264
1268
  summary: f.summary ?? "",
1265
1269
  type: f.issuetype?.name ?? "",
1266
1270
  status: f.status?.name ?? "",
@@ -1295,7 +1299,7 @@ async function searchIssues(client, site, params) {
1295
1299
  key: i.key,
1296
1300
  status: i.fields?.status?.name ?? "",
1297
1301
  summary: decodeEntities(i.fields?.summary ?? ""),
1298
- url: `${site}/browse/${i.key}`
1302
+ url: browseUrl(site, i.key)
1299
1303
  }));
1300
1304
  }
1301
1305
  function buildJql(params) {
@@ -1308,6 +1312,71 @@ function buildJql(params) {
1308
1312
  if (clauses.length === 0) clauses.push("updated >= -30d");
1309
1313
  return `${clauses.join(" AND ")} ORDER BY updated DESC`;
1310
1314
  }
1315
+ const PROJECT_PAGE_SIZE = 50;
1316
+ async function listProjects(client, site, query) {
1317
+ const projects = [];
1318
+ for (let startAt = 0;;) {
1319
+ const res = await client.getJson(`/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`);
1320
+ const values = res.values ?? [];
1321
+ for (const p of values) projects.push({
1322
+ key: p.key,
1323
+ name: p.name,
1324
+ id: p.id,
1325
+ type: p.projectTypeKey ?? "",
1326
+ url: browseUrl(site, p.key)
1327
+ });
1328
+ if (res.isLast || values.length === 0) break;
1329
+ startAt += values.length;
1330
+ }
1331
+ return projects;
1332
+ }
1333
+ function projectSearchQuery(query, startAt) {
1334
+ const params = new URLSearchParams({
1335
+ orderBy: "key",
1336
+ maxResults: String(PROJECT_PAGE_SIZE),
1337
+ startAt: String(startAt)
1338
+ });
1339
+ if (query) params.set("query", query);
1340
+ return params.toString();
1341
+ }
1342
+ async function listStatuses(client, project) {
1343
+ return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await client.getJson("/rest/api/3/status")).map(toStatusSummary));
1344
+ }
1345
+ async function fetchProjectStatuses(client, project) {
1346
+ let groups;
1347
+ try {
1348
+ groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
1349
+ } catch (err) {
1350
+ if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
1351
+ throw err;
1352
+ }
1353
+ return groups.flatMap((g) => g.statuses ?? []);
1354
+ }
1355
+ function toStatusSummary(s) {
1356
+ return {
1357
+ name: s.name,
1358
+ id: s.id,
1359
+ category: s.statusCategory?.name ?? "",
1360
+ categoryKey: s.statusCategory?.key ?? ""
1361
+ };
1362
+ }
1363
+ const CATEGORY_ORDER = {
1364
+ new: 0,
1365
+ indeterminate: 1,
1366
+ done: 2
1367
+ };
1368
+ const CATEGORY_LAST = Object.keys(CATEGORY_ORDER).length;
1369
+ function dedupeAndSortStatuses(statuses) {
1370
+ const byNameCategory = /* @__PURE__ */ new Map();
1371
+ for (const s of statuses) {
1372
+ const key = `${s.name}\0${s.categoryKey}`;
1373
+ if (!byNameCategory.has(key)) byNameCategory.set(key, s);
1374
+ }
1375
+ return [...byNameCategory.values()].sort((a, b) => {
1376
+ const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? CATEGORY_LAST;
1377
+ return rank(a) - rank(b) || a.name.localeCompare(b.name);
1378
+ });
1379
+ }
1311
1380
  function jqlValue(value) {
1312
1381
  return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1313
1382
  }
@@ -1320,6 +1389,43 @@ async function fetchComments(client, key) {
1320
1389
  }
1321
1390
  //#endregion
1322
1391
  //#region src/commands/jira.ts
1392
+ async function jiraProjects(query, options) {
1393
+ const auth = await requireAuth();
1394
+ const projects = await listProjects(new AtlassianClient(auth), auth.site, query);
1395
+ if (options.json) {
1396
+ console.log(JSON.stringify(projects, null, 2));
1397
+ return;
1398
+ }
1399
+ if (projects.length === 0) {
1400
+ console.log("No matching projects.");
1401
+ return;
1402
+ }
1403
+ for (const line of formatProjectRows(projects)) console.log(line);
1404
+ }
1405
+ function formatProjectRows(projects) {
1406
+ const width = Math.max(...projects.map((p) => p.key.length));
1407
+ return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
1408
+ }
1409
+ async function jiraStatuses(query, options) {
1410
+ let statuses = await listStatuses(new AtlassianClient(await requireAuth()), options.project);
1411
+ if (query) {
1412
+ const needle = query.toLowerCase();
1413
+ statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
1414
+ }
1415
+ if (options.json) {
1416
+ console.log(JSON.stringify(statuses, null, 2));
1417
+ return;
1418
+ }
1419
+ if (statuses.length === 0) {
1420
+ console.log("No matching statuses.");
1421
+ return;
1422
+ }
1423
+ for (const line of formatStatusRows(statuses)) console.log(line);
1424
+ }
1425
+ function formatStatusRows(statuses) {
1426
+ const width = Math.max(...statuses.map((s) => s.name.length));
1427
+ return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
1428
+ }
1323
1429
  async function jiraCopy(arg, options) {
1324
1430
  const auth = await requireAuth();
1325
1431
  const key = await resolveKey(arg);
@@ -1470,6 +1576,8 @@ auth.command("login").description("Store site, email, and API token").action(run
1470
1576
  auth.command("logout").description("Remove stored credentials").action(run(logout));
1471
1577
  auth.command("status").description("Show the current login").action(run(status));
1472
1578
  const jira = program.command("jira").description("Jira commands");
1579
+ jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(run(jiraProjects));
1580
+ jira.command("statuses [query]").description("List statuses (optionally filtered by name, scoped with --project)").option("-p, --project <key>", "limit to statuses used by a project").option("--json", "output results as JSON").action(run(jiraStatuses));
1473
1581
  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
1582
  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));
1475
1583
  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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "1.3.0",
3
+ "version": "1.5.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": {