@emmertarmin/redmine-cli 0.1.2 → 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 +584 -219
  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,7 +1290,7 @@ ${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
1296
  const summary = summarizeIssue(issue);
@@ -966,7 +1324,7 @@ ${frontmatter.join(`
966
1324
  `;
967
1325
  }
968
1326
  function printIssueListResponse(response, values, filters) {
969
- if (outputFormat(values) === "md") {
1327
+ if (outputFormat2(values) === "md") {
970
1328
  process.stdout.write(renderMarkdownForTerminal(issueListResponseToMarkdown(response, filters)));
971
1329
  return;
972
1330
  }
@@ -986,7 +1344,7 @@ function customFieldsByName(issue) {
986
1344
  }
987
1345
  const byName = {};
988
1346
  for (const field of customFields) {
989
- if (!isRecord(field) || typeof field.name !== "string") {
1347
+ if (!isRecord2(field) || typeof field.name !== "string") {
990
1348
  continue;
991
1349
  }
992
1350
  byName[field.name] = field.value;
@@ -1036,7 +1394,7 @@ function valueLabel(name, value, lookups) {
1036
1394
  return;
1037
1395
  }
1038
1396
  function enrichJournalDetail(detail, lookups) {
1039
- if (!isRecord(detail) || typeof detail.name !== "string") {
1397
+ if (!isRecord2(detail) || typeof detail.name !== "string") {
1040
1398
  return detail;
1041
1399
  }
1042
1400
  const label = detailLabel(detail.name, lookups);
@@ -1050,13 +1408,13 @@ function enrichJournalDetail(detail, lookups) {
1050
1408
  };
1051
1409
  }
1052
1410
  function enrichIssueResponse(response, enrichment) {
1053
- if (!isRecord(response) || !isRecord(response.issue)) {
1411
+ if (!isRecord2(response) || !isRecord2(response.issue)) {
1054
1412
  return response;
1055
1413
  }
1056
1414
  const issue = response.issue;
1057
1415
  const customFields = customFieldsByName(issue);
1058
1416
  const journals = Array.isArray(issue.journals) ? issue.journals.map((journal) => {
1059
- if (!isRecord(journal)) {
1417
+ if (!isRecord2(journal)) {
1060
1418
  return journal;
1061
1419
  }
1062
1420
  return {
@@ -1080,12 +1438,12 @@ function enrichIssueResponse(response, enrichment) {
1080
1438
  };
1081
1439
  }
1082
1440
  function collectJournalUserIds(issue) {
1083
- if (!isRecord(issue) || !Array.isArray(issue.journals)) {
1441
+ if (!isRecord2(issue) || !Array.isArray(issue.journals)) {
1084
1442
  return [];
1085
1443
  }
1086
1444
  const ids = new Set;
1087
1445
  for (const journal of issue.journals) {
1088
- if (!isRecord(journal)) {
1446
+ if (!isRecord2(journal)) {
1089
1447
  continue;
1090
1448
  }
1091
1449
  const userId = namedReferenceId(journal.user);
@@ -1095,7 +1453,7 @@ function collectJournalUserIds(issue) {
1095
1453
  continue;
1096
1454
  }
1097
1455
  for (const detail of journal.details) {
1098
- if (!isRecord(detail) || typeof detail.name !== "string") {
1456
+ if (!isRecord2(detail) || typeof detail.name !== "string") {
1099
1457
  continue;
1100
1458
  }
1101
1459
  if (detail.name === "assigned_to_id" || detail.name === "user_id" || detail.name === "author_id") {
@@ -1112,10 +1470,10 @@ function collectJournalUserIds(issue) {
1112
1470
  async function fetchIssueEnrichment(config, issueId, issue) {
1113
1471
  const lookups = createLookupMaps();
1114
1472
  const warnings = [];
1115
- if (isRecord(issue)) {
1473
+ if (isRecord2(issue)) {
1116
1474
  addNamedReferencesToMap(lookups.users, [issue.author, issue.assigned_to]);
1117
1475
  }
1118
- const projectId = isRecord(issue) ? namedReferenceId(issue.project) : undefined;
1476
+ const projectId = isRecord2(issue) ? namedReferenceId(issue.project) : undefined;
1119
1477
  const requests = await Promise.all([
1120
1478
  redmineGetJsonOptional(config, "/time_entries.json", { issue_id: issueId, limit: 100 }),
1121
1479
  redmineGetJsonOptional(config, "/issue_statuses.json", undefined, DAY_MS),
@@ -1144,9 +1502,9 @@ async function fetchIssueEnrichment(config, issueId, issue) {
1144
1502
  warnings.push(userRequest.warning);
1145
1503
  continue;
1146
1504
  }
1147
- if (isRecord(userRequest.data) && isRecord(userRequest.data.user)) {
1505
+ if (isRecord2(userRequest.data) && isRecord2(userRequest.data.user)) {
1148
1506
  const id = namedReferenceId(userRequest.data.user);
1149
- const name = namedReferenceName(userRequest.data.user);
1507
+ const name = namedReferenceName2(userRequest.data.user);
1150
1508
  if (id && name) {
1151
1509
  userLookups.users.set(id, name);
1152
1510
  }
@@ -1242,10 +1600,10 @@ function buildIssueQuery(filters, limit, offset) {
1242
1600
  }
1243
1601
  function filtersFromValues(values, overrides = {}) {
1244
1602
  return {
1245
- project: optionalString2(values.project),
1246
- assignee: overrides.assignee ?? optionalString2(values.assignee),
1247
- status: overrides.status ?? resolveStatus(optionalString2(values.status)),
1248
- 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))
1249
1607
  };
1250
1608
  }
1251
1609
  function getIssueSprintValue(issue) {
@@ -1422,7 +1780,7 @@ var issueGetCommand = {
1422
1780
  requiresConfig: true,
1423
1781
  aliases: ["show"],
1424
1782
  summary: "Get an issue",
1425
- 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.",
1426
1784
  arguments: [
1427
1785
  {
1428
1786
  name: "id",
@@ -1449,7 +1807,7 @@ var issueGetCommand = {
1449
1807
  type: "string",
1450
1808
  choices: ["json", "md"],
1451
1809
  description: "Output format: json or md",
1452
- defaultValue: "json"
1810
+ defaultValue: "md"
1453
1811
  },
1454
1812
  {
1455
1813
  name: "no-secondary",
@@ -1457,20 +1815,20 @@ var issueGetCommand = {
1457
1815
  description: "Skip secondary requests for time entries and lookup metadata"
1458
1816
  }
1459
1817
  ],
1460
- 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"],
1461
1819
  execute: async ({ values, positionals }) => {
1462
1820
  if (positionals.length !== 1) {
1463
1821
  throw new Error("Expected exactly one issue ID argument");
1464
1822
  }
1465
1823
  const issueId = requirePositiveInteger(positionals[0], "issue ID");
1466
- const include = optionalString2(values.include);
1824
+ const include = optionalString3(values.include);
1467
1825
  const config = await loadConfig();
1468
1826
  const response = await redmineGetJson(config, {
1469
1827
  path: `/issues/${issueId}.json`,
1470
1828
  query: { include: include && include !== "none" ? include : undefined }
1471
1829
  });
1472
- const format = outputFormat(values);
1473
- 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)) {
1474
1832
  if (format === "md") {
1475
1833
  process.stdout.write(renderMarkdownForTerminal(issueResponseToMarkdown(response)));
1476
1834
  return;
@@ -1571,7 +1929,7 @@ var rootCommand = {
1571
1929
  name: "redmine",
1572
1930
  summary: "Redmine CLI",
1573
1931
  description: "Inspect and manage Redmine resources from the command line.",
1574
- subcommands: [configCommand, issueCommand]
1932
+ subcommands: [configCommand, issueCommand, activityCommand]
1575
1933
  };
1576
1934
  function getVisibleSubcommands(command) {
1577
1935
  return (command.subcommands ?? []).filter((entry) => entry.hidden !== true);
@@ -1608,7 +1966,13 @@ function resolveCommandPath(args) {
1608
1966
  };
1609
1967
  }
1610
1968
  function collectCommandFlags(command) {
1611
- 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];
1612
1976
  }
1613
1977
 
1614
1978
  // src/cli/help.ts
@@ -1827,7 +2191,7 @@ function parseCli(args) {
1827
2191
  // package.json
1828
2192
  var package_default = {
1829
2193
  name: "@emmertarmin/redmine-cli",
1830
- version: "0.1.2",
2194
+ version: "0.1.3",
1831
2195
  description: "A personal Redmine CLI.",
1832
2196
  license: "MIT",
1833
2197
  repository: {
@@ -1850,6 +2214,7 @@ var package_default = {
1850
2214
  bun: ">=1.0.0"
1851
2215
  },
1852
2216
  scripts: {
2217
+ cli: "bun run ./index.ts",
1853
2218
  dev: "bun --watch run index.ts",
1854
2219
  build: "bun build ./index.ts --outdir dist --target bun",
1855
2220
  tsc: "tsc --noEmit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emmertarmin/redmine-cli",
3
- "version": "0.1.2",
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",