@stravica-ai/digital-operator-pa 0.1.39 → 0.1.41

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 (92) hide show
  1. package/README.md +12 -2
  2. package/bin/confluence +9 -0
  3. package/bin/confluence-fetch +9 -0
  4. package/bin/jira +9 -0
  5. package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
  6. package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
  7. package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
  8. package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
  9. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
  10. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
  11. package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
  12. package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
  13. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
  14. package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
  15. package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
  16. package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
  17. package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
  18. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
  19. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
  20. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
  21. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
  22. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
  23. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
  24. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
  25. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
  26. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
  27. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
  28. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
  29. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
  30. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
  31. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
  32. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
  33. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
  34. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
  35. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
  36. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
  37. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
  38. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
  39. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
  40. package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
  41. package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
  42. package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
  43. package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
  44. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
  45. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
  46. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
  47. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
  48. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
  49. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
  50. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
  51. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
  52. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
  53. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
  54. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
  55. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
  56. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
  57. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
  58. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
  59. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
  60. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
  61. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
  62. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
  63. package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
  64. package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
  65. package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
  66. package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
  67. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
  68. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
  69. package/dist/engineering-pa-scaffold/README.md +32 -1
  70. package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
  71. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
  72. package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
  73. package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
  74. package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
  75. package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
  76. package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
  77. package/dist/engineering-pa-types-engineering/index.mjs +14 -6
  78. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
  79. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
  80. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
  81. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
  82. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
  83. package/dist/engineering-pa-types-engineering/package.json +2 -2
  84. package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
  85. package/dist/engineering-pa-workspace/app/home.html +7 -7
  86. package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
  87. package/dist/engineering-pa-workspace/src/api.mjs +1 -1
  88. package/dist/engineering-pa-workspace/src/server.mjs +1 -1
  89. package/docs/installing.md +78 -0
  90. package/docs/onboarding-solo.md +162 -0
  91. package/docs/what-it-can-do.md +85 -0
  92. package/package.json +6 -2
@@ -0,0 +1,380 @@
1
+ // worklog.mjs - Log or list worklogs on a Jira issue.
2
+ //
3
+ // Usage:
4
+ // jira worklog <KEY> --time <dur> e.g. --time 2d4h30m
5
+ // jira worklog <KEY> --from <iso> [--to <iso>] elapsed = to (default now) - from
6
+ // jira worklog <KEY> --time <dur> --comment "..."
7
+ // jira worklog <KEY> --list recent worklogs for the issue
8
+ // jira worklog <KEY> --time 1h --dry-run print the request, send nothing
9
+ //
10
+ // Time semantics:
11
+ // Logged time represents REAL wall-clock elapsed time - agent time, human
12
+ // time, overnight sleep time all included. `--from/--to` is the primary
13
+ // ergonomic path; record ticket-start time, log the true delta at close.
14
+ //
15
+ // Duration format is Jira-style tokens: "1d 4h 30m" or "1d4h30m". `d`
16
+ // means a literal 24-hour day (NOT Jira's 8-hour workday). The command
17
+ // converts everything to seconds and posts `timeSpentSeconds` so Jira's
18
+ // workday interpretation cannot silently rescale the number.
19
+ //
20
+ // Exit codes:
21
+ // 0 - success
22
+ // 1 - usage error (bad args, unparseable duration, from > to)
23
+ // 2 - API error (issue not found, permission denied)
24
+
25
+ import { createClient, JiraError } from '../client.mjs';
26
+ import { textToAdf, adfToText } from '../adf.mjs';
27
+ import { bold, dim, green } from '../format.mjs';
28
+ import { notifyWorkspaceAsync } from '../invalidate.mjs';
29
+
30
+ const HELP = `jira worklog - Log or list worklogs on a Jira issue
31
+
32
+ Usage:
33
+ jira worklog <KEY> --time <dur> Log elapsed time
34
+ jira worklog <KEY> --from <iso> [--to <iso>] Log delta between two timestamps
35
+ jira worklog <KEY> --time <dur> --comment "..." Log time with a comment
36
+ jira worklog <KEY> --list Show recent worklogs
37
+
38
+ Options:
39
+ --time DUR Duration to log. Jira-style tokens "1d 4h 30m" or
40
+ compact "1d4h30m". "d" = 24-hour wall day (NOT Jira's
41
+ 8h workday). Rounds to the nearest minute.
42
+ --from ISO Start timestamp (ISO-8601). Elapsed is computed as
43
+ --to (default: now) minus --from, rounded to the
44
+ nearest minute. Mutually exclusive with --time.
45
+ --to ISO End timestamp (ISO-8601). Defaults to now.
46
+ --comment TEXT Optional worklog comment (plain text, converted to ADF).
47
+ --started ISO Timestamp recorded as the worklog start. Defaults to
48
+ the derived --from, else (now - elapsed).
49
+ --list Show the 10 most recent worklogs on the issue.
50
+ --dry-run Print the request path + body, send nothing (exit 0).
51
+ --help, -h Show this help.
52
+
53
+ Duration semantics:
54
+ All input is converted to seconds and posted as timeSpentSeconds, so
55
+ 1d always means 86400 seconds regardless of the project's time-tracking
56
+ workday setting.
57
+
58
+ Examples:
59
+ jira worklog AI-1810 --time 2h30m
60
+ jira worklog AI-1810 --from 2026-08-21T09:00:00Z
61
+ jira worklog AI-1810 --from 2026-08-20T14:00:00Z --to 2026-08-21T09:15:00Z
62
+ jira worklog AI-1810 --time 45m --comment "Investigation + write-up"
63
+ jira worklog AI-1810 --list
64
+ `;
65
+
66
+ // ── Duration parsing ─────────────────────────────────────────────────
67
+
68
+ const SECONDS_PER = {
69
+ d: 24 * 60 * 60, // 24-hour wall day (documented; NOT the Jira 8h workday)
70
+ h: 60 * 60,
71
+ m: 60,
72
+ };
73
+
74
+ /**
75
+ * Parse a Jira-style duration into seconds.
76
+ *
77
+ * Accepts space-separated or compact forms, mixed order, case-insensitive:
78
+ * "1d 4h 30m", "1d4h30m", "30m", "2H", "1d 30m".
79
+ *
80
+ * Each unit (`d`, `h`, `m`) may appear at most once. Returns an integer
81
+ * number of seconds. Throws JiraError(1) on any parse failure so the caller
82
+ * exits with a usage-error code.
83
+ *
84
+ * @param {string} raw Duration string as supplied by the operator
85
+ * @returns {number} Total seconds
86
+ */
87
+ export function parseDuration(raw) {
88
+ if (raw == null || typeof raw !== 'string' || raw.trim() === '') {
89
+ throw new JiraError('Duration is empty; expected e.g. "1d 4h 30m"', 1);
90
+ }
91
+ const compact = raw.trim().replace(/\s+/g, '').toLowerCase();
92
+ // Must be one or more <int><unit> tokens, nothing else.
93
+ if (!/^(?:\d+[dhm])+$/.test(compact)) {
94
+ throw new JiraError(
95
+ `Unparseable duration "${raw}"; expected tokens like "1d 4h 30m"`,
96
+ 1
97
+ );
98
+ }
99
+ const seen = new Set();
100
+ let total = 0;
101
+ const tokenRe = /(\d+)([dhm])/g;
102
+ let m;
103
+ while ((m = tokenRe.exec(compact)) !== null) {
104
+ const [, nStr, unit] = m;
105
+ if (seen.has(unit)) {
106
+ throw new JiraError(
107
+ `Duration "${raw}" repeats unit "${unit}"; use one of each`,
108
+ 1
109
+ );
110
+ }
111
+ seen.add(unit);
112
+ const n = Number.parseInt(nStr, 10);
113
+ if (!Number.isFinite(n) || n < 0) {
114
+ throw new JiraError(`Duration "${raw}" has invalid number "${nStr}"`, 1);
115
+ }
116
+ total += n * SECONDS_PER[unit];
117
+ }
118
+ if (total === 0) {
119
+ throw new JiraError(`Duration "${raw}" evaluates to zero seconds`, 1);
120
+ }
121
+ return total;
122
+ }
123
+
124
+ /**
125
+ * Format a duration (seconds) back into a compact "1d 4h 30m" string
126
+ * for human display. Zero-value units are dropped.
127
+ *
128
+ * @param {number} seconds
129
+ * @returns {string}
130
+ */
131
+ export function formatDuration(seconds) {
132
+ let s = Math.max(0, Math.round(seconds));
133
+ const d = Math.floor(s / SECONDS_PER.d); s -= d * SECONDS_PER.d;
134
+ const h = Math.floor(s / SECONDS_PER.h); s -= h * SECONDS_PER.h;
135
+ const m = Math.floor(s / SECONDS_PER.m); s -= m * SECONDS_PER.m;
136
+ const parts = [];
137
+ if (d) parts.push(`${d}d`);
138
+ if (h) parts.push(`${h}h`);
139
+ if (m) parts.push(`${m}m`);
140
+ if (s) parts.push(`${s}s`);
141
+ return parts.length > 0 ? parts.join(' ') : '0m';
142
+ }
143
+
144
+ // ── Timestamp handling ───────────────────────────────────────────────
145
+
146
+ /**
147
+ * Parse an ISO-8601 timestamp; throws JiraError(1) if invalid.
148
+ *
149
+ * @param {string} iso ISO-8601 timestamp
150
+ * @param {string} label Flag name for the error message ("--from"/"--to")
151
+ * @returns {Date}
152
+ */
153
+ export function parseIsoTimestamp(iso, label) {
154
+ if (iso == null || typeof iso !== 'string' || iso.trim() === '') {
155
+ throw new JiraError(`${label} is empty; expected an ISO-8601 timestamp`, 1);
156
+ }
157
+ const d = new Date(iso);
158
+ if (Number.isNaN(d.getTime())) {
159
+ throw new JiraError(`${label} "${iso}" is not a valid ISO-8601 timestamp`, 1);
160
+ }
161
+ return d;
162
+ }
163
+
164
+ /**
165
+ * Format a Date for Jira's `started` worklog field, which requires the
166
+ * `yyyy-MM-dd'T'HH:mm:ss.SSSZ` shape with a numeric timezone offset (no colon).
167
+ *
168
+ * JS `toISOString()` produces `...Z`; Jira rejects that and wants `+0000`.
169
+ *
170
+ * @param {Date} d
171
+ * @returns {string}
172
+ */
173
+ export function formatJiraStarted(d) {
174
+ return d.toISOString().replace(/Z$/, '+0000');
175
+ }
176
+
177
+ // ── Arg parsing ──────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Parse argv for the worklog command.
181
+ *
182
+ * @param {string[]} argv Args after 'worklog'
183
+ * @returns {object}
184
+ */
185
+ function parseWorklogArgs(argv) {
186
+ const out = {
187
+ key: null,
188
+ time: null,
189
+ from: null,
190
+ to: null,
191
+ comment: null,
192
+ started: null,
193
+ list: false,
194
+ dryRun: false,
195
+ help: false,
196
+ };
197
+ for (let i = 0; i < argv.length; i++) {
198
+ const a = argv[i];
199
+ if (a === '--help' || a === '-h') { out.help = true; continue; }
200
+ if (a === '--list') { out.list = true; continue; }
201
+ if (a === '--dry-run') { out.dryRun = true; continue; }
202
+ if (a === '--time') { out.time = argv[++i]; continue; }
203
+ if (a === '--from') { out.from = argv[++i]; continue; }
204
+ if (a === '--to') { out.to = argv[++i]; continue; }
205
+ if (a === '--comment') { out.comment = argv[++i]; continue; }
206
+ if (a === '--started') { out.started = argv[++i]; continue; }
207
+ if (a.startsWith('-')) {
208
+ throw new JiraError(`Unknown flag "${a}"`, 1);
209
+ }
210
+ if (!out.key) { out.key = a; continue; }
211
+ throw new JiraError(`Unexpected positional argument "${a}"`, 1);
212
+ }
213
+ return out;
214
+ }
215
+
216
+ // ── Runner ───────────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Run the worklog command.
220
+ *
221
+ * @param {string[]} argv Args after 'worklog'
222
+ * @param {object} [deps] Dependency overrides
223
+ * @returns {Promise<number>}
224
+ */
225
+ export async function run(argv = [], deps = {}) {
226
+ const log = deps.log || console.log;
227
+ const logError = deps.logError || console.error;
228
+
229
+ let parsed;
230
+ try {
231
+ parsed = parseWorklogArgs(argv);
232
+ } catch (err) {
233
+ if (err instanceof JiraError) {
234
+ logError(`jira worklog: ${err.message}`);
235
+ return err.exitCode;
236
+ }
237
+ throw err;
238
+ }
239
+
240
+ if (parsed.help) { log(HELP); return 0; }
241
+
242
+ if (!parsed.key) {
243
+ logError('Usage: jira worklog <KEY> --time <dur>');
244
+ logError(' jira worklog <KEY> --from <iso> [--to <iso>]');
245
+ logError(' jira worklog <KEY> --list');
246
+ return 1;
247
+ }
248
+
249
+ const client = deps.client || createClient();
250
+
251
+ if (parsed.list) {
252
+ return listWorklogs(client, parsed.key, log, logError);
253
+ }
254
+
255
+ // ── Decide elapsed seconds and started timestamp ──────────────────
256
+ let elapsedSeconds;
257
+ let derivedStarted = null;
258
+
259
+ const hasTime = parsed.time != null;
260
+ const hasFrom = parsed.from != null;
261
+
262
+ if (hasTime && hasFrom) {
263
+ logError('jira worklog: --time and --from are mutually exclusive');
264
+ return 1;
265
+ }
266
+ if (!hasTime && !hasFrom) {
267
+ logError('jira worklog: provide --time <dur> or --from <iso> (or --list)');
268
+ return 1;
269
+ }
270
+
271
+ try {
272
+ if (hasTime) {
273
+ elapsedSeconds = parseDuration(String(parsed.time));
274
+ } else {
275
+ const fromDate = parseIsoTimestamp(String(parsed.from), '--from');
276
+ const toDate = parsed.to != null
277
+ ? parseIsoTimestamp(String(parsed.to), '--to')
278
+ : new Date();
279
+ const deltaMs = toDate.getTime() - fromDate.getTime();
280
+ if (deltaMs <= 0) {
281
+ logError('jira worklog: --from must be earlier than --to');
282
+ return 1;
283
+ }
284
+ // Round to the nearest minute per the ergonomic contract.
285
+ elapsedSeconds = Math.round(deltaMs / 1000 / 60) * 60;
286
+ if (elapsedSeconds < 60) {
287
+ // Sub-minute delta after rounding is not useful and Jira rejects
288
+ // zero-second worklogs anyway.
289
+ logError(
290
+ `jira worklog: elapsed time (${Math.round(deltaMs / 1000)}s) rounds ` +
291
+ 'to under a minute; nothing to log'
292
+ );
293
+ return 1;
294
+ }
295
+ derivedStarted = fromDate;
296
+ }
297
+
298
+ // Started: explicit --started wins; else derived from --from; else
299
+ // (now - elapsed).
300
+ let startedDate;
301
+ if (parsed.started != null) {
302
+ startedDate = parseIsoTimestamp(String(parsed.started), '--started');
303
+ } else if (derivedStarted) {
304
+ startedDate = derivedStarted;
305
+ } else {
306
+ startedDate = new Date(Date.now() - elapsedSeconds * 1000);
307
+ }
308
+
309
+ // ── Build request body ─────────────────────────────────────────
310
+ const body = {
311
+ timeSpentSeconds: elapsedSeconds,
312
+ started: formatJiraStarted(startedDate),
313
+ };
314
+ if (parsed.comment) {
315
+ body.comment = textToAdf(String(parsed.comment));
316
+ }
317
+
318
+ const path = `/rest/api/3/issue/${encodeURIComponent(parsed.key)}/worklog`;
319
+
320
+ if (parsed.dryRun) {
321
+ log(dim('DRY RUN - no request sent'));
322
+ log(`POST ${path}`);
323
+ log(JSON.stringify(body, null, 2));
324
+ log(dim(`(elapsed: ${formatDuration(elapsedSeconds)})`));
325
+ return 0;
326
+ }
327
+
328
+ const { data } = await client.post(path, body);
329
+ log(green(`Worklog added to ${bold(parsed.key)}.`)
330
+ + dim(` (id: ${data && data.id}, ${formatDuration(elapsedSeconds)})`));
331
+
332
+ (deps.notifyWorkspace || notifyWorkspaceAsync)();
333
+ return 0;
334
+ } catch (err) {
335
+ if (err instanceof JiraError) {
336
+ logError(`jira worklog: ${err.message}`);
337
+ return err.exitCode;
338
+ }
339
+ throw err;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * List the 10 most recent worklogs on an issue (newest last - matches the
345
+ * REST endpoint's natural order).
346
+ */
347
+ async function listWorklogs(client, key, log, logError) {
348
+ try {
349
+ const { data } = await client.get(
350
+ `/rest/api/3/issue/${encodeURIComponent(key)}/worklog?maxResults=10`
351
+ );
352
+
353
+ const worklogs = data.worklogs || [];
354
+ if (worklogs.length === 0) {
355
+ log(dim(`No worklogs on ${key}.`));
356
+ return 0;
357
+ }
358
+
359
+ log(bold(`Worklogs on ${key}`) + dim(` (${worklogs.length} shown)\n`));
360
+ for (const w of worklogs) {
361
+ const who = (w.author && (w.author.displayName || w.author.emailAddress))
362
+ || 'unknown';
363
+ const started = w.started || '';
364
+ const timeStr = formatDuration(w.timeSpentSeconds ?? 0);
365
+ log(`${bold(timeStr.padEnd(10))} ${who} ${dim(started)}`);
366
+ if (w.comment) {
367
+ const bodyText = adfToText(w.comment);
368
+ if (bodyText) log(` ${bodyText.split('\n').join('\n ')}`);
369
+ }
370
+ log('');
371
+ }
372
+ return 0;
373
+ } catch (err) {
374
+ if (err instanceof JiraError) {
375
+ logError(`jira worklog: Failed to list worklogs on ${key}: ${err.message}`);
376
+ return err.exitCode;
377
+ }
378
+ throw err;
379
+ }
380
+ }
@@ -0,0 +1,267 @@
1
+ // format.mjs - Terminal output formatting for Jira data.
2
+ //
3
+ // Provides table rendering, issue/project/comment formatting, and
4
+ // ANSI color helpers. Used by CLI commands and workspace API handlers.
5
+ //
6
+ // Zero external dependencies.
7
+
8
+ // ── ANSI color helpers ───────────────────────────────────────────────
9
+
10
+ const IS_TTY = process.stdout.isTTY;
11
+
12
+ function ansi(code, text) {
13
+ if (!IS_TTY) return text;
14
+ return `\x1b[${code}m${text}\x1b[0m`;
15
+ }
16
+
17
+ export function bold(text) { return ansi('1', text); }
18
+ export function dim(text) { return ansi('2', text); }
19
+ export function red(text) { return ansi('31', text); }
20
+ export function green(text) { return ansi('32', text); }
21
+ export function yellow(text) { return ansi('33', text); }
22
+ export function blue(text) { return ansi('34', text); }
23
+ export function cyan(text) { return ansi('36', text); }
24
+
25
+ // ── Status color mapping ─────────────────────────────────────────────
26
+
27
+ const STATUS_COLORS = {
28
+ 'done': green,
29
+ 'closed': green,
30
+ 'resolved': green,
31
+ 'in progress': blue,
32
+ 'in review': cyan,
33
+ 'to do': yellow,
34
+ 'open': yellow,
35
+ 'backlog': dim,
36
+ };
37
+
38
+ function colorStatus(status) {
39
+ if (!status) return '';
40
+ const lower = status.toLowerCase();
41
+ const colorFn = STATUS_COLORS[lower] || ((s) => s);
42
+ return colorFn(status);
43
+ }
44
+
45
+ // ── Priority rendering ───────────────────────────────────────────────
46
+
47
+ const PRIORITY_LABELS = {
48
+ 1: 'Highest',
49
+ 2: 'High',
50
+ 3: 'Medium',
51
+ 4: 'Low',
52
+ 5: 'Lowest',
53
+ };
54
+
55
+ function formatPriority(priority) {
56
+ if (!priority) return '';
57
+ const id = priority.id ? parseInt(priority.id, 10) : null;
58
+ return priority.name || PRIORITY_LABELS[id] || '';
59
+ }
60
+
61
+ // ── Table formatting ─────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Render rows as an aligned text table.
65
+ *
66
+ * @param {Array<object>} rows Array of data objects
67
+ * @param {Array<object>} columns Column definitions: { key, label, width?, align? }
68
+ * - key: property name on each row
69
+ * - label: column header text
70
+ * - width: max column width (optional, auto-calculated if omitted)
71
+ * - align: 'left' (default) or 'right'
72
+ * @returns {string} Formatted table
73
+ */
74
+ export function formatTable(rows, columns) {
75
+ if (!rows || rows.length === 0) return '';
76
+
77
+ // Calculate column widths
78
+ const colWidths = columns.map((col) => {
79
+ const headerLen = col.label.length;
80
+ const maxDataLen = rows.reduce((max, row) => {
81
+ const val = String(row[col.key] ?? '');
82
+ return Math.max(max, val.length);
83
+ }, 0);
84
+ const natural = Math.max(headerLen, maxDataLen);
85
+ return col.width ? Math.min(natural, col.width) : natural;
86
+ });
87
+
88
+ // Header
89
+ const header = columns.map((col, i) => {
90
+ return pad(col.label, colWidths[i], col.align || 'left');
91
+ }).join(' ');
92
+
93
+ // Separator
94
+ const sep = colWidths.map((w) => '-'.repeat(w)).join(' ');
95
+
96
+ // Rows
97
+ const body = rows.map((row) => {
98
+ return columns.map((col, i) => {
99
+ const val = String(row[col.key] ?? '');
100
+ const truncated = val.length > colWidths[i]
101
+ ? val.slice(0, colWidths[i] - 1) + '\u2026'
102
+ : val;
103
+ return pad(truncated, colWidths[i], col.align || 'left');
104
+ }).join(' ');
105
+ });
106
+
107
+ return [bold(header), sep, ...body].join('\n');
108
+ }
109
+
110
+ function pad(str, width, align) {
111
+ if (align === 'right') return str.padStart(width);
112
+ return str.padEnd(width);
113
+ }
114
+
115
+ // ── Issue formatting ─────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Format a single Jira issue for terminal display.
119
+ *
120
+ * @param {object} issue Jira issue object (from API response)
121
+ * @returns {string} Formatted output
122
+ */
123
+ export function formatIssue(issue) {
124
+ if (!issue) return '';
125
+
126
+ const f = issue.fields || {};
127
+ const lines = [];
128
+
129
+ // Key + Summary
130
+ lines.push(`${bold(issue.key)} ${f.summary || '(no summary)'}`);
131
+ lines.push('');
132
+
133
+ // Status + Priority
134
+ const status = f.status?.name || 'Unknown';
135
+ const priority = formatPriority(f.priority);
136
+ const type = f.issuetype?.name || '';
137
+ const metaParts = [colorStatus(status)];
138
+ if (priority) metaParts.push(priority);
139
+ if (type) metaParts.push(type);
140
+ lines.push(metaParts.join(' | '));
141
+
142
+ // Assignee + Reporter
143
+ const assignee = f.assignee?.displayName || dim('Unassigned');
144
+ const reporter = f.reporter?.displayName || '';
145
+ lines.push(`Assignee: ${assignee}`);
146
+ if (reporter) lines.push(`Reporter: ${reporter}`);
147
+
148
+ // Labels
149
+ if (f.labels && f.labels.length > 0) {
150
+ lines.push(`Labels: ${f.labels.join(', ')}`);
151
+ }
152
+
153
+ // Sprint
154
+ if (f.sprint) {
155
+ lines.push(`Sprint: ${f.sprint.name || f.sprint.id}`);
156
+ }
157
+
158
+ // Created / Updated
159
+ if (f.created) lines.push(`Created: ${formatDate(f.created)}`);
160
+ if (f.updated) lines.push(`Updated: ${formatDate(f.updated)}`);
161
+
162
+ // Description excerpt
163
+ if (f.description) {
164
+ lines.push('');
165
+ lines.push(dim('--- Description ---'));
166
+ // Import adf lazily to avoid circular deps - but in practice
167
+ // format.mjs will be used alongside adf.mjs at the call site.
168
+ // Here we just show the raw type hint.
169
+ lines.push(dim('(ADF document - use adfToText/adfToMarkdown to render)'));
170
+ }
171
+
172
+ return lines.join('\n');
173
+ }
174
+
175
+ /**
176
+ * Format a list of issues as a compact table.
177
+ *
178
+ * @param {Array<object>} issues Array of Jira issues
179
+ * @returns {string} Formatted table
180
+ */
181
+ export function formatIssueList(issues) {
182
+ if (!issues || issues.length === 0) return dim('No issues found.');
183
+
184
+ const rows = issues.map((issue) => {
185
+ const f = issue.fields || {};
186
+ return {
187
+ key: issue.key,
188
+ type: f.issuetype?.name || '',
189
+ status: f.status?.name || '',
190
+ priority: formatPriority(f.priority),
191
+ assignee: f.assignee?.displayName || '',
192
+ summary: f.summary || '',
193
+ };
194
+ });
195
+
196
+ return formatTable(rows, [
197
+ { key: 'key', label: 'Key', width: 12 },
198
+ { key: 'type', label: 'Type', width: 10 },
199
+ { key: 'status', label: 'Status', width: 14 },
200
+ { key: 'priority', label: 'Pri', width: 8 },
201
+ { key: 'assignee', label: 'Assignee', width: 20 },
202
+ { key: 'summary', label: 'Summary', width: 50 },
203
+ ]);
204
+ }
205
+
206
+ // ── Project formatting ───────────────────────────────────────────────
207
+
208
+ /**
209
+ * Format a single Jira project for terminal display.
210
+ *
211
+ * @param {object} project Jira project object
212
+ * @returns {string} Formatted output
213
+ */
214
+ export function formatProject(project) {
215
+ if (!project) return '';
216
+
217
+ const lines = [];
218
+ lines.push(`${bold(project.key)} ${project.name || ''}`);
219
+ if (project.projectTypeKey) lines.push(`Type: ${project.projectTypeKey}`);
220
+ if (project.lead?.displayName) lines.push(`Lead: ${project.lead.displayName}`);
221
+ if (project.description) lines.push(`\n${project.description}`);
222
+
223
+ return lines.join('\n');
224
+ }
225
+
226
+ // ── Comment formatting ───────────────────────────────────────────────
227
+
228
+ /**
229
+ * Format a single Jira comment for terminal display.
230
+ *
231
+ * @param {object} comment Jira comment object
232
+ * @returns {string} Formatted output
233
+ */
234
+ export function formatComment(comment) {
235
+ if (!comment) return '';
236
+
237
+ const author = comment.author?.displayName || 'Unknown';
238
+ const created = comment.created ? formatDate(comment.created) : '';
239
+ const updated = comment.updated ? formatDate(comment.updated) : '';
240
+
241
+ const lines = [];
242
+ lines.push(`${bold(author)} ${dim(created)}`);
243
+ if (updated && updated !== created) {
244
+ lines.push(dim(`(edited ${updated})`));
245
+ }
246
+
247
+ // Comment body is ADF - note that like formatIssue, the caller should
248
+ // render ADF before passing to this function for the body display.
249
+ if (comment.body) {
250
+ lines.push('');
251
+ lines.push(dim('(ADF body - use adfToText/adfToMarkdown to render)'));
252
+ }
253
+
254
+ return lines.join('\n');
255
+ }
256
+
257
+ // ── Date formatting ──────────────────────────────────────────────────
258
+
259
+ function formatDate(isoString) {
260
+ if (!isoString) return '';
261
+ try {
262
+ const d = new Date(isoString);
263
+ return d.toISOString().slice(0, 10);
264
+ } catch {
265
+ return isoString;
266
+ }
267
+ }