@emmertarmin/redmine-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/dist/index.js +1920 -0
  4. package/package.json +36 -0
package/dist/index.js ADDED
@@ -0,0 +1,1920 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/commands/config.ts
5
+ import { stdin as input, stderr as output } from "process";
6
+ import { createInterface } from "readline/promises";
7
+
8
+ // src/config/load-config.ts
9
+ import { constants as fsConstants } from "fs";
10
+ import { access, mkdir, readFile, writeFile } from "fs/promises";
11
+ import { dirname } from "path";
12
+
13
+ // src/config/xdg.ts
14
+ import { homedir } from "os";
15
+ import { join } from "path";
16
+ function getConfigPath() {
17
+ const baseDir = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
18
+ return join(baseDir, "redmine", "config.json");
19
+ }
20
+ function getDataDir() {
21
+ const baseDir = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
22
+ return join(baseDir, "redmine");
23
+ }
24
+ function getDefaultIssueMirrorDir() {
25
+ return join(getDataDir(), "issues");
26
+ }
27
+ function expandHomePath(path) {
28
+ if (path === "~") {
29
+ return homedir();
30
+ }
31
+ if (path.startsWith("~/")) {
32
+ return join(homedir(), path.slice(2));
33
+ }
34
+ return path;
35
+ }
36
+
37
+ // src/config/load-config.ts
38
+ async function ensureConfigDir() {
39
+ await mkdir(dirname(getConfigPath()), { recursive: true });
40
+ }
41
+ var requiredConfigKeys = ["url", "key"];
42
+ function getMissingRequiredConfig(config) {
43
+ return requiredConfigKeys.filter((key) => !config[key]);
44
+ }
45
+ function formatMissingConfigWarning(missing) {
46
+ const names = missing.map((key) => `--${key}`).join(", ");
47
+ return `Missing required Redmine configuration: ${names}. Run \`redmine config setup\`.`;
48
+ }
49
+ async function loadConfig() {
50
+ const configPath = getConfigPath();
51
+ try {
52
+ await access(configPath, fsConstants.R_OK);
53
+ } catch {
54
+ return {};
55
+ }
56
+ const raw = await readFile(configPath, "utf8");
57
+ const config = JSON.parse(raw);
58
+ if (config.url !== undefined && typeof config.url !== "string") {
59
+ throw new Error(`Invalid config in ${configPath}: url must be a string`);
60
+ }
61
+ if (config.key !== undefined && typeof config.key !== "string") {
62
+ throw new Error(`Invalid config in ${configPath}: key must be a string`);
63
+ }
64
+ if (config.issueMirrorDir !== undefined && typeof config.issueMirrorDir !== "string") {
65
+ throw new Error(`Invalid config in ${configPath}: issueMirrorDir must be a string`);
66
+ }
67
+ return config;
68
+ }
69
+ function getIssueMirrorDir(config) {
70
+ return config.issueMirrorDir ? expandHomePath(config.issueMirrorDir) : getDefaultIssueMirrorDir();
71
+ }
72
+ async function saveConfig(config) {
73
+ await ensureConfigDir();
74
+ await writeFile(getConfigPath(), `${JSON.stringify(config, null, 2)}
75
+ `, "utf8");
76
+ }
77
+
78
+ // src/commands/config.ts
79
+ function optionalString(value) {
80
+ return typeof value === "string" ? value : undefined;
81
+ }
82
+ function maskApiKey(key) {
83
+ if (key.length <= 8) {
84
+ return "*****";
85
+ }
86
+ return `*****${key.slice(-4)}`;
87
+ }
88
+ function maskConfig(config) {
89
+ return {
90
+ ...config,
91
+ key: config.key === undefined ? undefined : maskApiKey(config.key)
92
+ };
93
+ }
94
+ var configGetCommand = {
95
+ name: "get",
96
+ summary: "Get Redmine configuration values",
97
+ description: "Print a single Redmine setting from the XDG config file.",
98
+ flags: [
99
+ {
100
+ name: "url",
101
+ type: "boolean",
102
+ description: "Print the configured Redmine server URL"
103
+ },
104
+ {
105
+ name: "key",
106
+ type: "boolean",
107
+ description: "Print the configured Redmine API key, obfuscated for identification"
108
+ },
109
+ {
110
+ name: "issue-mirror-dir",
111
+ type: "boolean",
112
+ description: "Print the configured issue mirror directory, or the default if unset"
113
+ }
114
+ ],
115
+ examples: ["redmine config get --url", "redmine config get --key", "redmine config get --issue-mirror-dir"],
116
+ execute: async ({ values, positionals }) => {
117
+ if (positionals.length > 0) {
118
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
119
+ }
120
+ const wantsUrl = values.url === true;
121
+ const wantsKey = values.key === true;
122
+ const wantsIssueMirrorDir = values["issue-mirror-dir"] === true;
123
+ if ([wantsUrl, wantsKey, wantsIssueMirrorDir].filter(Boolean).length !== 1) {
124
+ throw new Error("Specify exactly one setting to get: --url, --key, or --issue-mirror-dir.");
125
+ }
126
+ const config = await loadConfig();
127
+ if (wantsIssueMirrorDir) {
128
+ console.log(getIssueMirrorDir(config));
129
+ return;
130
+ }
131
+ const name = wantsUrl ? "url" : "key";
132
+ const value = config[name];
133
+ if (value === undefined) {
134
+ throw new Error(`Config value is not set: ${name}`);
135
+ }
136
+ console.log(name === "key" ? maskApiKey(value) : value);
137
+ }
138
+ };
139
+ var configListCommand = {
140
+ name: "list",
141
+ aliases: ["ls"],
142
+ summary: "List Redmine configuration",
143
+ description: "Print all configured Redmine settings from the XDG config file.",
144
+ examples: ["redmine config list"],
145
+ execute: async ({ positionals }) => {
146
+ if (positionals.length > 0) {
147
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
148
+ }
149
+ const config = await loadConfig();
150
+ console.log(JSON.stringify(maskConfig(config), null, 2));
151
+ }
152
+ };
153
+ async function createPromptReader() {
154
+ if (input.isTTY) {
155
+ return createInterface({ input, output });
156
+ }
157
+ const chunks = [];
158
+ for await (const chunk of input) {
159
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
160
+ }
161
+ const answers = Buffer.concat(chunks).toString("utf8").split(/\r?\n/);
162
+ let index = 0;
163
+ return {
164
+ question: async (message) => {
165
+ output.write(message);
166
+ return answers[index++] ?? "";
167
+ },
168
+ close: () => {}
169
+ };
170
+ }
171
+ async function prompt(readline, message, currentValue, options = {}) {
172
+ const suffix = currentValue ? ` [current: ${options.mask ? options.mask(currentValue) : currentValue}]` : "";
173
+ const answer = await readline.question(`${message}${suffix}: `);
174
+ const value = answer.trim();
175
+ return value === "" ? currentValue : value;
176
+ }
177
+ var configSetupCommand = {
178
+ name: "setup",
179
+ aliases: ["init"],
180
+ summary: "Interactively set up Redmine configuration",
181
+ description: "Prompt for the required Redmine URL and API key, then write them to the XDG config file.",
182
+ examples: ["redmine config setup"],
183
+ execute: async ({ positionals }) => {
184
+ if (positionals.length > 0) {
185
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
186
+ }
187
+ const currentConfig = await loadConfig();
188
+ const readline = await createPromptReader();
189
+ try {
190
+ output.write(`Redmine configuration setup (${getConfigPath()})
191
+ `);
192
+ const url = await prompt(readline, "Redmine URL", currentConfig.url);
193
+ const key = await prompt(readline, "Redmine API key", currentConfig.key, { mask: maskApiKey });
194
+ const nextConfig = {
195
+ ...currentConfig,
196
+ ...url ? { url } : {},
197
+ ...key ? { key } : {}
198
+ };
199
+ const missing = getMissingRequiredConfig(nextConfig);
200
+ if (missing.length > 0) {
201
+ throw new Error(formatMissingConfigWarning(missing));
202
+ }
203
+ await saveConfig(nextConfig);
204
+ console.log(`Wrote ${getConfigPath()}`);
205
+ } finally {
206
+ readline.close();
207
+ }
208
+ }
209
+ };
210
+ var configSetCommand = {
211
+ name: "set",
212
+ summary: "Set Redmine configuration",
213
+ description: "Write Redmine connection settings to the XDG config file.",
214
+ flags: [
215
+ {
216
+ name: "url",
217
+ type: "string",
218
+ description: "Redmine server URL"
219
+ },
220
+ {
221
+ name: "key",
222
+ type: "string",
223
+ description: "Redmine API key"
224
+ },
225
+ {
226
+ name: "issue-mirror-dir",
227
+ type: "string",
228
+ description: "Directory for the local Markdown issue mirror"
229
+ }
230
+ ],
231
+ examples: ["redmine config set --url https://redmine.example.com --key YOUR_API_KEY", "redmine config set --issue-mirror-dir ~/Documents/Redmine"],
232
+ execute: async ({ values, positionals }) => {
233
+ if (positionals.length > 0) {
234
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
235
+ }
236
+ const url = optionalString(values.url);
237
+ const key = optionalString(values.key);
238
+ const issueMirrorDir = optionalString(values["issue-mirror-dir"]);
239
+ if (url === undefined && key === undefined && issueMirrorDir === undefined) {
240
+ throw new Error("Nothing to set. Provide --url, --key, --issue-mirror-dir, or any combination.");
241
+ }
242
+ const nextConfig = {
243
+ ...await loadConfig()
244
+ };
245
+ if (url !== undefined) {
246
+ nextConfig.url = url;
247
+ }
248
+ if (key !== undefined) {
249
+ nextConfig.key = key;
250
+ }
251
+ if (issueMirrorDir !== undefined) {
252
+ nextConfig.issueMirrorDir = issueMirrorDir;
253
+ }
254
+ await saveConfig(nextConfig);
255
+ console.log(`Wrote ${getConfigPath()}`);
256
+ }
257
+ };
258
+ var configCommand = {
259
+ name: "config",
260
+ summary: "Manage configuration",
261
+ description: "Commands for Redmine settings stored in the XDG config file.",
262
+ subcommands: [configGetCommand, configSetCommand, configSetupCommand, configListCommand]
263
+ };
264
+
265
+ // src/commands/issues.ts
266
+ import { createInterface as createInterface2 } from "readline/promises";
267
+ import { stdin as input2, stderr as output2 } from "process";
268
+
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
+ // src/output/markdown.ts
423
+ function splitFrontmatter(markdown) {
424
+ if (!markdown.startsWith(`---
425
+ `)) {
426
+ return { body: markdown };
427
+ }
428
+ const end = markdown.indexOf(`
429
+ ---
430
+ `, 4);
431
+ if (end === -1) {
432
+ return { body: markdown };
433
+ }
434
+ return {
435
+ frontmatter: markdown.slice(4, end).trim(),
436
+ body: markdown.slice(end + `
437
+ ---
438
+ `.length)
439
+ };
440
+ }
441
+ function renderFrontmatter(frontmatter) {
442
+ const dim = "\x1B[2m";
443
+ const reset = "\x1B[0m";
444
+ return `${dim}---
445
+ ${frontmatter}
446
+ ---${reset}
447
+
448
+ `;
449
+ }
450
+ function renderMarkdownForTerminal(markdown, options = {}) {
451
+ const { frontmatter, body } = splitFrontmatter(markdown);
452
+ const renderedBody = Bun.markdown.ansi(body, {
453
+ colors: options.colors ?? true,
454
+ columns: options.columns ?? process.stdout.columns ?? 100,
455
+ hyperlinks: options.hyperlinks ?? true,
456
+ kittyGraphics: options.kittyGraphics
457
+ });
458
+ return `${frontmatter ? renderFrontmatter(frontmatter) : ""}${renderedBody}`;
459
+ }
460
+
461
+ // src/commands/issues.ts
462
+ var SPRINT_CUSTOM_FIELD_ID = 10;
463
+ var SPRINT_ANCHOR_START = "2026-06-08";
464
+ var SPRINT_ANCHOR_NUMBER = 12;
465
+ var DAY_MS = 24 * 60 * 60 * 1000;
466
+ var SPRINT_DAYS = 14;
467
+ var SPRINT_WORK_DAYS = 12;
468
+ var DESCRIPTION_PREVIEW_LENGTH = 180;
469
+ var DESCRIPTION_PREVIEW_HYSTERESIS_LENGTH = 220;
470
+ var DEFAULT_ISSUE_GET_INCLUDE = "journals,attachments,relations,changesets,watchers";
471
+ function optionalString2(value) {
472
+ return typeof value === "string" ? value : undefined;
473
+ }
474
+ function optionalNumber(value, fallback) {
475
+ if (value === undefined) {
476
+ return fallback;
477
+ }
478
+ if (typeof value !== "string") {
479
+ throw new Error("Expected a numeric value");
480
+ }
481
+ const parsed = Number.parseInt(value, 10);
482
+ if (!Number.isFinite(parsed) || parsed < 0) {
483
+ throw new Error(`Expected a non-negative integer, got: ${value}`);
484
+ }
485
+ return parsed;
486
+ }
487
+ function requirePositiveInteger(value, name) {
488
+ if (!value) {
489
+ throw new Error(`Expected ${name}`);
490
+ }
491
+ const parsed = Number.parseInt(value, 10);
492
+ if (!Number.isInteger(parsed) || String(parsed) !== value || parsed < 1) {
493
+ throw new Error(`Expected ${name} to be a positive integer, got: ${value}`);
494
+ }
495
+ return parsed;
496
+ }
497
+ function isIssueListResponse(value) {
498
+ if (!value || typeof value !== "object") {
499
+ return false;
500
+ }
501
+ const response = value;
502
+ return Array.isArray(response.issues) && typeof response.total_count === "number" && typeof response.offset === "number" && typeof response.limit === "number";
503
+ }
504
+ function isRecord(value) {
505
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
506
+ }
507
+ function namedReferenceName(value) {
508
+ if (!isRecord(value)) {
509
+ return;
510
+ }
511
+ const name = value.name;
512
+ if (typeof name === "string") {
513
+ return name;
514
+ }
515
+ const firstName = value.firstname;
516
+ const lastName = value.lastname;
517
+ if (typeof firstName === "string" && typeof lastName === "string") {
518
+ return `${lastName}, ${firstName}`;
519
+ }
520
+ const login = value.login;
521
+ return typeof login === "string" ? login : undefined;
522
+ }
523
+ function namedReferenceId(value) {
524
+ if (!isRecord(value)) {
525
+ return;
526
+ }
527
+ const id = value.id;
528
+ return typeof id === "string" || typeof id === "number" ? String(id) : undefined;
529
+ }
530
+ function createLookupMaps() {
531
+ return {
532
+ statuses: new Map,
533
+ trackers: new Map,
534
+ priorities: new Map,
535
+ customFields: new Map,
536
+ versions: new Map,
537
+ categories: new Map,
538
+ users: new Map
539
+ };
540
+ }
541
+ function addNamedReferencesToMap(map, values) {
542
+ if (!Array.isArray(values)) {
543
+ return;
544
+ }
545
+ for (const value of values) {
546
+ const id = namedReferenceId(value);
547
+ const name = namedReferenceName(value);
548
+ if (id && name) {
549
+ map.set(id, name);
550
+ }
551
+ }
552
+ }
553
+ function lookupArray(response, key) {
554
+ return isRecord(response) ? response[key] : undefined;
555
+ }
556
+ function mergeLookupMaps(target, source) {
557
+ for (const [key, map] of Object.entries(source)) {
558
+ for (const [id, name] of map) {
559
+ target[key].set(id, name);
560
+ }
561
+ }
562
+ }
563
+ function truncateDescription(value) {
564
+ if (typeof value !== "string") {
565
+ return;
566
+ }
567
+ const normalized = value.replace(/\s+/g, " ").trim();
568
+ if (!normalized) {
569
+ return;
570
+ }
571
+ if (normalized.length <= DESCRIPTION_PREVIEW_HYSTERESIS_LENGTH) {
572
+ return normalized;
573
+ }
574
+ return `${normalized.slice(0, DESCRIPTION_PREVIEW_LENGTH - 1).trimEnd()}\u2026`;
575
+ }
576
+ function summarizeIssue(issue) {
577
+ if (!issue || typeof issue !== "object") {
578
+ return {};
579
+ }
580
+ const record = issue;
581
+ return {
582
+ id: record.id,
583
+ 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),
590
+ sprint: getIssueSprintValue(issue) || undefined,
591
+ description_preview: truncateDescription(record.description),
592
+ updated_on: record.updated_on,
593
+ created_on: record.created_on,
594
+ spent_hours: record.spent_hours,
595
+ estimated_hours: record.estimated_hours,
596
+ done_ratio: record.done_ratio
597
+ };
598
+ }
599
+ function summarizeIssueListResponse(response, issues = response.issues) {
600
+ return {
601
+ ...response,
602
+ issues: issues.map((issue) => summarizeIssue(issue))
603
+ };
604
+ }
605
+ function outputFormat(values) {
606
+ const value = optionalString2(values.output) ?? "json";
607
+ if (value !== "json" && value !== "md") {
608
+ throw new Error(`Invalid value for --output: ${value}`);
609
+ }
610
+ return value;
611
+ }
612
+ function mdEscape(value) {
613
+ return String(value ?? "").replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\|/g, "\\|").replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
614
+ }
615
+ function scalarValue(value) {
616
+ if (value === undefined || value === null || value === "") {
617
+ return;
618
+ }
619
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
620
+ return String(value);
621
+ }
622
+ const name = namedReferenceName(value);
623
+ if (name) {
624
+ return name;
625
+ }
626
+ return;
627
+ }
628
+ function formatDateTime(value) {
629
+ if (typeof value !== "string" || !value) {
630
+ return;
631
+ }
632
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
633
+ return value;
634
+ }
635
+ const date = new Date(value);
636
+ return Number.isNaN(date.getTime()) ? value : date.toISOString();
637
+ }
638
+ function formatHours(value) {
639
+ if (typeof value !== "number") {
640
+ return scalarValue(value);
641
+ }
642
+ return `${value}h`;
643
+ }
644
+ function normalizeRedmineTextileToMarkdown(value) {
645
+ if (typeof value !== "string") {
646
+ return;
647
+ }
648
+ const codeBlocks = [];
649
+ const protectCodeBlock = (_match, language, body) => {
650
+ const index = codeBlocks.push(`
651
+
652
+ \`\`\`${language ? language.trim() : ""}
653
+ ${body.replace(/^\n|\n$/g, "")}
654
+ \`\`\`
655
+
656
+ `) - 1;
657
+ return `
658
+
659
+ @@REDMINE_CODE_BLOCK_${index}@@
660
+
661
+ `;
662
+ };
663
+ let text = value.replace(/\r\n/g, `
664
+ `).replace(/<pre><code(?:\s+class=["']?([^"'>\s]+)["']?)?>([\s\S]*?)<\/code><\/pre>/gi, protectCodeBlock).replace(/<pre>([\s\S]*?)<\/pre>/gi, (_match, body) => protectCodeBlock("", undefined, body)).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/^h([1-6])\.\s+/gm, (_, level) => `${"#".repeat(Number.parseInt(level, 10))} `).replace(/^bq\.\s+/gm, "> ").replace(/^p[<>=(){}[\]#.\w-]*\.\s+/gm, "").replace(/(^|[\s([{])\*([^*\n]+)\*(?=[\s).,!?:;\]}]|$)/g, "$1**$2**").replace(/\+([^+\n]+)\+/g, "$1").replace(/"([^"\n]+)":(https?:\/\/\S+)/g, "[$1]($2)").replace(/@([^@\n]+)@/g, "`$1`").replace(/\{\{(?:toc|>toc)\}\}/gi, "").trim();
665
+ text = text.replace(/@@REDMINE_CODE_BLOCK_(\d+)@@/g, (_match, index) => codeBlocks[Number.parseInt(index, 10)] ?? "");
666
+ return text || undefined;
667
+ }
668
+ function markdownTable(rows) {
669
+ const presentRows = rows.filter(([, value]) => value !== undefined && value !== "");
670
+ if (presentRows.length === 0) {
671
+ return "";
672
+ }
673
+ return ["| Field | Value |", "| --- | --- |", ...presentRows.map(([label, value]) => `| ${mdEscape(label)} | ${mdEscape(value)} |`)].join(`
674
+ `);
675
+ }
676
+ function customFieldsMarkdown(issue) {
677
+ if (!Array.isArray(issue.custom_fields)) {
678
+ return "";
679
+ }
680
+ const rows = issue.custom_fields.filter((field) => isRecord(field)).map((field) => [scalarValue(field.name) ?? `Custom field ${scalarValue(field.id) ?? ""}`.trim(), scalarValue(field.value)]);
681
+ return markdownTable(rows);
682
+ }
683
+ function yamlString(value) {
684
+ return JSON.stringify(String(value ?? ""));
685
+ }
686
+ function joinNameAndId(value) {
687
+ const name = namedReferenceName(value);
688
+ const id = namedReferenceId(value);
689
+ if (name && id)
690
+ return `${name} (#${id})`;
691
+ return name ?? id;
692
+ }
693
+ function bulletList(items) {
694
+ return items.length > 0 ? items.map((item) => `- ${item}`).join(`
695
+ `) : "";
696
+ }
697
+ function compactJson(value) {
698
+ if (value === undefined || value === null || value === "") {
699
+ return;
700
+ }
701
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
702
+ return String(value);
703
+ }
704
+ return JSON.stringify(value);
705
+ }
706
+ function summarizeChangedText(value) {
707
+ if (typeof value !== "string") {
708
+ return compactJson(value);
709
+ }
710
+ const normalized = value.replace(/\s+/g, " ").trim();
711
+ if (!normalized) {
712
+ return;
713
+ }
714
+ return `${normalized.length} chars${normalized.length <= 80 ? `: ${normalized}` : ""}`;
715
+ }
716
+ function journalDetailMarkdown(detail) {
717
+ const label = scalarValue(detail.label) ?? scalarValue(detail.name) ?? "Change";
718
+ const oldValue = scalarValue(detail.old_label) ?? compactJson(detail.old_value);
719
+ const newValue = scalarValue(detail.new_label) ?? compactJson(detail.new_value);
720
+ const property = scalarValue(detail.property);
721
+ const suffix = property && property !== "attr" ? ` (${mdEscape(property)})` : "";
722
+ if (detail.name === "description" || detail.name === "notes") {
723
+ const oldSummary = summarizeChangedText(detail.old_value);
724
+ const newSummary = summarizeChangedText(detail.new_value);
725
+ const summary = [oldSummary ? `previous ${oldSummary}` : undefined, newSummary ? `updated ${newSummary}` : undefined].filter(Boolean).join("; ");
726
+ return `- **${mdEscape(label)}${suffix}:** changed${summary ? ` (${mdEscape(summary)})` : ""}`;
727
+ }
728
+ const maxInlineLength = 160;
729
+ const oldInline = oldValue && oldValue.length > maxInlineLength ? `${oldValue.slice(0, maxInlineLength - 1).trimEnd()}\u2026` : oldValue;
730
+ const newInline = newValue && newValue.length > maxInlineLength ? `${newValue.slice(0, maxInlineLength - 1).trimEnd()}\u2026` : newValue;
731
+ if (oldInline !== undefined && newInline !== undefined) {
732
+ return `- **${mdEscape(label)}${suffix}:** \`${mdEscape(oldInline)}\` \u2192 \`${mdEscape(newInline)}\``;
733
+ }
734
+ if (newInline !== undefined) {
735
+ return `- **${mdEscape(label)}${suffix}:** set to \`${mdEscape(newInline)}\``;
736
+ }
737
+ if (oldInline !== undefined) {
738
+ return `- **${mdEscape(label)}${suffix}:** removed \`${mdEscape(oldInline)}\``;
739
+ }
740
+ return `- **${mdEscape(label)}${suffix}** changed`;
741
+ }
742
+ function issueResponseToMarkdown(response) {
743
+ const issue = isRecord(response) && isRecord(response.issue) ? response.issue : undefined;
744
+ if (!issue) {
745
+ return `---
746
+ resource: redmine_issue
747
+ generated_at: ${new Date().toISOString()}
748
+ ---
749
+
750
+ # Redmine issue
751
+
752
+ \`\`\`json
753
+ ${JSON.stringify(response, null, 2)}
754
+ \`\`\`
755
+ `;
756
+ }
757
+ const id = scalarValue(issue.id) ?? "?";
758
+ const subject = scalarValue(issue.subject) ?? "Untitled issue";
759
+ const frontmatter = [
760
+ "resource: redmine_issue",
761
+ `id: ${yamlString(id)}`,
762
+ `subject: ${yamlString(subject)}`,
763
+ `project: ${yamlString(namedReferenceName(issue.project) ?? "")}`,
764
+ `status: ${yamlString(namedReferenceName(issue.status) ?? "")}`,
765
+ `generated_at: ${new Date().toISOString()}`
766
+ ];
767
+ const parts = [`---
768
+ ${frontmatter.join(`
769
+ `)}
770
+ ---`, `# #${mdEscape(id)} ${mdEscape(subject)}`];
771
+ const sprint = getIssueSprintValue(issue);
772
+ const overview = markdownTable([
773
+ ["Project", joinNameAndId(issue.project)],
774
+ ["Tracker", joinNameAndId(issue.tracker)],
775
+ ["Status", joinNameAndId(issue.status)],
776
+ ["Priority", joinNameAndId(issue.priority)],
777
+ ["Category", joinNameAndId(issue.category)],
778
+ ["Target version", joinNameAndId(issue.fixed_version)],
779
+ ["Parent", joinNameAndId(issue.parent)],
780
+ ["Assignee", joinNameAndId(issue.assigned_to)],
781
+ ["Author", joinNameAndId(issue.author)],
782
+ ["Sprint", sprint],
783
+ ["Done", scalarValue(issue.done_ratio) ? `${scalarValue(issue.done_ratio)}%` : undefined],
784
+ ["Estimated", formatHours(issue.estimated_hours)],
785
+ ["Spent", formatHours(issue.spent_hours)],
786
+ ["Total spent", formatHours(issue.total_spent_hours)],
787
+ ["Start", scalarValue(issue.start_date)],
788
+ ["Due", scalarValue(issue.due_date)],
789
+ ["Created", formatDateTime(issue.created_on)],
790
+ ["Updated", formatDateTime(issue.updated_on)],
791
+ ["Closed", formatDateTime(issue.closed_on)],
792
+ ["Private", issue.is_private === true ? "yes" : undefined]
793
+ ]);
794
+ if (overview)
795
+ parts.push("## Overview", overview);
796
+ const description = normalizeRedmineTextileToMarkdown(issue.description);
797
+ if (description)
798
+ parts.push("## Description", description);
799
+ const customFields = customFieldsMarkdown(issue);
800
+ if (customFields)
801
+ parts.push("## Custom fields", customFields);
802
+ if (Array.isArray(issue.attachments) && issue.attachments.length > 0) {
803
+ const items = issue.attachments.filter(isRecord).map((attachment) => {
804
+ const filename = scalarValue(attachment.filename) ?? scalarValue(attachment.id) ?? "attachment";
805
+ const url = scalarValue(attachment.content_url);
806
+ const author = namedReferenceName(attachment.author);
807
+ const size = scalarValue(attachment.filesize);
808
+ const meta = [size ? `${size} bytes` : undefined, author ? `by ${author}` : undefined, formatDateTime(attachment.created_on)].filter(Boolean).join(", ");
809
+ return `${url ? `[${mdEscape(filename)}](${url})` : mdEscape(filename)}${meta ? ` \u2014 ${mdEscape(meta)}` : ""}`;
810
+ });
811
+ parts.push("## Attachments", bulletList(items));
812
+ }
813
+ if (Array.isArray(issue.relations) && issue.relations.length > 0) {
814
+ const rows = issue.relations.filter(isRecord).map((relation) => [
815
+ scalarValue(relation.relation_type) ?? "relation",
816
+ `#${scalarValue(relation.issue_id) ?? "?"} \u2194 #${scalarValue(relation.issue_to_id) ?? "?"}${scalarValue(relation.delay) ? ` (${scalarValue(relation.delay)}d)` : ""}`
817
+ ]);
818
+ parts.push("## Relations", markdownTable(rows));
819
+ }
820
+ 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());
822
+ parts.push("## Children", bulletList(items));
823
+ }
824
+ if (Array.isArray(issue.changesets) && issue.changesets.length > 0) {
825
+ const items = issue.changesets.filter(isRecord).map((changeset) => {
826
+ const revision = scalarValue(changeset.revision) ?? scalarValue(changeset.id) ?? "changeset";
827
+ const comments = scalarValue(changeset.comments);
828
+ const author = scalarValue(changeset.user) ?? scalarValue(changeset.author);
829
+ const committed = formatDateTime(changeset.committed_on);
830
+ return [`\`${mdEscape(revision)}\``, author, committed, comments].filter(Boolean).join(" \u2014 ");
831
+ });
832
+ parts.push("## Changesets", bulletList(items));
833
+ }
834
+ if (Array.isArray(issue.watchers) && issue.watchers.length > 0) {
835
+ parts.push("## Watchers", bulletList(issue.watchers.map((watcher) => mdEscape(joinNameAndId(watcher) ?? compactJson(watcher) ?? "watcher"))));
836
+ }
837
+ if (Array.isArray(issue.time_entries) && issue.time_entries.length > 0) {
838
+ const rows = issue.time_entries.filter(isRecord).map((entry) => [
839
+ 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 ")
841
+ ]);
842
+ parts.push("## Time entries", markdownTable(rows));
843
+ }
844
+ if (Array.isArray(issue.journals) && issue.journals.length > 0) {
845
+ parts.push("## Journal");
846
+ for (const journal of issue.journals.filter(isRecord)) {
847
+ const journalId = scalarValue(journal.id) ?? "?";
848
+ const author = namedReferenceName(journal.user) ?? "Unknown user";
849
+ const created = formatDateTime(journal.created_on) ?? scalarValue(journal.created_on) ?? "unknown date";
850
+ parts.push(`### Note ${mdEscape(journalId)} \u2014 ${mdEscape(author)} \u2014 ${mdEscape(created)}`);
851
+ const notes = normalizeRedmineTextileToMarkdown(journal.notes);
852
+ if (notes)
853
+ parts.push(notes);
854
+ if (Array.isArray(journal.details) && journal.details.length > 0) {
855
+ const detailBlocks = journal.details.filter(isRecord).map(journalDetailMarkdown);
856
+ parts.push(detailBlocks.join(`
857
+ `));
858
+ }
859
+ }
860
+ }
861
+ if (isRecord(issue._enrichment) && Array.isArray(issue._enrichment.warnings) && issue._enrichment.warnings.length > 0) {
862
+ parts.push("## Enrichment warnings", bulletList(issue._enrichment.warnings.map((warning) => mdEscape(compactJson(warning) ?? "warning"))));
863
+ }
864
+ const renderedKeys = new Set([
865
+ "id",
866
+ "subject",
867
+ "project",
868
+ "tracker",
869
+ "status",
870
+ "priority",
871
+ "category",
872
+ "fixed_version",
873
+ "parent",
874
+ "assigned_to",
875
+ "author",
876
+ "custom_fields",
877
+ "custom_fields_by_name",
878
+ "done_ratio",
879
+ "estimated_hours",
880
+ "spent_hours",
881
+ "total_spent_hours",
882
+ "start_date",
883
+ "due_date",
884
+ "created_on",
885
+ "updated_on",
886
+ "closed_on",
887
+ "is_private",
888
+ "description",
889
+ "attachments",
890
+ "relations",
891
+ "children",
892
+ "changesets",
893
+ "watchers",
894
+ "time_entries",
895
+ "journals",
896
+ "_enrichment"
897
+ ]);
898
+ const additionalData = Object.fromEntries(Object.entries(issue).filter(([key]) => !renderedKeys.has(key)));
899
+ if (Object.keys(additionalData).length > 0) {
900
+ parts.push("## Additional data", `\`\`\`json
901
+ ${JSON.stringify(additionalData, null, 2)}
902
+ \`\`\``);
903
+ }
904
+ return `${parts.filter(Boolean).join(`
905
+
906
+ `)}
907
+ `;
908
+ }
909
+ function issueListResponseToMarkdown(response, filters) {
910
+ const fetchedCount = response.issues.length;
911
+ const complete = response.offset + fetchedCount >= response.total_count;
912
+ const frontmatter = [
913
+ "resource: redmine_issue_list",
914
+ `total_count: ${response.total_count}`,
915
+ `fetched_count: ${fetchedCount}`,
916
+ `offset: ${response.offset}`,
917
+ `limit: ${response.limit}`,
918
+ `complete: ${complete}`,
919
+ `generated_at: ${new Date().toISOString()}`
920
+ ];
921
+ const activeFilters = [
922
+ filters.project ? `project=${filters.project}` : undefined,
923
+ filters.assignee ? `assignee=${filters.assignee}` : undefined,
924
+ filters.status ? `status=${filters.status}` : undefined,
925
+ filters.sprint ? `sprint=${filters.sprint.kind === "exact" ? filters.sprint.value : filters.sprint.kind}` : undefined
926
+ ].filter(Boolean);
927
+ const parts = [`---
928
+ ${frontmatter.join(`
929
+ `)}
930
+ ---`, "# Redmine issues", `Fetched **${fetchedCount}** of **${response.total_count}** issues${complete ? "." : " (partial result)."}`];
931
+ if (activeFilters.length > 0) {
932
+ parts.push(`Filters: ${activeFilters.map((filter) => `\`${filter}\``).join(", ")}`);
933
+ }
934
+ for (const issue of response.issues) {
935
+ if (!isRecord(issue)) {
936
+ continue;
937
+ }
938
+ const id = scalarValue(issue.id) ?? "?";
939
+ const subject = scalarValue(issue.subject) ?? "Untitled issue";
940
+ const sprint = getIssueSprintValue(issue);
941
+ 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]
959
+ ]);
960
+ parts.push(`## #${mdEscape(id)} ${mdEscape(subject)}`);
961
+ if (fields) {
962
+ parts.push(fields);
963
+ }
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);
971
+ }
972
+ }
973
+ return `${parts.join(`
974
+
975
+ `)}
976
+ `;
977
+ }
978
+ function printIssueListResponse(response, values, filters) {
979
+ if (outputFormat(values) === "md") {
980
+ process.stdout.write(renderMarkdownForTerminal(issueListResponseToMarkdown(response, filters)));
981
+ return;
982
+ }
983
+ console.log(JSON.stringify(values.raw === true ? response : summarizeIssueListResponse(response), null, 2));
984
+ }
985
+ async function redmineGetJsonOptional(config, path, query, cacheTtlMs) {
986
+ try {
987
+ return { data: cacheTtlMs === undefined ? await redmineGetJson(config, { path, query }) : await redmineGetJsonCached(config, { path, query }, cacheTtlMs) };
988
+ } catch (error) {
989
+ return { warning: `${path}: ${error instanceof Error ? error.message : String(error)}` };
990
+ }
991
+ }
992
+ function customFieldsByName(issue) {
993
+ const customFields = issue.custom_fields;
994
+ if (!Array.isArray(customFields)) {
995
+ return;
996
+ }
997
+ const byName = {};
998
+ for (const field of customFields) {
999
+ if (!isRecord(field) || typeof field.name !== "string") {
1000
+ continue;
1001
+ }
1002
+ byName[field.name] = field.value;
1003
+ }
1004
+ return byName;
1005
+ }
1006
+ function detailLabel(name, lookups) {
1007
+ if (lookups.customFields.has(name.replace(/^cf_/, ""))) {
1008
+ return lookups.customFields.get(name.replace(/^cf_/, ""));
1009
+ }
1010
+ const labels = {
1011
+ assigned_to_id: "Assignee",
1012
+ category_id: "Category",
1013
+ done_ratio: "Done ratio",
1014
+ due_date: "Due date",
1015
+ estimated_hours: "Estimated hours",
1016
+ fixed_version_id: "Target version",
1017
+ is_private: "Private",
1018
+ priority_id: "Priority",
1019
+ project_id: "Project",
1020
+ start_date: "Start date",
1021
+ status_id: "Status",
1022
+ subject: "Subject",
1023
+ tracker_id: "Tracker"
1024
+ };
1025
+ return labels[name];
1026
+ }
1027
+ function valueLabel(name, value, lookups) {
1028
+ if (value === undefined || value === null || value === "") {
1029
+ return;
1030
+ }
1031
+ const key = String(value);
1032
+ if (name === "status_id")
1033
+ return lookups.statuses.get(key);
1034
+ if (name === "tracker_id")
1035
+ return lookups.trackers.get(key);
1036
+ if (name === "priority_id")
1037
+ return lookups.priorities.get(key);
1038
+ if (name === "fixed_version_id")
1039
+ return lookups.versions.get(key);
1040
+ if (name === "category_id")
1041
+ return lookups.categories.get(key);
1042
+ if (name === "assigned_to_id" || name === "user_id" || name === "author_id")
1043
+ return lookups.users.get(key);
1044
+ if (name.startsWith("cf_"))
1045
+ return lookups.customFields.get(name.slice(3));
1046
+ return;
1047
+ }
1048
+ function enrichJournalDetail(detail, lookups) {
1049
+ if (!isRecord(detail) || typeof detail.name !== "string") {
1050
+ return detail;
1051
+ }
1052
+ const label = detailLabel(detail.name, lookups);
1053
+ const oldLabel = valueLabel(detail.name, detail.old_value, lookups);
1054
+ const newLabel = valueLabel(detail.name, detail.new_value, lookups);
1055
+ return {
1056
+ ...detail,
1057
+ ...label ? { label } : {},
1058
+ ...oldLabel ? { old_label: oldLabel } : {},
1059
+ ...newLabel ? { new_label: newLabel } : {}
1060
+ };
1061
+ }
1062
+ function enrichIssueResponse(response, enrichment) {
1063
+ if (!isRecord(response) || !isRecord(response.issue)) {
1064
+ return response;
1065
+ }
1066
+ const issue = response.issue;
1067
+ const customFields = customFieldsByName(issue);
1068
+ const journals = Array.isArray(issue.journals) ? issue.journals.map((journal) => {
1069
+ if (!isRecord(journal)) {
1070
+ return journal;
1071
+ }
1072
+ return {
1073
+ ...journal,
1074
+ details: Array.isArray(journal.details) ? journal.details.map((detail) => enrichJournalDetail(detail, enrichment.lookups)) : journal.details
1075
+ };
1076
+ }) : issue.journals;
1077
+ return {
1078
+ ...response,
1079
+ issue: {
1080
+ ...issue,
1081
+ ...customFields ? { custom_fields_by_name: customFields } : {},
1082
+ ...journals ? { journals } : {},
1083
+ ...enrichment.timeEntries ? { time_entries: enrichment.timeEntries } : {},
1084
+ _enrichment: {
1085
+ includes: DEFAULT_ISSUE_GET_INCLUDE.split(","),
1086
+ secondary_resources: ["time_entries", "issue_statuses", "trackers", "issue_priorities", "custom_fields", "versions", "issue_categories", "users"],
1087
+ warnings: enrichment.warnings
1088
+ }
1089
+ }
1090
+ };
1091
+ }
1092
+ function collectJournalUserIds(issue) {
1093
+ if (!isRecord(issue) || !Array.isArray(issue.journals)) {
1094
+ return [];
1095
+ }
1096
+ const ids = new Set;
1097
+ for (const journal of issue.journals) {
1098
+ if (!isRecord(journal)) {
1099
+ continue;
1100
+ }
1101
+ const userId = namedReferenceId(journal.user);
1102
+ if (userId)
1103
+ ids.add(userId);
1104
+ if (!Array.isArray(journal.details)) {
1105
+ continue;
1106
+ }
1107
+ for (const detail of journal.details) {
1108
+ if (!isRecord(detail) || typeof detail.name !== "string") {
1109
+ continue;
1110
+ }
1111
+ if (detail.name === "assigned_to_id" || detail.name === "user_id" || detail.name === "author_id") {
1112
+ for (const value of [detail.old_value, detail.new_value]) {
1113
+ if (typeof value === "string" || typeof value === "number") {
1114
+ ids.add(String(value));
1115
+ }
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1120
+ return [...ids];
1121
+ }
1122
+ async function fetchIssueEnrichment(config, issueId, issue) {
1123
+ const lookups = createLookupMaps();
1124
+ const warnings = [];
1125
+ if (isRecord(issue)) {
1126
+ addNamedReferencesToMap(lookups.users, [issue.author, issue.assigned_to]);
1127
+ }
1128
+ const projectId = isRecord(issue) ? namedReferenceId(issue.project) : undefined;
1129
+ const requests = await Promise.all([
1130
+ redmineGetJsonOptional(config, "/time_entries.json", { issue_id: issueId, limit: 100 }),
1131
+ redmineGetJsonOptional(config, "/issue_statuses.json", undefined, DAY_MS),
1132
+ redmineGetJsonOptional(config, "/trackers.json", undefined, DAY_MS),
1133
+ redmineGetJsonOptional(config, "/enumerations/issue_priorities.json", undefined, DAY_MS),
1134
+ redmineGetJsonOptional(config, "/custom_fields.json", undefined, DAY_MS),
1135
+ projectId ? redmineGetJsonOptional(config, `/projects/${projectId}/versions.json`, undefined, DAY_MS) : Promise.resolve({ data: undefined, warning: undefined }),
1136
+ projectId ? redmineGetJsonOptional(config, `/projects/${projectId}/issue_categories.json`, undefined, DAY_MS) : Promise.resolve({ data: undefined, warning: undefined })
1137
+ ]);
1138
+ for (const request of requests) {
1139
+ if (request.warning)
1140
+ warnings.push(request.warning);
1141
+ }
1142
+ const [timeEntries, statuses, trackers, priorities, customFields, versions, categories] = requests.map((request) => request.data);
1143
+ addNamedReferencesToMap(lookups.statuses, lookupArray(statuses, "issue_statuses"));
1144
+ addNamedReferencesToMap(lookups.trackers, lookupArray(trackers, "trackers"));
1145
+ addNamedReferencesToMap(lookups.priorities, lookupArray(priorities, "issue_priorities"));
1146
+ addNamedReferencesToMap(lookups.customFields, lookupArray(customFields, "custom_fields"));
1147
+ addNamedReferencesToMap(lookups.versions, lookupArray(versions, "versions"));
1148
+ addNamedReferencesToMap(lookups.categories, lookupArray(categories, "issue_categories"));
1149
+ const userIds = collectJournalUserIds(issue).filter((id) => !lookups.users.has(id)).slice(0, 25);
1150
+ const userRequests = await Promise.all(userIds.map((id) => redmineGetJsonOptional(config, `/users/${id}.json`, undefined, DAY_MS)));
1151
+ const userLookups = createLookupMaps();
1152
+ for (const userRequest of userRequests) {
1153
+ if (userRequest.warning) {
1154
+ warnings.push(userRequest.warning);
1155
+ continue;
1156
+ }
1157
+ if (isRecord(userRequest.data) && isRecord(userRequest.data.user)) {
1158
+ const id = namedReferenceId(userRequest.data.user);
1159
+ const name = namedReferenceName(userRequest.data.user);
1160
+ if (id && name) {
1161
+ userLookups.users.set(id, name);
1162
+ }
1163
+ }
1164
+ }
1165
+ mergeLookupMaps(lookups, userLookups);
1166
+ return {
1167
+ lookups,
1168
+ timeEntries: Array.isArray(lookupArray(timeEntries, "time_entries")) ? lookupArray(timeEntries, "time_entries") : undefined,
1169
+ warnings
1170
+ };
1171
+ }
1172
+ async function confirmAll(totalRequests) {
1173
+ const readline = createInterface2({ input: input2, output: output2 });
1174
+ try {
1175
+ const answer = await readline.question(`Fetch all? ${totalRequests} requests. Continue? [y/N] `);
1176
+ return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
1177
+ } finally {
1178
+ readline.close();
1179
+ }
1180
+ }
1181
+ function utcDateOnly(value) {
1182
+ return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
1183
+ }
1184
+ function addDays(value, days) {
1185
+ return new Date(value.getTime() + days * DAY_MS);
1186
+ }
1187
+ function formatSprintDate(value, includeYear) {
1188
+ const day = String(value.getUTCDate()).padStart(2, "0");
1189
+ const month = String(value.getUTCMonth() + 1).padStart(2, "0");
1190
+ if (!includeYear) {
1191
+ return `${day}.${month}.`;
1192
+ }
1193
+ const year = String(value.getUTCFullYear()).slice(-2);
1194
+ return `${day}.${month}.${year}`;
1195
+ }
1196
+ function currentSprintIndex() {
1197
+ const anchor = new Date(`${SPRINT_ANCHOR_START}T00:00:00Z`);
1198
+ const today = utcDateOnly(new Date);
1199
+ return Math.floor((today.getTime() - anchor.getTime()) / (SPRINT_DAYS * DAY_MS));
1200
+ }
1201
+ function currentSprintNumber() {
1202
+ return SPRINT_ANCHOR_NUMBER + currentSprintIndex();
1203
+ }
1204
+ function sprintLabel(offset) {
1205
+ const anchor = new Date(`${SPRINT_ANCHOR_START}T00:00:00Z`);
1206
+ const sprintIndex = currentSprintIndex() + offset;
1207
+ const sprintNumber = SPRINT_ANCHOR_NUMBER + sprintIndex;
1208
+ const start = addDays(anchor, sprintIndex * SPRINT_DAYS);
1209
+ const end = addDays(start, SPRINT_WORK_DAYS - 1);
1210
+ return `Sprint ${String(sprintNumber).padStart(2, "0")} (${formatSprintDate(start, false)} - ${formatSprintDate(end, true)})`;
1211
+ }
1212
+ function resolveSprint(value) {
1213
+ if (!value) {
1214
+ return;
1215
+ }
1216
+ switch (value.toLowerCase()) {
1217
+ case "backlog":
1218
+ return { kind: "exact", value: "Backlog" };
1219
+ case "current":
1220
+ return { kind: "exact", value: sprintLabel(0) };
1221
+ case "last":
1222
+ return { kind: "exact", value: sprintLabel(-1) };
1223
+ case "next":
1224
+ return { kind: "exact", value: sprintLabel(1) };
1225
+ case "past":
1226
+ return { kind: "past" };
1227
+ case "future":
1228
+ return { kind: "future" };
1229
+ default:
1230
+ return { kind: "exact", value };
1231
+ }
1232
+ }
1233
+ function resolveStatus(value) {
1234
+ if (!value || value === "open") {
1235
+ return "open";
1236
+ }
1237
+ if (value === "all") {
1238
+ return "*";
1239
+ }
1240
+ return value;
1241
+ }
1242
+ function buildIssueQuery(filters, limit, offset) {
1243
+ return {
1244
+ project_id: filters.project,
1245
+ assigned_to_id: filters.assignee,
1246
+ status_id: filters.status,
1247
+ [`cf_${SPRINT_CUSTOM_FIELD_ID}`]: filters.sprint?.kind === "exact" ? filters.sprint.value : undefined,
1248
+ sort: "updated_on:desc",
1249
+ limit,
1250
+ offset
1251
+ };
1252
+ }
1253
+ function filtersFromValues(values, overrides = {}) {
1254
+ 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))
1259
+ };
1260
+ }
1261
+ function getIssueSprintValue(issue) {
1262
+ if (!issue || typeof issue !== "object") {
1263
+ return;
1264
+ }
1265
+ const customFields = issue.custom_fields;
1266
+ if (!Array.isArray(customFields)) {
1267
+ return;
1268
+ }
1269
+ const sprintField = customFields.find((field) => {
1270
+ return Boolean(field && typeof field === "object" && field.id === SPRINT_CUSTOM_FIELD_ID);
1271
+ });
1272
+ return sprintField && typeof sprintField === "object" && typeof sprintField.value === "string" ? sprintField.value : undefined;
1273
+ }
1274
+ function sprintNumberFromValue(value) {
1275
+ const match = value?.match(/^Sprint\s+(\d+)\b/);
1276
+ if (!match) {
1277
+ return;
1278
+ }
1279
+ const parsed = Number.parseInt(match[1], 10);
1280
+ return Number.isFinite(parsed) ? parsed : undefined;
1281
+ }
1282
+ function issueMatchesRelativeSprint(issue, sprint) {
1283
+ const sprintNumber = sprintNumberFromValue(getIssueSprintValue(issue));
1284
+ if (sprintNumber === undefined) {
1285
+ return false;
1286
+ }
1287
+ const current = currentSprintNumber();
1288
+ return sprint.kind === "past" ? sprintNumber < current : sprintNumber > current;
1289
+ }
1290
+ async function fetchRemainingPages(config, firstPage, filters, pageLimit, confirm) {
1291
+ const totalPages = Math.ceil(firstPage.total_count / pageLimit);
1292
+ if (confirm && totalPages > 10 && !await confirmAll(totalPages)) {
1293
+ return;
1294
+ }
1295
+ const issues = [...firstPage.issues];
1296
+ for (let pageIndex = 1;pageIndex < totalPages; pageIndex += 1) {
1297
+ const page = await redmineGetJson(config, {
1298
+ path: "/issues.json",
1299
+ query: buildIssueQuery(filters, pageLimit, pageIndex * pageLimit)
1300
+ });
1301
+ if (!isIssueListResponse(page)) {
1302
+ throw new Error("Unexpected Redmine issue list response");
1303
+ }
1304
+ issues.push(...page.issues);
1305
+ }
1306
+ return issues;
1307
+ }
1308
+ async function executeIssueList(values, filters) {
1309
+ const limit = optionalNumber(values.limit, 25);
1310
+ if (limit < 1) {
1311
+ throw new Error("Expected --limit to be at least 1");
1312
+ }
1313
+ const offset = optionalNumber(values.offset, 0);
1314
+ const config = await loadConfig();
1315
+ if (filters.sprint?.kind === "past" || filters.sprint?.kind === "future") {
1316
+ const pageLimit = 100;
1317
+ const serverFilters = { ...filters, sprint: undefined };
1318
+ const firstPage2 = await redmineGetJson(config, {
1319
+ path: "/issues.json",
1320
+ query: buildIssueQuery(serverFilters, pageLimit, 0)
1321
+ });
1322
+ if (!isIssueListResponse(firstPage2)) {
1323
+ throw new Error("Unexpected Redmine issue list response");
1324
+ }
1325
+ const allIssues = await fetchRemainingPages(config, firstPage2, serverFilters, pageLimit, values.yes !== true);
1326
+ if (!allIssues) {
1327
+ return;
1328
+ }
1329
+ const filteredIssues = allIssues.filter((issue) => issueMatchesRelativeSprint(issue, filters.sprint));
1330
+ const issues2 = values.all === true ? filteredIssues.slice(offset) : filteredIssues.slice(offset, offset + limit);
1331
+ const response2 = { ...firstPage2, issues: issues2, total_count: filteredIssues.length, offset, limit: values.all === true ? issues2.length : limit };
1332
+ printIssueListResponse(response2, values, filters);
1333
+ return;
1334
+ }
1335
+ const firstPage = await redmineGetJson(config, {
1336
+ path: "/issues.json",
1337
+ query: buildIssueQuery(filters, limit, offset)
1338
+ });
1339
+ if (values.all !== true) {
1340
+ if (!isIssueListResponse(firstPage)) {
1341
+ throw new Error("Unexpected Redmine issue list response");
1342
+ }
1343
+ printIssueListResponse(firstPage, values, filters);
1344
+ return;
1345
+ }
1346
+ if (!isIssueListResponse(firstPage)) {
1347
+ throw new Error("Unexpected Redmine issue list response");
1348
+ }
1349
+ const totalRemaining = Math.max(firstPage.total_count - offset, 0);
1350
+ const totalPages = Math.ceil(totalRemaining / limit);
1351
+ if (totalPages > 10 && values.yes !== true && !await confirmAll(totalPages)) {
1352
+ return;
1353
+ }
1354
+ const issues = [...firstPage.issues];
1355
+ for (let pageIndex = 1;pageIndex < totalPages; pageIndex += 1) {
1356
+ const pageOffset = offset + pageIndex * limit;
1357
+ const page = await redmineGetJson(config, {
1358
+ path: "/issues.json",
1359
+ query: buildIssueQuery(filters, limit, pageOffset)
1360
+ });
1361
+ if (!isIssueListResponse(page)) {
1362
+ throw new Error("Unexpected Redmine issue list response");
1363
+ }
1364
+ issues.push(...page.issues);
1365
+ }
1366
+ const response = { ...firstPage, issues, offset, limit: issues.length };
1367
+ printIssueListResponse(response, values, filters);
1368
+ }
1369
+ var issueListFlags = [
1370
+ {
1371
+ name: "project",
1372
+ aliases: ["p"],
1373
+ type: "string",
1374
+ description: "Project identifier or ID to filter by"
1375
+ },
1376
+ {
1377
+ name: "assignee",
1378
+ type: "string",
1379
+ description: "Assignee user ID, or 'me'"
1380
+ },
1381
+ {
1382
+ name: "status",
1383
+ type: "string",
1384
+ description: "Issue status filter: open, closed, all, or a status ID",
1385
+ defaultValue: "open"
1386
+ },
1387
+ {
1388
+ name: "sprint",
1389
+ type: "string",
1390
+ description: "Sprint filter: backlog, current, last, next, past, future, or an exact Sprint custom-field value"
1391
+ },
1392
+ {
1393
+ name: "limit",
1394
+ type: "string",
1395
+ description: "Maximum number of issues to return",
1396
+ defaultValue: "25"
1397
+ },
1398
+ {
1399
+ name: "offset",
1400
+ type: "string",
1401
+ description: "Number of issues to skip",
1402
+ defaultValue: "0"
1403
+ },
1404
+ {
1405
+ name: "all",
1406
+ aliases: ["a"],
1407
+ type: "boolean",
1408
+ description: "Fetch all pages"
1409
+ },
1410
+ {
1411
+ name: "raw",
1412
+ type: "boolean",
1413
+ description: "Output unmodified Redmine issue list JSON"
1414
+ },
1415
+ {
1416
+ name: "output",
1417
+ aliases: ["o"],
1418
+ type: "string",
1419
+ choices: ["json", "md"],
1420
+ description: "Output format: json or md",
1421
+ defaultValue: "json"
1422
+ },
1423
+ {
1424
+ name: "yes",
1425
+ aliases: ["y"],
1426
+ type: "boolean",
1427
+ description: "Skip confirmation prompts"
1428
+ }
1429
+ ];
1430
+ var issueGetCommand = {
1431
+ name: "get",
1432
+ requiresConfig: true,
1433
+ aliases: ["show"],
1434
+ summary: "Get an issue",
1435
+ description: "Get a single Redmine issue as JSON. Journals, attachments, relations, changesets, and watchers are included by default.",
1436
+ arguments: [
1437
+ {
1438
+ name: "id",
1439
+ description: "Issue ID",
1440
+ required: true
1441
+ }
1442
+ ],
1443
+ flags: [
1444
+ {
1445
+ name: "include",
1446
+ aliases: ["i"],
1447
+ type: "string",
1448
+ description: "Comma-separated Redmine issue includes, or 'none'",
1449
+ defaultValue: DEFAULT_ISSUE_GET_INCLUDE
1450
+ },
1451
+ {
1452
+ name: "raw",
1453
+ type: "boolean",
1454
+ description: "Output only the primary Redmine issue response, without secondary detail enrichment"
1455
+ },
1456
+ {
1457
+ name: "output",
1458
+ aliases: ["o"],
1459
+ type: "string",
1460
+ choices: ["json", "md"],
1461
+ description: "Output format: json or md",
1462
+ defaultValue: "json"
1463
+ },
1464
+ {
1465
+ name: "no-secondary",
1466
+ type: "boolean",
1467
+ description: "Skip secondary requests for time entries and lookup metadata"
1468
+ }
1469
+ ],
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"],
1471
+ execute: async ({ values, positionals }) => {
1472
+ if (positionals.length !== 1) {
1473
+ throw new Error("Expected exactly one issue ID argument");
1474
+ }
1475
+ const issueId = requirePositiveInteger(positionals[0], "issue ID");
1476
+ const include = optionalString2(values.include);
1477
+ const config = await loadConfig();
1478
+ const response = await redmineGetJson(config, {
1479
+ path: `/issues/${issueId}.json`,
1480
+ query: { include: include && include !== "none" ? include : undefined }
1481
+ });
1482
+ const format = outputFormat(values);
1483
+ if (values.raw === true || values["no-secondary"] === true || !isRecord(response) || !isRecord(response.issue)) {
1484
+ if (format === "md") {
1485
+ process.stdout.write(renderMarkdownForTerminal(issueResponseToMarkdown(response)));
1486
+ return;
1487
+ }
1488
+ console.log(JSON.stringify(response, null, 2));
1489
+ return;
1490
+ }
1491
+ const enrichment = await fetchIssueEnrichment(config, issueId, response.issue);
1492
+ const enriched = enrichIssueResponse(response, enrichment);
1493
+ if (format === "md") {
1494
+ process.stdout.write(renderMarkdownForTerminal(issueResponseToMarkdown(enriched)));
1495
+ return;
1496
+ }
1497
+ console.log(JSON.stringify(enriched, null, 2));
1498
+ }
1499
+ };
1500
+ var issueListCommand = {
1501
+ name: "list",
1502
+ requiresConfig: true,
1503
+ summary: "List issues",
1504
+ description: "List Redmine issues. Results are filtered to semantically open issues by default.",
1505
+ flags: issueListFlags,
1506
+ examples: [
1507
+ "redmine issue list",
1508
+ "redmine issue list -p ai",
1509
+ "redmine issue list -p ai -o md",
1510
+ "redmine issue list --assignee me --sprint current",
1511
+ "redmine issue list --assignee 585 --status all",
1512
+ "redmine issue list --sprint backlog -a -y",
1513
+ "redmine issue list --assignee me --sprint past"
1514
+ ],
1515
+ execute: async ({ values, positionals }) => {
1516
+ if (positionals.length > 0) {
1517
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
1518
+ }
1519
+ await executeIssueList(values, filtersFromValues(values));
1520
+ }
1521
+ };
1522
+ var issueMineCommand = {
1523
+ name: "mine",
1524
+ requiresConfig: true,
1525
+ summary: "List my open issues",
1526
+ description: "List issues assigned to the configured Redmine user. Results are filtered to semantically open issues by default.",
1527
+ flags: issueListFlags.filter((flag) => flag.name !== "assignee"),
1528
+ examples: ["redmine issue mine", "redmine issue mine --sprint current", "redmine issue mine -p ai --sprint backlog"],
1529
+ execute: async ({ values, positionals }) => {
1530
+ if (positionals.length > 0) {
1531
+ throw new Error(`Unexpected argument: ${positionals[0]}`);
1532
+ }
1533
+ await executeIssueList(values, filtersFromValues(values, { assignee: "me" }));
1534
+ }
1535
+ };
1536
+ var issueAssignedCommand = {
1537
+ name: "assigned",
1538
+ requiresConfig: true,
1539
+ summary: "List issues assigned to a user",
1540
+ description: "List issues assigned to a Redmine user ID, or to 'me'. Results are filtered to semantically open issues by default.",
1541
+ arguments: [
1542
+ {
1543
+ name: "assignee",
1544
+ description: "Assignee user ID, or 'me'",
1545
+ required: true
1546
+ }
1547
+ ],
1548
+ flags: issueListFlags.filter((flag) => flag.name !== "assignee"),
1549
+ examples: ["redmine issue assigned me", "redmine issue assigned 585 --sprint current", "redmine issue assigned 585 -p ai"],
1550
+ execute: async ({ values, positionals }) => {
1551
+ if (positionals.length !== 1) {
1552
+ throw new Error("Expected exactly one assignee argument: user ID or 'me'");
1553
+ }
1554
+ await executeIssueList(values, filtersFromValues(values, { assignee: positionals[0] }));
1555
+ }
1556
+ };
1557
+ var issueCommand = {
1558
+ name: "issue",
1559
+ aliases: ["issues"],
1560
+ summary: "Work with issues",
1561
+ description: "Commands for Redmine issues.",
1562
+ subcommands: [issueGetCommand, issueListCommand, issueMineCommand, issueAssignedCommand]
1563
+ };
1564
+
1565
+ // src/cli/registry.ts
1566
+ var globalFlags = [
1567
+ {
1568
+ name: "help",
1569
+ aliases: ["h"],
1570
+ type: "boolean",
1571
+ description: "Show help"
1572
+ },
1573
+ {
1574
+ name: "version",
1575
+ aliases: ["v"],
1576
+ type: "boolean",
1577
+ description: "Show version"
1578
+ }
1579
+ ];
1580
+ var rootCommand = {
1581
+ name: "redmine",
1582
+ summary: "Redmine CLI",
1583
+ description: "Inspect and manage Redmine resources from the command line.",
1584
+ subcommands: [configCommand, issueCommand]
1585
+ };
1586
+ function getVisibleSubcommands(command) {
1587
+ return (command.subcommands ?? []).filter((entry) => entry.hidden !== true);
1588
+ }
1589
+ function findSubcommand(command, name) {
1590
+ return (command.subcommands ?? []).find((entry) => entry.name === name || (entry.aliases ?? []).includes(name));
1591
+ }
1592
+ function resolveCommandPath(args) {
1593
+ const path = [];
1594
+ let current = rootCommand;
1595
+ let index = 0;
1596
+ while (index < args.length) {
1597
+ const token = args[index];
1598
+ if (!token || token.startsWith("-")) {
1599
+ break;
1600
+ }
1601
+ const next = findSubcommand(current, token);
1602
+ if (!next) {
1603
+ break;
1604
+ }
1605
+ path.push(next);
1606
+ current = next;
1607
+ index += 1;
1608
+ }
1609
+ const nextToken = args[index];
1610
+ const unknownCommand = Boolean(path.length > 0 && nextToken && !nextToken.startsWith("-") && (current.subcommands ?? []).length > 0);
1611
+ const unknownTopLevelCommand = path.length === 0 && args[0] !== undefined && !args[0].startsWith("-");
1612
+ return {
1613
+ command: path.at(-1),
1614
+ path,
1615
+ consumed: index,
1616
+ unknownCommand,
1617
+ unknownTopLevelCommand
1618
+ };
1619
+ }
1620
+ function collectCommandFlags(command) {
1621
+ return [...globalFlags, ...command?.flags ?? []];
1622
+ }
1623
+
1624
+ // src/cli/help.ts
1625
+ function formatArgument(argument) {
1626
+ const base = argument.variadic ? `${argument.name}...` : argument.name;
1627
+ return argument.required ? `<${base}>` : `[${base}]`;
1628
+ }
1629
+ function formatFlag(flag) {
1630
+ const aliases = (flag.aliases ?? []).map((alias) => alias.length === 1 ? `-${alias}` : `--${alias}`);
1631
+ const label = ["--" + flag.name, ...aliases].join(", ");
1632
+ return `${label}${flag.type === "string" ? " <value>" : ""}`;
1633
+ }
1634
+ function formatFlagDetails(flag) {
1635
+ const details = [];
1636
+ if (flag.required) {
1637
+ details.push("required");
1638
+ }
1639
+ if (flag.defaultValue !== undefined) {
1640
+ details.push(`default: ${String(flag.defaultValue)}`);
1641
+ }
1642
+ if (flag.choices && flag.choices.length > 0) {
1643
+ details.push(`choices: ${flag.choices.join(", ")}`);
1644
+ }
1645
+ return details.length > 0 ? ` (${details.join("; ")})` : "";
1646
+ }
1647
+ function printUsage(command, path) {
1648
+ const names = path.map((entry) => entry.name);
1649
+ const commandPath = names.length > 0 ? ` ${names.join(" ")}` : "";
1650
+ const executable = typeof command.execute === "function";
1651
+ const subcommands = getVisibleSubcommands(command);
1652
+ const hasOptions = collectCommandFlags(command).length > 0;
1653
+ const argumentList = (command.arguments ?? []).map(formatArgument).join(" ");
1654
+ console.log("Usage:");
1655
+ if (subcommands.length > 0) {
1656
+ console.log(` redmine${commandPath} <subcommand>`);
1657
+ }
1658
+ if (executable) {
1659
+ const parts = [`redmine${commandPath}`];
1660
+ if (hasOptions) {
1661
+ parts.push("[options]");
1662
+ }
1663
+ if (argumentList) {
1664
+ parts.push(argumentList);
1665
+ }
1666
+ console.log(` ${parts.join(" ")}`);
1667
+ }
1668
+ }
1669
+ function printArguments(command) {
1670
+ const argumentsList = command.arguments ?? [];
1671
+ if (argumentsList.length === 0) {
1672
+ return;
1673
+ }
1674
+ console.log();
1675
+ console.log("Arguments:");
1676
+ for (const argument of argumentsList) {
1677
+ console.log(` ${formatArgument(argument)}`);
1678
+ console.log(` ${argument.description}`);
1679
+ }
1680
+ }
1681
+ function printFlags(command) {
1682
+ const flags = collectCommandFlags(command).filter((flag) => flag.hidden !== true);
1683
+ if (flags.length === 0) {
1684
+ return;
1685
+ }
1686
+ console.log();
1687
+ console.log("Options:");
1688
+ for (const flag of flags) {
1689
+ console.log(` ${formatFlag(flag)}`);
1690
+ console.log(` ${flag.description}${formatFlagDetails(flag)}`);
1691
+ }
1692
+ }
1693
+ function printSubcommands(command, path) {
1694
+ const subcommands = getVisibleSubcommands(command);
1695
+ if (subcommands.length === 0) {
1696
+ return;
1697
+ }
1698
+ const prefix = path.map((entry) => entry.name).join(" ");
1699
+ console.log();
1700
+ console.log("Subcommands:");
1701
+ for (const subcommand of subcommands) {
1702
+ const label = prefix ? `${prefix} ${subcommand.name}` : subcommand.name;
1703
+ console.log(` ${label} - ${subcommand.summary}`);
1704
+ }
1705
+ }
1706
+ function printExamples(command) {
1707
+ if (!command.examples || command.examples.length === 0) {
1708
+ return;
1709
+ }
1710
+ console.log();
1711
+ console.log("Examples:");
1712
+ for (const example of command.examples) {
1713
+ console.log(` ${example}`);
1714
+ }
1715
+ }
1716
+ function printCommandHelp(command, path) {
1717
+ const title = path.length === 0 ? rootCommand.name : `${rootCommand.name} ${path.map((entry) => entry.name).join(" ")}`;
1718
+ console.log(title);
1719
+ console.log();
1720
+ console.log(command.description ?? command.summary);
1721
+ console.log();
1722
+ printUsage(command, path);
1723
+ printArguments(command);
1724
+ printFlags(command);
1725
+ printSubcommands(command, path);
1726
+ printExamples(command);
1727
+ }
1728
+ function printRootHelp() {
1729
+ printCommandHelp(rootCommand, []);
1730
+ console.log();
1731
+ console.log("Run `redmine <command> --help` for command-specific help.");
1732
+ }
1733
+
1734
+ // src/cli/parse.ts
1735
+ function buildFlagMaps(flags) {
1736
+ const byLongName = new Map;
1737
+ const byAlias = new Map;
1738
+ for (const flag of flags) {
1739
+ byLongName.set(flag.name, flag);
1740
+ for (const alias of flag.aliases ?? []) {
1741
+ byAlias.set(alias, flag);
1742
+ }
1743
+ }
1744
+ return { byLongName, byAlias };
1745
+ }
1746
+ function parseFlagToken(arg, maps) {
1747
+ if (arg.startsWith("--")) {
1748
+ const body = arg.slice(2);
1749
+ const [name, inlineValue] = body.split("=", 2);
1750
+ return {
1751
+ flag: maps.byLongName.get(name),
1752
+ inlineValue,
1753
+ displayName: `--${name}`
1754
+ };
1755
+ }
1756
+ if (arg.startsWith("-") && arg.length > 1) {
1757
+ const alias = arg.slice(1);
1758
+ return {
1759
+ flag: maps.byAlias.get(alias),
1760
+ inlineValue: undefined,
1761
+ displayName: `-${alias}`
1762
+ };
1763
+ }
1764
+ return {
1765
+ flag: undefined,
1766
+ inlineValue: undefined,
1767
+ displayName: arg
1768
+ };
1769
+ }
1770
+ function parseValues(args, flags) {
1771
+ const maps = buildFlagMaps(flags);
1772
+ const values = {};
1773
+ const positionals = [];
1774
+ for (let index = 0;index < args.length; index += 1) {
1775
+ const arg = args[index];
1776
+ if (arg === "--") {
1777
+ positionals.push(...args.slice(index + 1));
1778
+ break;
1779
+ }
1780
+ if (!arg.startsWith("-")) {
1781
+ positionals.push(arg);
1782
+ continue;
1783
+ }
1784
+ const { flag, inlineValue, displayName } = parseFlagToken(arg, maps);
1785
+ if (!flag) {
1786
+ throw new Error(`Unknown option: ${displayName}`);
1787
+ }
1788
+ if (flag.type === "boolean") {
1789
+ if (inlineValue !== undefined) {
1790
+ throw new Error(`Option ${displayName} does not take a value`);
1791
+ }
1792
+ values[flag.name] = true;
1793
+ continue;
1794
+ }
1795
+ const value = inlineValue ?? args[index + 1];
1796
+ if (value === undefined || value.startsWith("-")) {
1797
+ throw new Error(`Missing value for option ${displayName}`);
1798
+ }
1799
+ values[flag.name] = value;
1800
+ index += inlineValue === undefined ? 1 : 0;
1801
+ }
1802
+ const wantsHelp = values.help === true;
1803
+ for (const flag of flags) {
1804
+ if (values[flag.name] === undefined && flag.defaultValue !== undefined) {
1805
+ values[flag.name] = flag.defaultValue;
1806
+ }
1807
+ if (!wantsHelp && flag.required && values[flag.name] === undefined) {
1808
+ throw new Error(`Missing required option: --${flag.name}`);
1809
+ }
1810
+ const value = values[flag.name];
1811
+ if (flag.choices && typeof value === "string" && !flag.choices.includes(value)) {
1812
+ throw new Error(`Invalid value for --${flag.name}: ${value}`);
1813
+ }
1814
+ }
1815
+ return { values, positionals };
1816
+ }
1817
+ function parseCli(args) {
1818
+ const resolved = resolveCommandPath(args);
1819
+ if (resolved.unknownTopLevelCommand) {
1820
+ throw new Error(`Unknown command: ${args[0]}`);
1821
+ }
1822
+ if (resolved.unknownCommand) {
1823
+ const unknownToken = args[resolved.consumed];
1824
+ const commandPath = resolved.path.map((entry) => entry.name).join(" ");
1825
+ throw new Error(`Unknown subcommand for ${commandPath}: ${unknownToken}`);
1826
+ }
1827
+ const command = resolved.command;
1828
+ const flags = collectCommandFlags(command);
1829
+ const parsed = parseValues(args.slice(resolved.consumed), flags);
1830
+ return {
1831
+ command,
1832
+ path: resolved.path,
1833
+ values: parsed.values,
1834
+ positionals: parsed.positionals
1835
+ };
1836
+ }
1837
+ // package.json
1838
+ var package_default = {
1839
+ name: "@emmertarmin/redmine-cli",
1840
+ version: "0.1.0",
1841
+ description: "A personal Redmine CLI.",
1842
+ license: "MIT",
1843
+ repository: {
1844
+ type: "git",
1845
+ url: "git+https://github.com/emmertarmin/redmine-cli.git"
1846
+ },
1847
+ publishConfig: {
1848
+ access: "public"
1849
+ },
1850
+ type: "module",
1851
+ bin: {
1852
+ redmine: "dist/index.js"
1853
+ },
1854
+ files: [
1855
+ "dist",
1856
+ "README.md",
1857
+ "LICENSE"
1858
+ ],
1859
+ engines: {
1860
+ bun: ">=1.0.0"
1861
+ },
1862
+ scripts: {
1863
+ dev: "bun --watch run index.ts",
1864
+ build: "bun build ./index.ts --outdir dist --target bun",
1865
+ tsc: "tsc --noEmit",
1866
+ prepublishOnly: "bun run build"
1867
+ },
1868
+ devDependencies: {
1869
+ "@types/bun": "latest",
1870
+ "@types/node": "^22.15.21",
1871
+ typescript: "^5.8.3"
1872
+ }
1873
+ };
1874
+
1875
+ // src/version.ts
1876
+ var VERSION = package_default.version;
1877
+ function printVersion() {
1878
+ console.log(`redmine v${VERSION}`);
1879
+ }
1880
+
1881
+ // src/index.ts
1882
+ async function main() {
1883
+ await ensureConfigDir();
1884
+ const parsed = parseCli(process.argv.slice(2));
1885
+ const wantsHelp = parsed.values.help === true;
1886
+ const wantsVersion = parsed.values.version === true;
1887
+ if (wantsVersion) {
1888
+ printVersion();
1889
+ return;
1890
+ }
1891
+ if (!parsed.command) {
1892
+ if (wantsHelp || parsed.positionals.length === 0) {
1893
+ printRootHelp();
1894
+ return;
1895
+ }
1896
+ throw new Error("Missing command. Run `redmine --help` to see available commands.");
1897
+ }
1898
+ if (wantsHelp || typeof parsed.command.execute !== "function") {
1899
+ printCommandHelp(parsed.command, parsed.path);
1900
+ return;
1901
+ }
1902
+ if (parsed.command.requiresConfig === true) {
1903
+ const missing = getMissingRequiredConfig(await loadConfig());
1904
+ if (missing.length > 0) {
1905
+ console.error(`Warning: ${formatMissingConfigWarning(missing)}`);
1906
+ process.exit(1);
1907
+ }
1908
+ }
1909
+ await parsed.command.execute({
1910
+ values: parsed.values,
1911
+ positionals: parsed.positionals
1912
+ });
1913
+ }
1914
+ main().then(() => {
1915
+ process.exit(0);
1916
+ }).catch((error) => {
1917
+ const message = error instanceof Error ? error.message : String(error);
1918
+ console.error(`Error: ${message}`);
1919
+ process.exit(1);
1920
+ });