@emmertarmin/redmine-cli 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/index.js +601 -246
- 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,42 +1290,32 @@ ${frontmatter.join(`
|
|
|
932
1290
|
parts.push(`Filters: ${activeFilters.map((filter) => `\`${filter}\``).join(", ")}`);
|
|
933
1291
|
}
|
|
934
1292
|
for (const issue of response.issues) {
|
|
935
|
-
if (!
|
|
1293
|
+
if (!isRecord2(issue)) {
|
|
936
1294
|
continue;
|
|
937
1295
|
}
|
|
938
|
-
const
|
|
939
|
-
const
|
|
940
|
-
const
|
|
1296
|
+
const summary = summarizeIssue(issue);
|
|
1297
|
+
const id = scalarValue(summary.id) ?? "?";
|
|
1298
|
+
const subject = scalarValue(summary.subject) ?? "Untitled issue";
|
|
941
1299
|
const fields = markdownTable([
|
|
942
|
-
["Project",
|
|
943
|
-
["Tracker",
|
|
944
|
-
["Status",
|
|
945
|
-
["Priority",
|
|
946
|
-
["Assignee",
|
|
947
|
-
["Author",
|
|
948
|
-
["Sprint", sprint],
|
|
949
|
-
["Done", scalarValue(
|
|
950
|
-
["Estimated", formatHours(
|
|
951
|
-
["Spent", formatHours(
|
|
952
|
-
["
|
|
953
|
-
["
|
|
954
|
-
["Due", scalarValue(issue.due_date)],
|
|
955
|
-
["Created", formatDateTime(issue.created_on)],
|
|
956
|
-
["Updated", formatDateTime(issue.updated_on)],
|
|
957
|
-
["Closed", formatDateTime(issue.closed_on)],
|
|
958
|
-
["Private", issue.is_private === true ? "yes" : undefined]
|
|
1300
|
+
["Project", summary.project],
|
|
1301
|
+
["Tracker", summary.tracker],
|
|
1302
|
+
["Status", summary.status],
|
|
1303
|
+
["Priority", summary.priority],
|
|
1304
|
+
["Assignee", summary.assignee],
|
|
1305
|
+
["Author", summary.author],
|
|
1306
|
+
["Sprint", summary.sprint],
|
|
1307
|
+
["Done", scalarValue(summary.done_ratio) ? `${scalarValue(summary.done_ratio)}%` : undefined],
|
|
1308
|
+
["Estimated", formatHours(summary.estimated_hours)],
|
|
1309
|
+
["Spent", formatHours(summary.spent_hours)],
|
|
1310
|
+
["Created", formatDateTime(summary.created_on)],
|
|
1311
|
+
["Updated", formatDateTime(summary.updated_on)]
|
|
959
1312
|
]);
|
|
960
1313
|
parts.push(`## #${mdEscape(id)} ${mdEscape(subject)}`);
|
|
961
1314
|
if (fields) {
|
|
962
1315
|
parts.push(fields);
|
|
963
1316
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
parts.push("### Description", description);
|
|
967
|
-
}
|
|
968
|
-
const customFields = customFieldsMarkdown(issue);
|
|
969
|
-
if (customFields) {
|
|
970
|
-
parts.push("### Custom fields", customFields);
|
|
1317
|
+
if (summary.description_preview) {
|
|
1318
|
+
parts.push("### Description preview", mdEscape(summary.description_preview));
|
|
971
1319
|
}
|
|
972
1320
|
}
|
|
973
1321
|
return `${parts.join(`
|
|
@@ -976,7 +1324,7 @@ ${frontmatter.join(`
|
|
|
976
1324
|
`;
|
|
977
1325
|
}
|
|
978
1326
|
function printIssueListResponse(response, values, filters) {
|
|
979
|
-
if (
|
|
1327
|
+
if (outputFormat2(values) === "md") {
|
|
980
1328
|
process.stdout.write(renderMarkdownForTerminal(issueListResponseToMarkdown(response, filters)));
|
|
981
1329
|
return;
|
|
982
1330
|
}
|
|
@@ -996,7 +1344,7 @@ function customFieldsByName(issue) {
|
|
|
996
1344
|
}
|
|
997
1345
|
const byName = {};
|
|
998
1346
|
for (const field of customFields) {
|
|
999
|
-
if (!
|
|
1347
|
+
if (!isRecord2(field) || typeof field.name !== "string") {
|
|
1000
1348
|
continue;
|
|
1001
1349
|
}
|
|
1002
1350
|
byName[field.name] = field.value;
|
|
@@ -1046,7 +1394,7 @@ function valueLabel(name, value, lookups) {
|
|
|
1046
1394
|
return;
|
|
1047
1395
|
}
|
|
1048
1396
|
function enrichJournalDetail(detail, lookups) {
|
|
1049
|
-
if (!
|
|
1397
|
+
if (!isRecord2(detail) || typeof detail.name !== "string") {
|
|
1050
1398
|
return detail;
|
|
1051
1399
|
}
|
|
1052
1400
|
const label = detailLabel(detail.name, lookups);
|
|
@@ -1060,13 +1408,13 @@ function enrichJournalDetail(detail, lookups) {
|
|
|
1060
1408
|
};
|
|
1061
1409
|
}
|
|
1062
1410
|
function enrichIssueResponse(response, enrichment) {
|
|
1063
|
-
if (!
|
|
1411
|
+
if (!isRecord2(response) || !isRecord2(response.issue)) {
|
|
1064
1412
|
return response;
|
|
1065
1413
|
}
|
|
1066
1414
|
const issue = response.issue;
|
|
1067
1415
|
const customFields = customFieldsByName(issue);
|
|
1068
1416
|
const journals = Array.isArray(issue.journals) ? issue.journals.map((journal) => {
|
|
1069
|
-
if (!
|
|
1417
|
+
if (!isRecord2(journal)) {
|
|
1070
1418
|
return journal;
|
|
1071
1419
|
}
|
|
1072
1420
|
return {
|
|
@@ -1090,12 +1438,12 @@ function enrichIssueResponse(response, enrichment) {
|
|
|
1090
1438
|
};
|
|
1091
1439
|
}
|
|
1092
1440
|
function collectJournalUserIds(issue) {
|
|
1093
|
-
if (!
|
|
1441
|
+
if (!isRecord2(issue) || !Array.isArray(issue.journals)) {
|
|
1094
1442
|
return [];
|
|
1095
1443
|
}
|
|
1096
1444
|
const ids = new Set;
|
|
1097
1445
|
for (const journal of issue.journals) {
|
|
1098
|
-
if (!
|
|
1446
|
+
if (!isRecord2(journal)) {
|
|
1099
1447
|
continue;
|
|
1100
1448
|
}
|
|
1101
1449
|
const userId = namedReferenceId(journal.user);
|
|
@@ -1105,7 +1453,7 @@ function collectJournalUserIds(issue) {
|
|
|
1105
1453
|
continue;
|
|
1106
1454
|
}
|
|
1107
1455
|
for (const detail of journal.details) {
|
|
1108
|
-
if (!
|
|
1456
|
+
if (!isRecord2(detail) || typeof detail.name !== "string") {
|
|
1109
1457
|
continue;
|
|
1110
1458
|
}
|
|
1111
1459
|
if (detail.name === "assigned_to_id" || detail.name === "user_id" || detail.name === "author_id") {
|
|
@@ -1122,10 +1470,10 @@ function collectJournalUserIds(issue) {
|
|
|
1122
1470
|
async function fetchIssueEnrichment(config, issueId, issue) {
|
|
1123
1471
|
const lookups = createLookupMaps();
|
|
1124
1472
|
const warnings = [];
|
|
1125
|
-
if (
|
|
1473
|
+
if (isRecord2(issue)) {
|
|
1126
1474
|
addNamedReferencesToMap(lookups.users, [issue.author, issue.assigned_to]);
|
|
1127
1475
|
}
|
|
1128
|
-
const projectId =
|
|
1476
|
+
const projectId = isRecord2(issue) ? namedReferenceId(issue.project) : undefined;
|
|
1129
1477
|
const requests = await Promise.all([
|
|
1130
1478
|
redmineGetJsonOptional(config, "/time_entries.json", { issue_id: issueId, limit: 100 }),
|
|
1131
1479
|
redmineGetJsonOptional(config, "/issue_statuses.json", undefined, DAY_MS),
|
|
@@ -1154,9 +1502,9 @@ async function fetchIssueEnrichment(config, issueId, issue) {
|
|
|
1154
1502
|
warnings.push(userRequest.warning);
|
|
1155
1503
|
continue;
|
|
1156
1504
|
}
|
|
1157
|
-
if (
|
|
1505
|
+
if (isRecord2(userRequest.data) && isRecord2(userRequest.data.user)) {
|
|
1158
1506
|
const id = namedReferenceId(userRequest.data.user);
|
|
1159
|
-
const name =
|
|
1507
|
+
const name = namedReferenceName2(userRequest.data.user);
|
|
1160
1508
|
if (id && name) {
|
|
1161
1509
|
userLookups.users.set(id, name);
|
|
1162
1510
|
}
|
|
@@ -1252,10 +1600,10 @@ function buildIssueQuery(filters, limit, offset) {
|
|
|
1252
1600
|
}
|
|
1253
1601
|
function filtersFromValues(values, overrides = {}) {
|
|
1254
1602
|
return {
|
|
1255
|
-
project:
|
|
1256
|
-
assignee: overrides.assignee ??
|
|
1257
|
-
status: overrides.status ?? resolveStatus(
|
|
1258
|
-
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))
|
|
1259
1607
|
};
|
|
1260
1608
|
}
|
|
1261
1609
|
function getIssueSprintValue(issue) {
|
|
@@ -1432,7 +1780,7 @@ var issueGetCommand = {
|
|
|
1432
1780
|
requiresConfig: true,
|
|
1433
1781
|
aliases: ["show"],
|
|
1434
1782
|
summary: "Get an issue",
|
|
1435
|
-
description: "Get a single Redmine issue as
|
|
1783
|
+
description: "Get a single Redmine issue as Markdown. Journals, attachments, relations, changesets, and watchers are included by default.",
|
|
1436
1784
|
arguments: [
|
|
1437
1785
|
{
|
|
1438
1786
|
name: "id",
|
|
@@ -1459,7 +1807,7 @@ var issueGetCommand = {
|
|
|
1459
1807
|
type: "string",
|
|
1460
1808
|
choices: ["json", "md"],
|
|
1461
1809
|
description: "Output format: json or md",
|
|
1462
|
-
defaultValue: "
|
|
1810
|
+
defaultValue: "md"
|
|
1463
1811
|
},
|
|
1464
1812
|
{
|
|
1465
1813
|
name: "no-secondary",
|
|
@@ -1467,20 +1815,20 @@ var issueGetCommand = {
|
|
|
1467
1815
|
description: "Skip secondary requests for time entries and lookup metadata"
|
|
1468
1816
|
}
|
|
1469
1817
|
],
|
|
1470
|
-
examples: ["redmine issue get 43135", "redmine issue get 43135 -o
|
|
1818
|
+
examples: ["redmine issue get 43135", "redmine issue get 43135 -o json", "redmine issue get 43135 --include journals,attachments,relations", "redmine issue get 43135 --include none", "redmine issue get 43135 --raw"],
|
|
1471
1819
|
execute: async ({ values, positionals }) => {
|
|
1472
1820
|
if (positionals.length !== 1) {
|
|
1473
1821
|
throw new Error("Expected exactly one issue ID argument");
|
|
1474
1822
|
}
|
|
1475
1823
|
const issueId = requirePositiveInteger(positionals[0], "issue ID");
|
|
1476
|
-
const include =
|
|
1824
|
+
const include = optionalString3(values.include);
|
|
1477
1825
|
const config = await loadConfig();
|
|
1478
1826
|
const response = await redmineGetJson(config, {
|
|
1479
1827
|
path: `/issues/${issueId}.json`,
|
|
1480
1828
|
query: { include: include && include !== "none" ? include : undefined }
|
|
1481
1829
|
});
|
|
1482
|
-
const format =
|
|
1483
|
-
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)) {
|
|
1484
1832
|
if (format === "md") {
|
|
1485
1833
|
process.stdout.write(renderMarkdownForTerminal(issueResponseToMarkdown(response)));
|
|
1486
1834
|
return;
|
|
@@ -1581,7 +1929,7 @@ var rootCommand = {
|
|
|
1581
1929
|
name: "redmine",
|
|
1582
1930
|
summary: "Redmine CLI",
|
|
1583
1931
|
description: "Inspect and manage Redmine resources from the command line.",
|
|
1584
|
-
subcommands: [configCommand, issueCommand]
|
|
1932
|
+
subcommands: [configCommand, issueCommand, activityCommand]
|
|
1585
1933
|
};
|
|
1586
1934
|
function getVisibleSubcommands(command) {
|
|
1587
1935
|
return (command.subcommands ?? []).filter((entry) => entry.hidden !== true);
|
|
@@ -1618,7 +1966,13 @@ function resolveCommandPath(args) {
|
|
|
1618
1966
|
};
|
|
1619
1967
|
}
|
|
1620
1968
|
function collectCommandFlags(command) {
|
|
1621
|
-
|
|
1969
|
+
const commandFlags = command?.flags ?? [];
|
|
1970
|
+
const commandAliases = new Set(commandFlags.flatMap((flag) => flag.aliases ?? []));
|
|
1971
|
+
const globals = globalFlags.map((flag) => ({
|
|
1972
|
+
...flag,
|
|
1973
|
+
aliases: flag.aliases?.filter((alias) => !commandAliases.has(alias))
|
|
1974
|
+
}));
|
|
1975
|
+
return [...globals, ...commandFlags];
|
|
1622
1976
|
}
|
|
1623
1977
|
|
|
1624
1978
|
// src/cli/help.ts
|
|
@@ -1837,7 +2191,7 @@ function parseCli(args) {
|
|
|
1837
2191
|
// package.json
|
|
1838
2192
|
var package_default = {
|
|
1839
2193
|
name: "@emmertarmin/redmine-cli",
|
|
1840
|
-
version: "0.1.
|
|
2194
|
+
version: "0.1.3",
|
|
1841
2195
|
description: "A personal Redmine CLI.",
|
|
1842
2196
|
license: "MIT",
|
|
1843
2197
|
repository: {
|
|
@@ -1860,6 +2214,7 @@ var package_default = {
|
|
|
1860
2214
|
bun: ">=1.0.0"
|
|
1861
2215
|
},
|
|
1862
2216
|
scripts: {
|
|
2217
|
+
cli: "bun run ./index.ts",
|
|
1863
2218
|
dev: "bun --watch run index.ts",
|
|
1864
2219
|
build: "bun build ./index.ts --outdir dist --target bun",
|
|
1865
2220
|
tsc: "tsc --noEmit",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emmertarmin/redmine-cli",
|
|
3
|
-
"version": "0.1.
|
|
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",
|