@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.
- package/README.md +17 -0
- package/dist/index.js +584 -219
- 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/
|
|
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 =
|
|
237
|
-
const key =
|
|
238
|
-
const issueMirrorDir =
|
|
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
|
|
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
|
|
862
|
+
function isRecord2(value) {
|
|
505
863
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
506
864
|
}
|
|
507
|
-
function
|
|
508
|
-
if (!
|
|
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 (!
|
|
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 =
|
|
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
|
|
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:
|
|
585
|
-
tracker:
|
|
586
|
-
status:
|
|
587
|
-
priority:
|
|
588
|
-
assignee:
|
|
589
|
-
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
|
|
606
|
-
const value =
|
|
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 =
|
|
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) =>
|
|
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 =
|
|
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 =
|
|
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(
|
|
764
|
-
`status: ${yamlString(
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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),
|
|
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(
|
|
1204
|
+
for (const journal of issue.journals.filter(isRecord2)) {
|
|
847
1205
|
const journalId = scalarValue(journal.id) ?? "?";
|
|
848
|
-
const author =
|
|
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(
|
|
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 (
|
|
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 (!
|
|
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 (
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
1473
|
+
if (isRecord2(issue)) {
|
|
1116
1474
|
addNamedReferencesToMap(lookups.users, [issue.author, issue.assigned_to]);
|
|
1117
1475
|
}
|
|
1118
|
-
const projectId =
|
|
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 (
|
|
1505
|
+
if (isRecord2(userRequest.data) && isRecord2(userRequest.data.user)) {
|
|
1148
1506
|
const id = namedReferenceId(userRequest.data.user);
|
|
1149
|
-
const name =
|
|
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:
|
|
1246
|
-
assignee: overrides.assignee ??
|
|
1247
|
-
status: overrides.status ?? resolveStatus(
|
|
1248
|
-
sprint: overrides.sprint ?? resolveSprint(
|
|
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
|
|
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: "
|
|
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
|
|
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 =
|
|
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 =
|
|
1473
|
-
if (values.raw === true || values["no-secondary"] === true || !
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|