@emmertarmin/redmine-cli 0.1.1 → 0.1.3

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 +17 -0
  2. package/dist/index.js +601 -246
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -40,10 +40,27 @@ Options:
40
40
  Subcommands:
41
41
  config - Manage configuration
42
42
  issue - Work with issues
43
+ activity - Watch Redmine activity
43
44
 
44
45
  Run `redmine <command> --help` for command-specific help.
45
46
  ```
46
47
 
48
+ ### Watch activity
49
+
50
+ ```bash
51
+ redmine activity
52
+ redmine activity -n 60
53
+ redmine activity -o jsonl
54
+ redmine activity watch
55
+ redmine activity watch -n 5
56
+ redmine activity watch -o jsonl
57
+ redmine activity watch --verbose
58
+ ```
59
+
60
+ `activity` prints recent issue updates and time entries once. Use `-n` / `--minutes` to choose the lookback window, defaulting to 15 minutes.
61
+
62
+ `activity watch` polls recent activity, prints recent activity on startup, then continues printing new events. Minimal human-readable text is the default output. Use `--verbose` / `-v` for full markdown details. With `-o jsonl`, default output is `{ "text": "..." }`; verbose JSONL emits the full event object.
63
+
47
64
  ## License
48
65
 
49
66
  MIT
package/dist/index.js CHANGED
@@ -1,10 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
 
4
- // src/commands/config.ts
5
- import { stdin as input, stderr as output } from "process";
6
- import { createInterface } from "readline/promises";
7
-
8
4
  // src/config/load-config.ts
9
5
  import { constants as fsConstants } from "fs";
10
6
  import { access, mkdir, readFile, writeFile } from "fs/promises";
@@ -75,10 +71,525 @@ async function saveConfig(config) {
75
71
  `, "utf8");
76
72
  }
77
73
 
78
- // src/commands/config.ts
74
+ // src/redmine/cache.ts
75
+ import { createHash } from "crypto";
76
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
77
+ import { homedir as homedir2 } from "os";
78
+ import { join as join2 } from "path";
79
+ function getCacheDir() {
80
+ const baseDir = process.env.XDG_CACHE_HOME ?? join2(homedir2(), ".cache");
81
+ return join2(baseDir, "redmine", "api-cache-v1");
82
+ }
83
+ function cachePathForKey(key) {
84
+ const hash = createHash("sha256").update(key).digest("hex");
85
+ return join2(getCacheDir(), `${hash}.json`);
86
+ }
87
+ async function readCachedRedmineJson(key, ttlMs) {
88
+ try {
89
+ const raw = await readFile2(cachePathForKey(key), "utf8");
90
+ const entry = JSON.parse(raw);
91
+ if (typeof entry.createdAt !== "string" || !("data" in entry)) {
92
+ return;
93
+ }
94
+ const createdAt = Date.parse(entry.createdAt);
95
+ if (!Number.isFinite(createdAt)) {
96
+ return;
97
+ }
98
+ const ageMs = Date.now() - createdAt;
99
+ if (ageMs < 0 || ageMs > ttlMs) {
100
+ return;
101
+ }
102
+ return { data: entry.data, ageMs };
103
+ } catch {
104
+ return;
105
+ }
106
+ }
107
+ async function writeCachedRedmineJson(key, ttlMs, data) {
108
+ try {
109
+ await mkdir2(getCacheDir(), { recursive: true });
110
+ const entry = {
111
+ createdAt: new Date().toISOString(),
112
+ ttlMs,
113
+ data
114
+ };
115
+ await writeFile2(cachePathForKey(key), `${JSON.stringify(entry)}
116
+ `, "utf8");
117
+ } catch {}
118
+ }
119
+
120
+ // src/redmine/trace.ts
121
+ import { appendFile, mkdir as mkdir3 } from "fs/promises";
122
+ import { homedir as homedir3 } from "os";
123
+ import { dirname as dirname2, join as join3 } from "path";
124
+ function getDefaultTraceFilePath() {
125
+ const baseDir = process.env.XDG_CACHE_HOME ?? join3(homedir3(), ".cache");
126
+ return join3(baseDir, "redmine", "requests.jsonl");
127
+ }
128
+ function getTraceFilePath() {
129
+ return process.env.REDMINE_TRACE_FILE === "1" ? getDefaultTraceFilePath() : undefined;
130
+ }
131
+ async function traceRedmineRequest(event) {
132
+ const traceFilePath = getTraceFilePath();
133
+ if (!traceFilePath) {
134
+ return;
135
+ }
136
+ try {
137
+ await mkdir3(dirname2(traceFilePath), { recursive: true });
138
+ await appendFile(traceFilePath, `${JSON.stringify(event)}
139
+ `, "utf8");
140
+ } catch {}
141
+ }
142
+
143
+ // src/redmine/client.ts
144
+ function requireString(value, name) {
145
+ if (!value) {
146
+ throw new Error(`Missing Redmine ${name}. Run \`redmine config setup\` or \`redmine config set --${name} <value>\`.`);
147
+ }
148
+ return value;
149
+ }
150
+ function buildUrl(baseUrl, path, query = {}) {
151
+ const url = new URL(path.replace(/^\/+/, ""), baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
152
+ for (const [key, value] of Object.entries(query)) {
153
+ if (value !== undefined) {
154
+ url.searchParams.set(key, String(value));
155
+ }
156
+ }
157
+ return url;
158
+ }
159
+ async function fetchRedmineJson(config, url, path) {
160
+ const key = requireString(config.key, "key");
161
+ const startedAt = performance.now();
162
+ try {
163
+ const response = await fetch(url, {
164
+ headers: {
165
+ Accept: "application/json",
166
+ "X-Redmine-API-Key": key
167
+ }
168
+ });
169
+ const durationMs = Math.round(performance.now() - startedAt);
170
+ await traceRedmineRequest({
171
+ ts: new Date().toISOString(),
172
+ method: "GET",
173
+ path,
174
+ source: "network",
175
+ status: response.status,
176
+ duration_ms: durationMs,
177
+ ok: response.ok
178
+ });
179
+ if (!response.ok) {
180
+ const body = await response.text();
181
+ throw new Error(`Redmine API request failed (${response.status} ${response.statusText}): ${body}`);
182
+ }
183
+ return response.json();
184
+ } catch (error) {
185
+ if (!(error instanceof Error && error.message.startsWith("Redmine API request failed"))) {
186
+ await traceRedmineRequest({
187
+ ts: new Date().toISOString(),
188
+ method: "GET",
189
+ path,
190
+ source: "network",
191
+ duration_ms: Math.round(performance.now() - startedAt),
192
+ ok: false,
193
+ error: error instanceof Error ? error.message : String(error)
194
+ });
195
+ }
196
+ throw error;
197
+ }
198
+ }
199
+ async function redmineGetJson(config, options) {
200
+ const url = buildUrl(requireString(config.url, "url"), options.path, options.query);
201
+ const path = `${url.pathname}${url.search}`;
202
+ return fetchRedmineJson(config, url, path);
203
+ }
204
+ async function redmineGetJsonCached(config, options, ttlMs) {
205
+ const url = buildUrl(requireString(config.url, "url"), options.path, options.query);
206
+ const path = `${url.pathname}${url.search}`;
207
+ const startedAt = performance.now();
208
+ const cached = await readCachedRedmineJson(url.toString(), ttlMs);
209
+ if (cached) {
210
+ await traceRedmineRequest({
211
+ ts: new Date().toISOString(),
212
+ method: "GET",
213
+ path,
214
+ source: "cache",
215
+ status: 200,
216
+ duration_ms: Math.round(performance.now() - startedAt),
217
+ ok: true,
218
+ cache_age_ms: Math.round(cached.ageMs)
219
+ });
220
+ return cached.data;
221
+ }
222
+ const data = await fetchRedmineJson(config, url, path);
223
+ await writeCachedRedmineJson(url.toString(), ttlMs, data);
224
+ return data;
225
+ }
226
+
227
+ // src/commands/activity.ts
228
+ function isRecord(value) {
229
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
230
+ }
79
231
  function optionalString(value) {
80
232
  return typeof value === "string" ? value : undefined;
81
233
  }
234
+ function namedReferenceName(value) {
235
+ if (!isRecord(value)) {
236
+ return;
237
+ }
238
+ const name = value.name;
239
+ return typeof name === "string" ? name : undefined;
240
+ }
241
+ function numericId(value) {
242
+ return typeof value === "number" && Number.isInteger(value) ? value : undefined;
243
+ }
244
+ function parseRedmineTimestamp(value) {
245
+ if (typeof value !== "string") {
246
+ return;
247
+ }
248
+ const date = new Date(value);
249
+ return Number.isNaN(date.getTime()) ? undefined : date;
250
+ }
251
+ function parseWatchIntervalMinutes(values) {
252
+ const raw = optionalString(values.interval) ?? "1";
253
+ const parsed = Number.parseInt(raw, 10);
254
+ if (!Number.isInteger(parsed) || String(parsed) !== raw || parsed < 0) {
255
+ throw new Error(`Expected --interval to be a positive integer, got: ${raw}`);
256
+ }
257
+ return parsed;
258
+ }
259
+ function outputFormat(values) {
260
+ const value = optionalString(values.output) ?? "md";
261
+ if (value !== "md" && value !== "jsonl") {
262
+ throw new Error(`Invalid value for --output: ${value}`);
263
+ }
264
+ return value;
265
+ }
266
+ function markdownInline(value) {
267
+ return String(value ?? "").replace(/\s+/g, " ").trim().replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
268
+ }
269
+ function compact(value, maxLength = 160) {
270
+ if (typeof value !== "string") {
271
+ return;
272
+ }
273
+ const normalized = value.replace(/\s+/g, " ").trim();
274
+ if (!normalized) {
275
+ return;
276
+ }
277
+ return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1).trimEnd()}\u2026` : normalized;
278
+ }
279
+ function formatLocalTimestamp(iso) {
280
+ const date = new Date(iso);
281
+ const pad = (value) => String(value).padStart(2, "0");
282
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
283
+ }
284
+ function issueLabel(event) {
285
+ return event.issue === undefined ? "" : ` #${event.issue}`;
286
+ }
287
+ function subjectLabel(event) {
288
+ return event.subject ? ` \u201C${markdownInline(event.subject)}\u201D` : "";
289
+ }
290
+ function projectLabel(event) {
291
+ return event.project ? ` in **${markdownInline(event.project)}**` : "";
292
+ }
293
+ function detailsLabel(event) {
294
+ return event.details.length > 0 ? `: ${event.details.map(markdownInline).join("; ")}` : "";
295
+ }
296
+ function plainInline(value) {
297
+ return String(value ?? "").replace(/\s+/g, " ").trim();
298
+ }
299
+ function eventVerb(event) {
300
+ if (event.type === "issue_created") {
301
+ return "created";
302
+ }
303
+ if (event.type === "time_entry") {
304
+ return "logged time on";
305
+ }
306
+ return "updated";
307
+ }
308
+ function eventToMinimalText(event) {
309
+ const user = event.user ? plainInline(event.user) : "Someone";
310
+ const issue = event.issue === undefined ? "" : ` issue ${event.issue}`;
311
+ const subject = event.subject ? ` ${plainInline(event.subject)}` : "";
312
+ const project = event.project && event.issue === undefined ? ` in ${plainInline(event.project)}` : "";
313
+ return `${user} ${eventVerb(event)}${issue}${subject}${project}`;
314
+ }
315
+ function eventToMarkdownLine(event) {
316
+ const user = event.user ? `**${markdownInline(event.user)}**` : "someone";
317
+ const prefix = `- ${formatLocalTimestamp(event.ts)} \u2014 ${user}`;
318
+ if (event.type === "issue_created") {
319
+ return `${prefix} created${issueLabel(event)}${subjectLabel(event)}${projectLabel(event)}${detailsLabel(event)}
320
+ `;
321
+ }
322
+ if (event.type === "time_entry") {
323
+ return `${prefix} logged time${issueLabel(event)}${subjectLabel(event)}${projectLabel(event)}${detailsLabel(event)}
324
+ `;
325
+ }
326
+ return `${prefix} updated${issueLabel(event)}${subjectLabel(event)}${projectLabel(event)}${detailsLabel(event)}
327
+ `;
328
+ }
329
+ function eventToOutputLine(event, format, minimal) {
330
+ if (minimal) {
331
+ const text = eventToMinimalText(event);
332
+ if (format === "jsonl") {
333
+ return `${JSON.stringify({ text })}
334
+ `;
335
+ }
336
+ return `${text}
337
+ `;
338
+ }
339
+ if (format === "jsonl") {
340
+ return `${JSON.stringify(event)}
341
+ `;
342
+ }
343
+ return eventToMarkdownLine(event);
344
+ }
345
+ function journalDetails(journal) {
346
+ const details = [];
347
+ const note = compact(journal.notes);
348
+ if (note) {
349
+ details.push(`comment: ${note}`);
350
+ }
351
+ const rawDetails = journal.details;
352
+ if (Array.isArray(rawDetails)) {
353
+ for (const detail of rawDetails) {
354
+ if (!isRecord(detail)) {
355
+ continue;
356
+ }
357
+ const name = typeof detail.name === "string" ? detail.name : typeof detail.property === "string" ? detail.property : "field";
358
+ const oldValue = detail.old_value === undefined || detail.old_value === null || detail.old_value === "" ? "\u2205" : String(detail.old_value);
359
+ const newValue = detail.new_value === undefined || detail.new_value === null || detail.new_value === "" ? "\u2205" : String(detail.new_value);
360
+ details.push(`${name} ${oldValue} \u2192 ${newValue}`);
361
+ }
362
+ }
363
+ return details;
364
+ }
365
+ async function collectIssueEvents(config, since) {
366
+ const response = await redmineGetJson(config, {
367
+ path: "/issues.json",
368
+ query: { status_id: "*", sort: "updated_on:desc", limit: 100 }
369
+ });
370
+ const events = [];
371
+ const candidateIds = [];
372
+ for (const issue of response.issues ?? []) {
373
+ if (!isRecord(issue)) {
374
+ continue;
375
+ }
376
+ const updatedOn = parseRedmineTimestamp(issue.updated_on);
377
+ if (!updatedOn || updatedOn < since) {
378
+ continue;
379
+ }
380
+ const id = numericId(issue.id);
381
+ if (id === undefined) {
382
+ continue;
383
+ }
384
+ candidateIds.push(id);
385
+ const createdOn = parseRedmineTimestamp(issue.created_on);
386
+ if (createdOn && createdOn >= since) {
387
+ events.push({
388
+ key: `issue_created:${id}`,
389
+ ts: createdOn.toISOString(),
390
+ type: "issue_created",
391
+ user: namedReferenceName(issue.author),
392
+ issue: id,
393
+ project: namedReferenceName(issue.project),
394
+ subject: typeof issue.subject === "string" ? issue.subject : undefined,
395
+ details: []
396
+ });
397
+ }
398
+ }
399
+ for (const issueId of candidateIds) {
400
+ const response2 = await redmineGetJson(config, {
401
+ path: `/issues/${issueId}.json`,
402
+ query: { include: "journals" }
403
+ });
404
+ if (!isRecord(response2) || !isRecord(response2.issue)) {
405
+ continue;
406
+ }
407
+ const issue = response2.issue;
408
+ const journals = issue.journals;
409
+ if (!Array.isArray(journals)) {
410
+ continue;
411
+ }
412
+ for (const journal of journals) {
413
+ if (!isRecord(journal)) {
414
+ continue;
415
+ }
416
+ const createdOn = parseRedmineTimestamp(journal.created_on);
417
+ const journalId = numericId(journal.id);
418
+ if (!createdOn || createdOn < since || journalId === undefined) {
419
+ continue;
420
+ }
421
+ events.push({
422
+ key: `issue_journal:${issueId}:${journalId}`,
423
+ ts: createdOn.toISOString(),
424
+ type: "issue_update",
425
+ user: namedReferenceName(journal.user),
426
+ issue: issueId,
427
+ project: namedReferenceName(issue.project),
428
+ subject: typeof issue.subject === "string" ? issue.subject : undefined,
429
+ details: journalDetails(journal)
430
+ });
431
+ }
432
+ }
433
+ return events;
434
+ }
435
+ async function collectTimeEntryEvents(config, since) {
436
+ const response = await redmineGetJson(config, {
437
+ path: "/time_entries.json",
438
+ query: { sort: "created_on:desc", limit: 100 }
439
+ });
440
+ const events = [];
441
+ for (const entry of response.time_entries ?? []) {
442
+ if (!isRecord(entry)) {
443
+ continue;
444
+ }
445
+ const createdOn = parseRedmineTimestamp(entry.created_on);
446
+ const id = numericId(entry.id);
447
+ if (!createdOn || createdOn < since || id === undefined) {
448
+ continue;
449
+ }
450
+ const details = [];
451
+ const hours = typeof entry.hours === "number" ? `${entry.hours}h` : undefined;
452
+ const activity = namedReferenceName(entry.activity);
453
+ const spentOn = typeof entry.spent_on === "string" ? `spent on ${entry.spent_on}` : undefined;
454
+ const comment = compact(entry.comments, 120);
455
+ const summary = [hours, activity, spentOn].filter(Boolean).join(" ");
456
+ if (summary) {
457
+ details.push(summary);
458
+ }
459
+ if (comment) {
460
+ details.push(comment);
461
+ }
462
+ events.push({
463
+ key: `time_entry:${id}`,
464
+ ts: createdOn.toISOString(),
465
+ type: "time_entry",
466
+ user: namedReferenceName(entry.user),
467
+ issue: isRecord(entry.issue) ? numericId(entry.issue.id) : undefined,
468
+ project: namedReferenceName(entry.project),
469
+ subject: undefined,
470
+ details
471
+ });
472
+ }
473
+ return events;
474
+ }
475
+ async function collectActivityEvents(config, since) {
476
+ const [issueEvents, timeEntryEvents] = await Promise.all([collectIssueEvents(config, since), collectTimeEntryEvents(config, since)]);
477
+ return [...issueEvents, ...timeEntryEvents].sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
478
+ }
479
+ function sleep(ms) {
480
+ return new Promise((resolve) => setTimeout(resolve, ms));
481
+ }
482
+ function parseLookbackMinutes(values) {
483
+ const raw = optionalString(values.minutes) ?? "15";
484
+ const parsed = Number.parseInt(raw, 10);
485
+ if (!Number.isInteger(parsed) || String(parsed) !== raw || parsed < 1) {
486
+ throw new Error(`Expected --minutes to be a positive integer, got: ${raw}`);
487
+ }
488
+ return parsed;
489
+ }
490
+ async function printActivityOnce(values, lookbackMs) {
491
+ const config = await loadConfig();
492
+ const format = outputFormat(values);
493
+ const verbose = values.verbose === true;
494
+ const since = new Date(Date.now() - lookbackMs);
495
+ const events = await collectActivityEvents(config, since);
496
+ for (const event of events) {
497
+ process.stdout.write(eventToOutputLine(event, format, !verbose));
498
+ }
499
+ }
500
+ async function executeActivityWatch(values) {
501
+ const config = await loadConfig();
502
+ const intervalMinutes = parseWatchIntervalMinutes(values);
503
+ const intervalMs = intervalMinutes === 0 ? 1e4 : intervalMinutes * 60000;
504
+ const lookbackMs = (intervalMinutes + 5) * 60000;
505
+ const format = outputFormat(values);
506
+ const verbose = values.verbose === true;
507
+ const seen = new Set;
508
+ console.error(`Watching Redmine activity every ${intervalMinutes === 0 ? "10 seconds" : `${intervalMinutes} minute${intervalMinutes === 1 ? "" : "s"}`} (lookback: ${Math.round(lookbackMs / 60000)} minutes). Press Ctrl+C to stop.`);
509
+ while (true) {
510
+ const since = new Date(Date.now() - lookbackMs);
511
+ const events = await collectActivityEvents(config, since);
512
+ for (const event of events) {
513
+ if (seen.has(event.key)) {
514
+ continue;
515
+ }
516
+ seen.add(event.key);
517
+ process.stdout.write(eventToOutputLine(event, format, !verbose));
518
+ }
519
+ await sleep(intervalMs);
520
+ }
521
+ }
522
+ var activityOutputFlags = [
523
+ {
524
+ name: "output",
525
+ aliases: ["o"],
526
+ type: "string",
527
+ choices: ["md", "jsonl"],
528
+ description: "Output format: md or jsonl",
529
+ defaultValue: "md"
530
+ },
531
+ {
532
+ name: "verbose",
533
+ aliases: ["v"],
534
+ type: "boolean",
535
+ description: "Output full activity details instead of minimal text"
536
+ }
537
+ ];
538
+ var activityWatchCommand = {
539
+ name: "watch",
540
+ requiresConfig: true,
541
+ aliases: ["tail"],
542
+ summary: "Watch recent Redmine activity",
543
+ description: "Poll Redmine for recent issue and time-entry activity and print new events as a feed.",
544
+ flags: [
545
+ {
546
+ name: "interval",
547
+ aliases: ["n"],
548
+ type: "string",
549
+ description: "Positive integer polling interval in minutes",
550
+ defaultValue: "1"
551
+ },
552
+ ...activityOutputFlags
553
+ ],
554
+ examples: ["redmine activity watch", "redmine activity watch -n 5", "redmine activity watch -o jsonl", "redmine activity watch --verbose"],
555
+ execute: async ({ values, positionals }) => {
556
+ if (positionals.length > 0) {
557
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
558
+ }
559
+ await executeActivityWatch(values);
560
+ }
561
+ };
562
+ var activityCommand = {
563
+ name: "activity",
564
+ requiresConfig: true,
565
+ summary: "Show recent Redmine activity",
566
+ description: "Show recent Redmine issue and time-entry activity once, or watch it as a feed.",
567
+ flags: [
568
+ {
569
+ name: "minutes",
570
+ aliases: ["n"],
571
+ type: "string",
572
+ description: "Look back this many minutes",
573
+ defaultValue: "15"
574
+ },
575
+ ...activityOutputFlags
576
+ ],
577
+ examples: ["redmine activity", "redmine activity -n 60", "redmine activity -o jsonl", "redmine activity --verbose", "redmine activity watch"],
578
+ subcommands: [activityWatchCommand],
579
+ execute: async ({ values, positionals }) => {
580
+ if (positionals.length > 0) {
581
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
582
+ }
583
+ await printActivityOnce(values, parseLookbackMinutes(values) * 60000);
584
+ }
585
+ };
586
+
587
+ // src/commands/config.ts
588
+ import { stdin as input, stderr as output } from "process";
589
+ import { createInterface } from "readline/promises";
590
+ function optionalString2(value) {
591
+ return typeof value === "string" ? value : undefined;
592
+ }
82
593
  function maskApiKey(key) {
83
594
  if (key.length <= 8) {
84
595
  return "*****";
@@ -233,9 +744,9 @@ var configSetCommand = {
233
744
  if (positionals.length > 0) {
234
745
  throw new Error(`Unexpected argument: ${positionals[0]}`);
235
746
  }
236
- const url = optionalString(values.url);
237
- const key = optionalString(values.key);
238
- const issueMirrorDir = optionalString(values["issue-mirror-dir"]);
747
+ const url = optionalString2(values.url);
748
+ const key = optionalString2(values.key);
749
+ const issueMirrorDir = optionalString2(values["issue-mirror-dir"]);
239
750
  if (url === undefined && key === undefined && issueMirrorDir === undefined) {
240
751
  throw new Error("Nothing to set. Provide --url, --key, --issue-mirror-dir, or any combination.");
241
752
  }
@@ -266,159 +777,6 @@ var configCommand = {
266
777
  import { createInterface as createInterface2 } from "readline/promises";
267
778
  import { stdin as input2, stderr as output2 } from "process";
268
779
 
269
- // src/redmine/cache.ts
270
- import { createHash } from "crypto";
271
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
272
- import { homedir as homedir2 } from "os";
273
- import { join as join2 } from "path";
274
- function getCacheDir() {
275
- const baseDir = process.env.XDG_CACHE_HOME ?? join2(homedir2(), ".cache");
276
- return join2(baseDir, "redmine", "api-cache-v1");
277
- }
278
- function cachePathForKey(key) {
279
- const hash = createHash("sha256").update(key).digest("hex");
280
- return join2(getCacheDir(), `${hash}.json`);
281
- }
282
- async function readCachedRedmineJson(key, ttlMs) {
283
- try {
284
- const raw = await readFile2(cachePathForKey(key), "utf8");
285
- const entry = JSON.parse(raw);
286
- if (typeof entry.createdAt !== "string" || !("data" in entry)) {
287
- return;
288
- }
289
- const createdAt = Date.parse(entry.createdAt);
290
- if (!Number.isFinite(createdAt)) {
291
- return;
292
- }
293
- const ageMs = Date.now() - createdAt;
294
- if (ageMs < 0 || ageMs > ttlMs) {
295
- return;
296
- }
297
- return { data: entry.data, ageMs };
298
- } catch {
299
- return;
300
- }
301
- }
302
- async function writeCachedRedmineJson(key, ttlMs, data) {
303
- try {
304
- await mkdir2(getCacheDir(), { recursive: true });
305
- const entry = {
306
- createdAt: new Date().toISOString(),
307
- ttlMs,
308
- data
309
- };
310
- await writeFile2(cachePathForKey(key), `${JSON.stringify(entry)}
311
- `, "utf8");
312
- } catch {}
313
- }
314
-
315
- // src/redmine/trace.ts
316
- import { appendFile, mkdir as mkdir3 } from "fs/promises";
317
- import { homedir as homedir3 } from "os";
318
- import { dirname as dirname2, join as join3 } from "path";
319
- function getDefaultTraceFilePath() {
320
- const baseDir = process.env.XDG_CACHE_HOME ?? join3(homedir3(), ".cache");
321
- return join3(baseDir, "redmine", "requests.jsonl");
322
- }
323
- function getTraceFilePath() {
324
- return process.env.REDMINE_TRACE_FILE === "1" ? getDefaultTraceFilePath() : undefined;
325
- }
326
- async function traceRedmineRequest(event) {
327
- const traceFilePath = getTraceFilePath();
328
- if (!traceFilePath) {
329
- return;
330
- }
331
- try {
332
- await mkdir3(dirname2(traceFilePath), { recursive: true });
333
- await appendFile(traceFilePath, `${JSON.stringify(event)}
334
- `, "utf8");
335
- } catch {}
336
- }
337
-
338
- // src/redmine/client.ts
339
- function requireString(value, name) {
340
- if (!value) {
341
- throw new Error(`Missing Redmine ${name}. Run \`redmine config setup\` or \`redmine config set --${name} <value>\`.`);
342
- }
343
- return value;
344
- }
345
- function buildUrl(baseUrl, path, query = {}) {
346
- const url = new URL(path.replace(/^\/+/, ""), baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
347
- for (const [key, value] of Object.entries(query)) {
348
- if (value !== undefined) {
349
- url.searchParams.set(key, String(value));
350
- }
351
- }
352
- return url;
353
- }
354
- async function fetchRedmineJson(config, url, path) {
355
- const key = requireString(config.key, "key");
356
- const startedAt = performance.now();
357
- try {
358
- const response = await fetch(url, {
359
- headers: {
360
- Accept: "application/json",
361
- "X-Redmine-API-Key": key
362
- }
363
- });
364
- const durationMs = Math.round(performance.now() - startedAt);
365
- await traceRedmineRequest({
366
- ts: new Date().toISOString(),
367
- method: "GET",
368
- path,
369
- source: "network",
370
- status: response.status,
371
- duration_ms: durationMs,
372
- ok: response.ok
373
- });
374
- if (!response.ok) {
375
- const body = await response.text();
376
- throw new Error(`Redmine API request failed (${response.status} ${response.statusText}): ${body}`);
377
- }
378
- return response.json();
379
- } catch (error) {
380
- if (!(error instanceof Error && error.message.startsWith("Redmine API request failed"))) {
381
- await traceRedmineRequest({
382
- ts: new Date().toISOString(),
383
- method: "GET",
384
- path,
385
- source: "network",
386
- duration_ms: Math.round(performance.now() - startedAt),
387
- ok: false,
388
- error: error instanceof Error ? error.message : String(error)
389
- });
390
- }
391
- throw error;
392
- }
393
- }
394
- async function redmineGetJson(config, options) {
395
- const url = buildUrl(requireString(config.url, "url"), options.path, options.query);
396
- const path = `${url.pathname}${url.search}`;
397
- return fetchRedmineJson(config, url, path);
398
- }
399
- async function redmineGetJsonCached(config, options, ttlMs) {
400
- const url = buildUrl(requireString(config.url, "url"), options.path, options.query);
401
- const path = `${url.pathname}${url.search}`;
402
- const startedAt = performance.now();
403
- const cached = await readCachedRedmineJson(url.toString(), ttlMs);
404
- if (cached) {
405
- await traceRedmineRequest({
406
- ts: new Date().toISOString(),
407
- method: "GET",
408
- path,
409
- source: "cache",
410
- status: 200,
411
- duration_ms: Math.round(performance.now() - startedAt),
412
- ok: true,
413
- cache_age_ms: Math.round(cached.ageMs)
414
- });
415
- return cached.data;
416
- }
417
- const data = await fetchRedmineJson(config, url, path);
418
- await writeCachedRedmineJson(url.toString(), ttlMs, data);
419
- return data;
420
- }
421
-
422
780
  // src/output/markdown.ts
423
781
  function splitFrontmatter(markdown) {
424
782
  if (!markdown.startsWith(`---
@@ -468,7 +826,7 @@ var SPRINT_WORK_DAYS = 12;
468
826
  var DESCRIPTION_PREVIEW_LENGTH = 180;
469
827
  var DESCRIPTION_PREVIEW_HYSTERESIS_LENGTH = 220;
470
828
  var DEFAULT_ISSUE_GET_INCLUDE = "journals,attachments,relations,changesets,watchers";
471
- function optionalString2(value) {
829
+ function optionalString3(value) {
472
830
  return typeof value === "string" ? value : undefined;
473
831
  }
474
832
  function optionalNumber(value, fallback) {
@@ -501,11 +859,11 @@ function isIssueListResponse(value) {
501
859
  const response = value;
502
860
  return Array.isArray(response.issues) && typeof response.total_count === "number" && typeof response.offset === "number" && typeof response.limit === "number";
503
861
  }
504
- function isRecord(value) {
862
+ function isRecord2(value) {
505
863
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
506
864
  }
507
- function namedReferenceName(value) {
508
- if (!isRecord(value)) {
865
+ function namedReferenceName2(value) {
866
+ if (!isRecord2(value)) {
509
867
  return;
510
868
  }
511
869
  const name = value.name;
@@ -521,7 +879,7 @@ function namedReferenceName(value) {
521
879
  return typeof login === "string" ? login : undefined;
522
880
  }
523
881
  function namedReferenceId(value) {
524
- if (!isRecord(value)) {
882
+ if (!isRecord2(value)) {
525
883
  return;
526
884
  }
527
885
  const id = value.id;
@@ -544,14 +902,14 @@ function addNamedReferencesToMap(map, values) {
544
902
  }
545
903
  for (const value of values) {
546
904
  const id = namedReferenceId(value);
547
- const name = namedReferenceName(value);
905
+ const name = namedReferenceName2(value);
548
906
  if (id && name) {
549
907
  map.set(id, name);
550
908
  }
551
909
  }
552
910
  }
553
911
  function lookupArray(response, key) {
554
- return isRecord(response) ? response[key] : undefined;
912
+ return isRecord2(response) ? response[key] : undefined;
555
913
  }
556
914
  function mergeLookupMaps(target, source) {
557
915
  for (const [key, map] of Object.entries(source)) {
@@ -581,12 +939,12 @@ function summarizeIssue(issue) {
581
939
  return {
582
940
  id: record.id,
583
941
  subject: record.subject,
584
- project: namedReferenceName(record.project),
585
- tracker: namedReferenceName(record.tracker),
586
- status: namedReferenceName(record.status),
587
- priority: namedReferenceName(record.priority),
588
- assignee: namedReferenceName(record.assigned_to),
589
- author: namedReferenceName(record.author),
942
+ project: namedReferenceName2(record.project),
943
+ tracker: namedReferenceName2(record.tracker),
944
+ status: namedReferenceName2(record.status),
945
+ priority: namedReferenceName2(record.priority),
946
+ assignee: namedReferenceName2(record.assigned_to),
947
+ author: namedReferenceName2(record.author),
590
948
  sprint: getIssueSprintValue(issue) || undefined,
591
949
  description_preview: truncateDescription(record.description),
592
950
  updated_on: record.updated_on,
@@ -602,8 +960,8 @@ function summarizeIssueListResponse(response, issues = response.issues) {
602
960
  issues: issues.map((issue) => summarizeIssue(issue))
603
961
  };
604
962
  }
605
- function outputFormat(values) {
606
- const value = optionalString2(values.output) ?? "json";
963
+ function outputFormat2(values) {
964
+ const value = optionalString3(values.output) ?? "json";
607
965
  if (value !== "json" && value !== "md") {
608
966
  throw new Error(`Invalid value for --output: ${value}`);
609
967
  }
@@ -619,7 +977,7 @@ function scalarValue(value) {
619
977
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
620
978
  return String(value);
621
979
  }
622
- const name = namedReferenceName(value);
980
+ const name = namedReferenceName2(value);
623
981
  if (name) {
624
982
  return name;
625
983
  }
@@ -677,14 +1035,14 @@ function customFieldsMarkdown(issue) {
677
1035
  if (!Array.isArray(issue.custom_fields)) {
678
1036
  return "";
679
1037
  }
680
- const rows = issue.custom_fields.filter((field) => isRecord(field)).map((field) => [scalarValue(field.name) ?? `Custom field ${scalarValue(field.id) ?? ""}`.trim(), scalarValue(field.value)]);
1038
+ const rows = issue.custom_fields.filter((field) => isRecord2(field)).map((field) => [scalarValue(field.name) ?? `Custom field ${scalarValue(field.id) ?? ""}`.trim(), scalarValue(field.value)]);
681
1039
  return markdownTable(rows);
682
1040
  }
683
1041
  function yamlString(value) {
684
1042
  return JSON.stringify(String(value ?? ""));
685
1043
  }
686
1044
  function joinNameAndId(value) {
687
- const name = namedReferenceName(value);
1045
+ const name = namedReferenceName2(value);
688
1046
  const id = namedReferenceId(value);
689
1047
  if (name && id)
690
1048
  return `${name} (#${id})`;
@@ -740,7 +1098,7 @@ function journalDetailMarkdown(detail) {
740
1098
  return `- **${mdEscape(label)}${suffix}** changed`;
741
1099
  }
742
1100
  function issueResponseToMarkdown(response) {
743
- const issue = isRecord(response) && isRecord(response.issue) ? response.issue : undefined;
1101
+ const issue = isRecord2(response) && isRecord2(response.issue) ? response.issue : undefined;
744
1102
  if (!issue) {
745
1103
  return `---
746
1104
  resource: redmine_issue
@@ -760,8 +1118,8 @@ ${JSON.stringify(response, null, 2)}
760
1118
  "resource: redmine_issue",
761
1119
  `id: ${yamlString(id)}`,
762
1120
  `subject: ${yamlString(subject)}`,
763
- `project: ${yamlString(namedReferenceName(issue.project) ?? "")}`,
764
- `status: ${yamlString(namedReferenceName(issue.status) ?? "")}`,
1121
+ `project: ${yamlString(namedReferenceName2(issue.project) ?? "")}`,
1122
+ `status: ${yamlString(namedReferenceName2(issue.status) ?? "")}`,
765
1123
  `generated_at: ${new Date().toISOString()}`
766
1124
  ];
767
1125
  const parts = [`---
@@ -800,10 +1158,10 @@ ${frontmatter.join(`
800
1158
  if (customFields)
801
1159
  parts.push("## Custom fields", customFields);
802
1160
  if (Array.isArray(issue.attachments) && issue.attachments.length > 0) {
803
- const items = issue.attachments.filter(isRecord).map((attachment) => {
1161
+ const items = issue.attachments.filter(isRecord2).map((attachment) => {
804
1162
  const filename = scalarValue(attachment.filename) ?? scalarValue(attachment.id) ?? "attachment";
805
1163
  const url = scalarValue(attachment.content_url);
806
- const author = namedReferenceName(attachment.author);
1164
+ const author = namedReferenceName2(attachment.author);
807
1165
  const size = scalarValue(attachment.filesize);
808
1166
  const meta = [size ? `${size} bytes` : undefined, author ? `by ${author}` : undefined, formatDateTime(attachment.created_on)].filter(Boolean).join(", ");
809
1167
  return `${url ? `[${mdEscape(filename)}](${url})` : mdEscape(filename)}${meta ? ` \u2014 ${mdEscape(meta)}` : ""}`;
@@ -811,18 +1169,18 @@ ${frontmatter.join(`
811
1169
  parts.push("## Attachments", bulletList(items));
812
1170
  }
813
1171
  if (Array.isArray(issue.relations) && issue.relations.length > 0) {
814
- const rows = issue.relations.filter(isRecord).map((relation) => [
1172
+ const rows = issue.relations.filter(isRecord2).map((relation) => [
815
1173
  scalarValue(relation.relation_type) ?? "relation",
816
1174
  `#${scalarValue(relation.issue_id) ?? "?"} \u2194 #${scalarValue(relation.issue_to_id) ?? "?"}${scalarValue(relation.delay) ? ` (${scalarValue(relation.delay)}d)` : ""}`
817
1175
  ]);
818
1176
  parts.push("## Relations", markdownTable(rows));
819
1177
  }
820
1178
  if (Array.isArray(issue.children) && issue.children.length > 0) {
821
- const items = issue.children.filter(isRecord).map((child) => `#${scalarValue(child.id) ?? "?"} ${mdEscape(scalarValue(child.subject) ?? "Untitled")} \u2014 ${mdEscape(namedReferenceName(child.status) ?? "")}`.trim());
1179
+ const items = issue.children.filter(isRecord2).map((child) => `#${scalarValue(child.id) ?? "?"} ${mdEscape(scalarValue(child.subject) ?? "Untitled")} \u2014 ${mdEscape(namedReferenceName2(child.status) ?? "")}`.trim());
822
1180
  parts.push("## Children", bulletList(items));
823
1181
  }
824
1182
  if (Array.isArray(issue.changesets) && issue.changesets.length > 0) {
825
- const items = issue.changesets.filter(isRecord).map((changeset) => {
1183
+ const items = issue.changesets.filter(isRecord2).map((changeset) => {
826
1184
  const revision = scalarValue(changeset.revision) ?? scalarValue(changeset.id) ?? "changeset";
827
1185
  const comments = scalarValue(changeset.comments);
828
1186
  const author = scalarValue(changeset.user) ?? scalarValue(changeset.author);
@@ -835,30 +1193,30 @@ ${frontmatter.join(`
835
1193
  parts.push("## Watchers", bulletList(issue.watchers.map((watcher) => mdEscape(joinNameAndId(watcher) ?? compactJson(watcher) ?? "watcher"))));
836
1194
  }
837
1195
  if (Array.isArray(issue.time_entries) && issue.time_entries.length > 0) {
838
- const rows = issue.time_entries.filter(isRecord).map((entry) => [
1196
+ const rows = issue.time_entries.filter(isRecord2).map((entry) => [
839
1197
  scalarValue(entry.spent_on) ?? formatDateTime(entry.created_on) ?? "time entry",
840
- [formatHours(entry.hours), namedReferenceName(entry.user), namedReferenceName(entry.activity), scalarValue(entry.comments)].filter(Boolean).join(" \u2014 ")
1198
+ [formatHours(entry.hours), namedReferenceName2(entry.user), namedReferenceName2(entry.activity), scalarValue(entry.comments)].filter(Boolean).join(" \u2014 ")
841
1199
  ]);
842
1200
  parts.push("## Time entries", markdownTable(rows));
843
1201
  }
844
1202
  if (Array.isArray(issue.journals) && issue.journals.length > 0) {
845
1203
  parts.push("## Journal");
846
- for (const journal of issue.journals.filter(isRecord)) {
1204
+ for (const journal of issue.journals.filter(isRecord2)) {
847
1205
  const journalId = scalarValue(journal.id) ?? "?";
848
- const author = namedReferenceName(journal.user) ?? "Unknown user";
1206
+ const author = namedReferenceName2(journal.user) ?? "Unknown user";
849
1207
  const created = formatDateTime(journal.created_on) ?? scalarValue(journal.created_on) ?? "unknown date";
850
1208
  parts.push(`### Note ${mdEscape(journalId)} \u2014 ${mdEscape(author)} \u2014 ${mdEscape(created)}`);
851
1209
  const notes = normalizeRedmineTextileToMarkdown(journal.notes);
852
1210
  if (notes)
853
1211
  parts.push(notes);
854
1212
  if (Array.isArray(journal.details) && journal.details.length > 0) {
855
- const detailBlocks = journal.details.filter(isRecord).map(journalDetailMarkdown);
1213
+ const detailBlocks = journal.details.filter(isRecord2).map(journalDetailMarkdown);
856
1214
  parts.push(detailBlocks.join(`
857
1215
  `));
858
1216
  }
859
1217
  }
860
1218
  }
861
- if (isRecord(issue._enrichment) && Array.isArray(issue._enrichment.warnings) && issue._enrichment.warnings.length > 0) {
1219
+ if (isRecord2(issue._enrichment) && Array.isArray(issue._enrichment.warnings) && issue._enrichment.warnings.length > 0) {
862
1220
  parts.push("## Enrichment warnings", bulletList(issue._enrichment.warnings.map((warning) => mdEscape(compactJson(warning) ?? "warning"))));
863
1221
  }
864
1222
  const renderedKeys = new Set([
@@ -932,42 +1290,32 @@ ${frontmatter.join(`
932
1290
  parts.push(`Filters: ${activeFilters.map((filter) => `\`${filter}\``).join(", ")}`);
933
1291
  }
934
1292
  for (const issue of response.issues) {
935
- if (!isRecord(issue)) {
1293
+ if (!isRecord2(issue)) {
936
1294
  continue;
937
1295
  }
938
- const id = scalarValue(issue.id) ?? "?";
939
- const subject = scalarValue(issue.subject) ?? "Untitled issue";
940
- const sprint = getIssueSprintValue(issue);
1296
+ const summary = summarizeIssue(issue);
1297
+ const id = scalarValue(summary.id) ?? "?";
1298
+ const subject = scalarValue(summary.subject) ?? "Untitled issue";
941
1299
  const fields = markdownTable([
942
- ["Project", scalarValue(issue.project)],
943
- ["Tracker", scalarValue(issue.tracker)],
944
- ["Status", scalarValue(issue.status)],
945
- ["Priority", scalarValue(issue.priority)],
946
- ["Assignee", scalarValue(issue.assigned_to)],
947
- ["Author", scalarValue(issue.author)],
948
- ["Sprint", sprint],
949
- ["Done", scalarValue(issue.done_ratio) ? `${scalarValue(issue.done_ratio)}%` : undefined],
950
- ["Estimated", formatHours(issue.estimated_hours)],
951
- ["Spent", formatHours(issue.spent_hours)],
952
- ["Total spent", formatHours(issue.total_spent_hours)],
953
- ["Start", scalarValue(issue.start_date)],
954
- ["Due", scalarValue(issue.due_date)],
955
- ["Created", formatDateTime(issue.created_on)],
956
- ["Updated", formatDateTime(issue.updated_on)],
957
- ["Closed", formatDateTime(issue.closed_on)],
958
- ["Private", issue.is_private === true ? "yes" : undefined]
1300
+ ["Project", summary.project],
1301
+ ["Tracker", summary.tracker],
1302
+ ["Status", summary.status],
1303
+ ["Priority", summary.priority],
1304
+ ["Assignee", summary.assignee],
1305
+ ["Author", summary.author],
1306
+ ["Sprint", summary.sprint],
1307
+ ["Done", scalarValue(summary.done_ratio) ? `${scalarValue(summary.done_ratio)}%` : undefined],
1308
+ ["Estimated", formatHours(summary.estimated_hours)],
1309
+ ["Spent", formatHours(summary.spent_hours)],
1310
+ ["Created", formatDateTime(summary.created_on)],
1311
+ ["Updated", formatDateTime(summary.updated_on)]
959
1312
  ]);
960
1313
  parts.push(`## #${mdEscape(id)} ${mdEscape(subject)}`);
961
1314
  if (fields) {
962
1315
  parts.push(fields);
963
1316
  }
964
- const description = normalizeRedmineTextileToMarkdown(issue.description);
965
- if (description) {
966
- parts.push("### Description", description);
967
- }
968
- const customFields = customFieldsMarkdown(issue);
969
- if (customFields) {
970
- parts.push("### Custom fields", customFields);
1317
+ if (summary.description_preview) {
1318
+ parts.push("### Description preview", mdEscape(summary.description_preview));
971
1319
  }
972
1320
  }
973
1321
  return `${parts.join(`
@@ -976,7 +1324,7 @@ ${frontmatter.join(`
976
1324
  `;
977
1325
  }
978
1326
  function printIssueListResponse(response, values, filters) {
979
- if (outputFormat(values) === "md") {
1327
+ if (outputFormat2(values) === "md") {
980
1328
  process.stdout.write(renderMarkdownForTerminal(issueListResponseToMarkdown(response, filters)));
981
1329
  return;
982
1330
  }
@@ -996,7 +1344,7 @@ function customFieldsByName(issue) {
996
1344
  }
997
1345
  const byName = {};
998
1346
  for (const field of customFields) {
999
- if (!isRecord(field) || typeof field.name !== "string") {
1347
+ if (!isRecord2(field) || typeof field.name !== "string") {
1000
1348
  continue;
1001
1349
  }
1002
1350
  byName[field.name] = field.value;
@@ -1046,7 +1394,7 @@ function valueLabel(name, value, lookups) {
1046
1394
  return;
1047
1395
  }
1048
1396
  function enrichJournalDetail(detail, lookups) {
1049
- if (!isRecord(detail) || typeof detail.name !== "string") {
1397
+ if (!isRecord2(detail) || typeof detail.name !== "string") {
1050
1398
  return detail;
1051
1399
  }
1052
1400
  const label = detailLabel(detail.name, lookups);
@@ -1060,13 +1408,13 @@ function enrichJournalDetail(detail, lookups) {
1060
1408
  };
1061
1409
  }
1062
1410
  function enrichIssueResponse(response, enrichment) {
1063
- if (!isRecord(response) || !isRecord(response.issue)) {
1411
+ if (!isRecord2(response) || !isRecord2(response.issue)) {
1064
1412
  return response;
1065
1413
  }
1066
1414
  const issue = response.issue;
1067
1415
  const customFields = customFieldsByName(issue);
1068
1416
  const journals = Array.isArray(issue.journals) ? issue.journals.map((journal) => {
1069
- if (!isRecord(journal)) {
1417
+ if (!isRecord2(journal)) {
1070
1418
  return journal;
1071
1419
  }
1072
1420
  return {
@@ -1090,12 +1438,12 @@ function enrichIssueResponse(response, enrichment) {
1090
1438
  };
1091
1439
  }
1092
1440
  function collectJournalUserIds(issue) {
1093
- if (!isRecord(issue) || !Array.isArray(issue.journals)) {
1441
+ if (!isRecord2(issue) || !Array.isArray(issue.journals)) {
1094
1442
  return [];
1095
1443
  }
1096
1444
  const ids = new Set;
1097
1445
  for (const journal of issue.journals) {
1098
- if (!isRecord(journal)) {
1446
+ if (!isRecord2(journal)) {
1099
1447
  continue;
1100
1448
  }
1101
1449
  const userId = namedReferenceId(journal.user);
@@ -1105,7 +1453,7 @@ function collectJournalUserIds(issue) {
1105
1453
  continue;
1106
1454
  }
1107
1455
  for (const detail of journal.details) {
1108
- if (!isRecord(detail) || typeof detail.name !== "string") {
1456
+ if (!isRecord2(detail) || typeof detail.name !== "string") {
1109
1457
  continue;
1110
1458
  }
1111
1459
  if (detail.name === "assigned_to_id" || detail.name === "user_id" || detail.name === "author_id") {
@@ -1122,10 +1470,10 @@ function collectJournalUserIds(issue) {
1122
1470
  async function fetchIssueEnrichment(config, issueId, issue) {
1123
1471
  const lookups = createLookupMaps();
1124
1472
  const warnings = [];
1125
- if (isRecord(issue)) {
1473
+ if (isRecord2(issue)) {
1126
1474
  addNamedReferencesToMap(lookups.users, [issue.author, issue.assigned_to]);
1127
1475
  }
1128
- const projectId = isRecord(issue) ? namedReferenceId(issue.project) : undefined;
1476
+ const projectId = isRecord2(issue) ? namedReferenceId(issue.project) : undefined;
1129
1477
  const requests = await Promise.all([
1130
1478
  redmineGetJsonOptional(config, "/time_entries.json", { issue_id: issueId, limit: 100 }),
1131
1479
  redmineGetJsonOptional(config, "/issue_statuses.json", undefined, DAY_MS),
@@ -1154,9 +1502,9 @@ async function fetchIssueEnrichment(config, issueId, issue) {
1154
1502
  warnings.push(userRequest.warning);
1155
1503
  continue;
1156
1504
  }
1157
- if (isRecord(userRequest.data) && isRecord(userRequest.data.user)) {
1505
+ if (isRecord2(userRequest.data) && isRecord2(userRequest.data.user)) {
1158
1506
  const id = namedReferenceId(userRequest.data.user);
1159
- const name = namedReferenceName(userRequest.data.user);
1507
+ const name = namedReferenceName2(userRequest.data.user);
1160
1508
  if (id && name) {
1161
1509
  userLookups.users.set(id, name);
1162
1510
  }
@@ -1252,10 +1600,10 @@ function buildIssueQuery(filters, limit, offset) {
1252
1600
  }
1253
1601
  function filtersFromValues(values, overrides = {}) {
1254
1602
  return {
1255
- project: optionalString2(values.project),
1256
- assignee: overrides.assignee ?? optionalString2(values.assignee),
1257
- status: overrides.status ?? resolveStatus(optionalString2(values.status)),
1258
- sprint: overrides.sprint ?? resolveSprint(optionalString2(values.sprint))
1603
+ project: optionalString3(values.project),
1604
+ assignee: overrides.assignee ?? optionalString3(values.assignee),
1605
+ status: overrides.status ?? resolveStatus(optionalString3(values.status)),
1606
+ sprint: overrides.sprint ?? resolveSprint(optionalString3(values.sprint))
1259
1607
  };
1260
1608
  }
1261
1609
  function getIssueSprintValue(issue) {
@@ -1432,7 +1780,7 @@ var issueGetCommand = {
1432
1780
  requiresConfig: true,
1433
1781
  aliases: ["show"],
1434
1782
  summary: "Get an issue",
1435
- description: "Get a single Redmine issue as JSON. Journals, attachments, relations, changesets, and watchers are included by default.",
1783
+ description: "Get a single Redmine issue as Markdown. Journals, attachments, relations, changesets, and watchers are included by default.",
1436
1784
  arguments: [
1437
1785
  {
1438
1786
  name: "id",
@@ -1459,7 +1807,7 @@ var issueGetCommand = {
1459
1807
  type: "string",
1460
1808
  choices: ["json", "md"],
1461
1809
  description: "Output format: json or md",
1462
- defaultValue: "json"
1810
+ defaultValue: "md"
1463
1811
  },
1464
1812
  {
1465
1813
  name: "no-secondary",
@@ -1467,20 +1815,20 @@ var issueGetCommand = {
1467
1815
  description: "Skip secondary requests for time entries and lookup metadata"
1468
1816
  }
1469
1817
  ],
1470
- examples: ["redmine issue get 43135", "redmine issue get 43135 -o md", "redmine issue get 43135 --include journals,attachments,relations", "redmine issue get 43135 --include none", "redmine issue get 43135 --raw"],
1818
+ examples: ["redmine issue get 43135", "redmine issue get 43135 -o json", "redmine issue get 43135 --include journals,attachments,relations", "redmine issue get 43135 --include none", "redmine issue get 43135 --raw"],
1471
1819
  execute: async ({ values, positionals }) => {
1472
1820
  if (positionals.length !== 1) {
1473
1821
  throw new Error("Expected exactly one issue ID argument");
1474
1822
  }
1475
1823
  const issueId = requirePositiveInteger(positionals[0], "issue ID");
1476
- const include = optionalString2(values.include);
1824
+ const include = optionalString3(values.include);
1477
1825
  const config = await loadConfig();
1478
1826
  const response = await redmineGetJson(config, {
1479
1827
  path: `/issues/${issueId}.json`,
1480
1828
  query: { include: include && include !== "none" ? include : undefined }
1481
1829
  });
1482
- const format = outputFormat(values);
1483
- if (values.raw === true || values["no-secondary"] === true || !isRecord(response) || !isRecord(response.issue)) {
1830
+ const format = outputFormat2(values);
1831
+ if (values.raw === true || values["no-secondary"] === true || !isRecord2(response) || !isRecord2(response.issue)) {
1484
1832
  if (format === "md") {
1485
1833
  process.stdout.write(renderMarkdownForTerminal(issueResponseToMarkdown(response)));
1486
1834
  return;
@@ -1581,7 +1929,7 @@ var rootCommand = {
1581
1929
  name: "redmine",
1582
1930
  summary: "Redmine CLI",
1583
1931
  description: "Inspect and manage Redmine resources from the command line.",
1584
- subcommands: [configCommand, issueCommand]
1932
+ subcommands: [configCommand, issueCommand, activityCommand]
1585
1933
  };
1586
1934
  function getVisibleSubcommands(command) {
1587
1935
  return (command.subcommands ?? []).filter((entry) => entry.hidden !== true);
@@ -1618,7 +1966,13 @@ function resolveCommandPath(args) {
1618
1966
  };
1619
1967
  }
1620
1968
  function collectCommandFlags(command) {
1621
- return [...globalFlags, ...command?.flags ?? []];
1969
+ const commandFlags = command?.flags ?? [];
1970
+ const commandAliases = new Set(commandFlags.flatMap((flag) => flag.aliases ?? []));
1971
+ const globals = globalFlags.map((flag) => ({
1972
+ ...flag,
1973
+ aliases: flag.aliases?.filter((alias) => !commandAliases.has(alias))
1974
+ }));
1975
+ return [...globals, ...commandFlags];
1622
1976
  }
1623
1977
 
1624
1978
  // src/cli/help.ts
@@ -1837,7 +2191,7 @@ function parseCli(args) {
1837
2191
  // package.json
1838
2192
  var package_default = {
1839
2193
  name: "@emmertarmin/redmine-cli",
1840
- version: "0.1.1",
2194
+ version: "0.1.3",
1841
2195
  description: "A personal Redmine CLI.",
1842
2196
  license: "MIT",
1843
2197
  repository: {
@@ -1860,6 +2214,7 @@ var package_default = {
1860
2214
  bun: ">=1.0.0"
1861
2215
  },
1862
2216
  scripts: {
2217
+ cli: "bun run ./index.ts",
1863
2218
  dev: "bun --watch run index.ts",
1864
2219
  build: "bun build ./index.ts --outdir dist --target bun",
1865
2220
  tsc: "tsc --noEmit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emmertarmin/redmine-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A personal Redmine CLI.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,6 +23,7 @@
23
23
  "bun": ">=1.0.0"
24
24
  },
25
25
  "scripts": {
26
+ "cli": "bun run ./index.ts",
26
27
  "dev": "bun --watch run index.ts",
27
28
  "build": "bun build ./index.ts --outdir dist --target bun",
28
29
  "tsc": "tsc --noEmit",