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.
- package/README.md +30 -0
- package/dist/cli.mjs +124 -16
- 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.
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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:
|
|
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:
|
|
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));
|