kodevu 0.1.9 → 0.1.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Kodevu
2
2
 
3
- A Node.js tool that polls new SVN revisions or Git commits, fetches each change diff directly from the repository, sends the diff to a supported reviewer CLI, and writes the result to Markdown files.
3
+ A Node.js tool that polls new SVN revisions or Git commits, fetches each change diff directly from the repository, sends the diff to a supported reviewer CLI, and writes review results to report files.
4
4
 
5
5
  ## Workflow
6
6
 
@@ -12,9 +12,20 @@ A Node.js tool that polls new SVN revisions or Git commits, fetches each change
12
12
  - generate a unified diff for that single revision or commit
13
13
  - send the diff and change metadata to the configured reviewer CLI
14
14
  - allow the reviewer to inspect related local repository files in read-only mode when a local workspace is available
15
- - write the result to `~/.kodevu/`
15
+ - write the result to `~/.kodevu/` (Markdown by default; optional JSON via config)
16
16
  5. Update `~/.kodevu/state.json` so the same change is not reviewed twice.
17
17
 
18
+ ## Quick start
19
+
20
+ ```bash
21
+ npx kodevu /path/to/your/repo
22
+ ```
23
+
24
+ No config file is required for the default flow.
25
+ Review reports are written to `~/.kodevu/` as Markdown (`.md`) by default, and review state is stored at `~/.kodevu/state.json`.
26
+
27
+ If you want a config file, run `npx kodevu init` to create `./config.json` in the current directory.
28
+
18
29
  ## Setup
19
30
 
20
31
  ```bash
@@ -36,13 +47,13 @@ If you do not pass `--config`, Kodevu will try to load `./config.json` from the
36
47
 
37
48
  ## Run
38
49
 
39
- Run once:
50
+ Run:
40
51
 
41
52
  ```bash
42
53
  npx kodevu /path/to/your/repo
43
54
  ```
44
55
 
45
- Run once with debug logs:
56
+ Run with debug logs:
46
57
 
47
58
  ```bash
48
59
  npx kodevu /path/to/your/repo --debug
@@ -68,6 +79,7 @@ npx kodevu /path/to/your/repo --config ./config.current.json
68
79
  - `reviewer`: `codex`, `gemini`, or `auto`; default `auto`
69
80
  - `prompt`: saved into the report as review context
70
81
  - `outputDir`: report output directory; default `~/.kodevu`
82
+ - `outputFormats`: report formats to generate; supports `markdown` and `json`; default `["markdown"]`
71
83
  - `stateFilePath`: review state file path; default `~/.kodevu/state.json`
72
84
  - `commandTimeoutMs`: timeout for a single review command execution in milliseconds
73
85
  - `maxRevisionsPerRun`: cap the number of pending changes per polling cycle
@@ -92,7 +104,8 @@ Internal defaults:
92
104
  - Large diffs are truncated before being sent to the reviewer or written into the report once they exceed the configured line or character limits.
93
105
  - For Git targets and local SVN working copies, the reviewer command runs from the repository workspace so it can inspect related files beyond the diff when needed.
94
106
  - For remote SVN URLs without a local working copy, the review still relies on the diff and change metadata only.
95
- - SVN reports are written as `<YYYYMMDD-HHmmss>-svn-r<revision>.md`.
96
- - Git reports are written as `<YYYYMMDD-HHmmss>-git-<short-commit-hash>.md`.
107
+ - By default, SVN reports are written as `<YYYYMMDD-HHmmss>-svn-r<revision>.md`.
108
+ - By default, Git reports are written as `<YYYYMMDD-HHmmss>-git-<short-commit-hash>.md`.
109
+ - If `outputFormats` includes `json`, matching `.json` files are generated alongside Markdown reports.
97
110
  - `~/.kodevu/state.json` stores per-project checkpoints keyed by repository identity; only the v2 multi-project structure is supported.
98
111
  - If the reviewer command exits non-zero or times out, the report is still written, but the state is not advanced so the change can be retried later.
@@ -5,5 +5,8 @@
5
5
  "outputDir": "~/.kodevu",
6
6
  "stateFilePath": "~/.kodevu/state.json",
7
7
  "commandTimeoutMs": 600000,
8
- "maxRevisionsPerRun": 5
8
+ "maxRevisionsPerRun": 5,
9
+ "outputFormats": [
10
+ "markdown"
11
+ ]
9
12
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "kodevu",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
- "description": "Poll SVN revisions or Git commits, send each change diff to a reviewer CLI, and write Markdown review reports.",
5
+ "description": "Poll SVN revisions or Git commits, send each change diff to a reviewer CLI, and write configurable review reports.",
6
6
  "bin": {
7
7
  "kodevu": "./src/index.js"
8
8
  },
package/src/config.js CHANGED
@@ -16,7 +16,8 @@ const defaultConfig = {
16
16
  commandTimeoutMs: 600000,
17
17
  prompt:
18
18
  "请严格审查当前变更,优先指出 bug、回归风险、兼容性问题、安全问题、边界条件缺陷和缺失测试。请使用简体中文输出 Markdown;如果没有明确缺陷,请写“未发现明确缺陷”,并补充剩余风险。",
19
- maxRevisionsPerRun: 20
19
+ maxRevisionsPerRun: 20,
20
+ outputFormats: ["markdown"]
20
21
  };
21
22
 
22
23
  function resolveConfigPath(baseDir, value) {
@@ -39,7 +40,30 @@ function resolveConfigPath(baseDir, value) {
39
40
  return path.isAbsolute(value) ? value : path.resolve(baseDir, value);
40
41
  }
41
42
 
42
- async function resolveAutoReviewer(debug, loadedConfigPath) {
43
+
44
+ function normalizeOutputFormats(outputFormats, loadedConfigPath) {
45
+ const source = outputFormats == null ? ["markdown"] : outputFormats;
46
+ const values = Array.isArray(source) ? source : [source];
47
+ const normalized = [...new Set(values.map((item) => String(item || "").trim().toLowerCase()).filter(Boolean))];
48
+ const supported = ["markdown", "json"];
49
+ const invalid = normalized.filter((item) => !supported.includes(item));
50
+
51
+ if (invalid.length > 0) {
52
+ throw new Error(
53
+ `"outputFormats" contains unsupported value(s): ${invalid.join(", ")}. Use any of: ${supported.join(", ")}${
54
+ loadedConfigPath ? ` in ${loadedConfigPath}` : ""
55
+ }`
56
+ );
57
+ }
58
+
59
+ if (normalized.length === 0) {
60
+ throw new Error(`"outputFormats" must include at least one format${loadedConfigPath ? ` in ${loadedConfigPath}` : ""}`);
61
+ }
62
+
63
+ return normalized;
64
+ }
65
+
66
+ async function resolveAutoReviewers(debug, loadedConfigPath) {
43
67
  const availableReviewers = [];
44
68
 
45
69
  for (const reviewerName of SUPPORTED_REVIEWERS) {
@@ -57,7 +81,12 @@ async function resolveAutoReviewer(debug, loadedConfigPath) {
57
81
  );
58
82
  }
59
83
 
60
- return availableReviewers[Math.floor(Math.random() * availableReviewers.length)];
84
+ for (let i = availableReviewers.length - 1; i > 0; i--) {
85
+ const j = Math.floor(Math.random() * (i + 1));
86
+ [availableReviewers[i], availableReviewers[j]] = [availableReviewers[j], availableReviewers[i]];
87
+ }
88
+
89
+ return availableReviewers;
61
90
  }
62
91
 
63
92
  export function parseCliArgs(argv) {
@@ -177,9 +206,11 @@ export async function loadConfig(configPath, cliArgs = {}) {
177
206
  config.reviewer = String(config.reviewer || "auto").toLowerCase();
178
207
 
179
208
  if (config.reviewer === "auto") {
180
- const selectedReviewer = await resolveAutoReviewer(config.debug, loadedConfigPath);
209
+ const availableReviewers = await resolveAutoReviewers(config.debug, loadedConfigPath);
210
+ const selectedReviewer = availableReviewers[0];
181
211
  config.reviewer = selectedReviewer.reviewerName;
182
212
  config.reviewerCommandPath = selectedReviewer.commandPath;
213
+ config.fallbackReviewers = availableReviewers.map(r => r.reviewerName).slice(1);
183
214
  config.reviewerWasAutoSelected = true;
184
215
  } else if (!SUPPORTED_REVIEWERS.includes(config.reviewer)) {
185
216
  throw new Error(
@@ -193,6 +224,7 @@ export async function loadConfig(configPath, cliArgs = {}) {
193
224
  config.stateFilePath = resolveConfigPath(config.baseDir, config.stateFilePath);
194
225
  config.maxRevisionsPerRun = Number(config.maxRevisionsPerRun);
195
226
  config.commandTimeoutMs = Number(config.commandTimeoutMs);
227
+ config.outputFormats = normalizeOutputFormats(config.outputFormats, loadedConfigPath);
196
228
 
197
229
  if (!Number.isInteger(config.maxRevisionsPerRun) || config.maxRevisionsPerRun <= 0) {
198
230
  throw new Error(`"maxRevisionsPerRun" must be a positive integer${loadedConfigPath ? ` in ${loadedConfigPath}` : ""}`);
@@ -226,6 +258,7 @@ Options:
226
258
  Config highlights:
227
259
  reviewer codex | gemini | auto
228
260
  target Repository target path (Git) or SVN working copy / URL; CLI positional target overrides config
261
+ outputFormats ["markdown"] by default; set to include "json" when needed
229
262
  `);
230
263
  }
231
264
 
@@ -154,6 +154,15 @@ async function writeTextFile(filePath, contents) {
154
154
  await fs.writeFile(filePath, contents, "utf8");
155
155
  }
156
156
 
157
+ async function writeJsonFile(filePath, payload) {
158
+ await ensureDir(path.dirname(filePath));
159
+ await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
160
+ }
161
+
162
+ function shouldWriteFormat(config, format) {
163
+ return Array.isArray(config.outputFormats) && config.outputFormats.includes(format);
164
+ }
165
+
157
166
  function formatChangedPaths(changedPaths) {
158
167
  if (changedPaths.length === 0) {
159
168
  return "_No changed files captured._";
@@ -351,6 +360,47 @@ function buildReport(config, backend, targetInfo, details, diffPayloads, reviewe
351
360
  return `${lines.join("\n")}\n`;
352
361
  }
353
362
 
363
+ function buildJsonReport(config, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult) {
364
+ return {
365
+ repositoryType: backend.displayName,
366
+ target: targetInfo.targetDisplay || config.target,
367
+ changeId: details.displayId,
368
+ author: details.author,
369
+ commitDate: details.date || "unknown",
370
+ generatedAt: new Date().toISOString(),
371
+ reviewer: {
372
+ name: reviewer.displayName,
373
+ exitCode: reviewerResult.code,
374
+ timedOut: Boolean(reviewerResult.timedOut)
375
+ },
376
+ changedFiles: details.changedPaths.map((item) => ({
377
+ action: item.action,
378
+ path: item.relativePath,
379
+ previousPath: item.previousPath || null
380
+ })),
381
+ commitMessage: details.message || "",
382
+ reviewContext: buildPrompt(config, backend, targetInfo, details, diffPayloads.review),
383
+ diffHandling: {
384
+ reviewerInput: {
385
+ originalLines: diffPayloads.review.originalLineCount,
386
+ originalChars: diffPayloads.review.originalCharCount,
387
+ includedLines: diffPayloads.review.outputLineCount,
388
+ includedChars: diffPayloads.review.outputCharCount,
389
+ truncated: diffPayloads.review.wasTruncated
390
+ },
391
+ reportDiff: {
392
+ originalLines: diffPayloads.report.originalLineCount,
393
+ originalChars: diffPayloads.report.originalCharCount,
394
+ includedLines: diffPayloads.report.outputLineCount,
395
+ includedChars: diffPayloads.report.outputCharCount,
396
+ truncated: diffPayloads.report.wasTruncated
397
+ }
398
+ },
399
+ diff: diffPayloads.report.text.trim(),
400
+ reviewerResponse: reviewerResult.message?.trim() ? reviewerResult.message.trim() : reviewer.emptyResponseText
401
+ };
402
+ }
403
+
354
404
  async function runReviewerPrompt(config, backend, targetInfo, details, diffText) {
355
405
  const reviewer = REVIEWERS[config.reviewer];
356
406
  const reviewWorkspaceRoot = getReviewWorkspaceRoot(config, backend, targetInfo);
@@ -396,28 +446,92 @@ async function reviewChange(config, backend, targetInfo, changeId) {
396
446
  "No file changes were captured for this change under the configured target."
397
447
  ].join("\n");
398
448
 
399
- const outputFile = path.join(config.outputDir, backend.getReportFileName(changeId));
400
- await writeTextFile(outputFile, `${skippedReport}\n`);
401
- return { success: true, outputFile };
449
+ const markdownReportFile = path.join(config.outputDir, backend.getReportFileName(changeId));
450
+ const jsonReportFile = markdownReportFile.replace(/\.md$/i, ".json");
451
+
452
+ if (shouldWriteFormat(config, "markdown")) {
453
+ await writeTextFile(markdownReportFile, `${skippedReport}\n`);
454
+ }
455
+
456
+ if (shouldWriteFormat(config, "json")) {
457
+ await writeJsonFile(jsonReportFile, {
458
+ repositoryType: backend.displayName,
459
+ target: targetInfo.targetDisplay || config.target,
460
+ changeId: details.displayId,
461
+ generatedAt: new Date().toISOString(),
462
+ skipped: true,
463
+ message: "No file changes were captured for this change under the configured target."
464
+ });
465
+ }
466
+
467
+ return {
468
+ success: true,
469
+ outputFile: shouldWriteFormat(config, "markdown") ? markdownReportFile : null,
470
+ jsonOutputFile: shouldWriteFormat(config, "json") ? jsonReportFile : null
471
+ };
402
472
  }
403
473
 
404
474
  const diffText = await backend.getChangeDiff(config, targetInfo, changeId);
405
- const { reviewer, diffPayloads, result: reviewerResult } = await runReviewerPrompt(
406
- config,
407
- backend,
408
- targetInfo,
409
- details,
410
- diffText
411
- );
412
- const report = buildReport(config, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult);
475
+ const reviewersToTry = [config.reviewer, ...(config.fallbackReviewers || [])];
476
+
477
+ let reviewer;
478
+ let diffPayloads;
479
+ let reviewerResult;
480
+ let currentReviewerConfig;
481
+
482
+ for (const reviewerName of reviewersToTry) {
483
+ currentReviewerConfig = { ...config, reviewer: reviewerName };
484
+ debugLog(config, `Trying reviewer: ${reviewerName}`);
485
+
486
+ const res = await runReviewerPrompt(
487
+ currentReviewerConfig,
488
+ backend,
489
+ targetInfo,
490
+ details,
491
+ diffText
492
+ );
493
+ reviewer = res.reviewer;
494
+ diffPayloads = res.diffPayloads;
495
+ reviewerResult = res.result;
496
+
497
+ if (reviewerResult.code === 0 && !reviewerResult.timedOut) {
498
+ break;
499
+ }
500
+
501
+ if (reviewerName !== reviewersToTry[reviewersToTry.length - 1]) {
502
+ console.log(`${reviewer.displayName} failed for ${details.displayId}; trying next reviewer...`);
503
+ }
504
+ }
505
+
506
+ const report = buildReport(currentReviewerConfig, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult);
413
507
  const outputFile = path.join(config.outputDir, backend.getReportFileName(changeId));
414
- await writeTextFile(outputFile, report);
508
+ const jsonOutputFile = outputFile.replace(/\.md$/i, ".json");
509
+
510
+ if (shouldWriteFormat(config, "markdown")) {
511
+ await writeTextFile(outputFile, report);
512
+ }
513
+
514
+ if (shouldWriteFormat(config, "json")) {
515
+ await writeJsonFile(
516
+ jsonOutputFile,
517
+ buildJsonReport(currentReviewerConfig, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult)
518
+ );
519
+ }
415
520
 
416
521
  if (reviewerResult.code !== 0 || reviewerResult.timedOut) {
417
- throw new Error(`${reviewer.displayName} failed for ${details.displayId}; report written to ${outputFile}`);
522
+ throw new Error(
523
+ `${reviewer.displayName} failed for ${details.displayId}; report written to ${outputFile}${
524
+ shouldWriteFormat(config, "json") ? ` and ${jsonOutputFile}` : ""
525
+ }`
526
+ );
418
527
  }
419
528
 
420
- return { success: true, outputFile, details };
529
+ return {
530
+ success: true,
531
+ outputFile: shouldWriteFormat(config, "markdown") ? outputFile : null,
532
+ jsonOutputFile: shouldWriteFormat(config, "json") ? jsonOutputFile : null,
533
+ details
534
+ };
421
535
  }
422
536
 
423
537
  function formatChangeList(backend, changeIds) {
@@ -478,7 +592,11 @@ export async function runReviewCycle(config) {
478
592
  for (const changeId of changeIdsToReview) {
479
593
  debugLog(config, `Starting review for ${backend.formatChangeId(changeId)}.`);
480
594
  const result = await reviewChange(config, backend, targetInfo, changeId);
481
- console.log(`Reviewed ${backend.formatChangeId(changeId)}: ${result.outputFile}`);
595
+ const outputLabels = [
596
+ result.outputFile ? `md: ${result.outputFile}` : null,
597
+ result.jsonOutputFile ? `json: ${result.jsonOutputFile}` : null
598
+ ].filter(Boolean);
599
+ console.log(`Reviewed ${backend.formatChangeId(changeId)}: ${outputLabels.join(" | ") || "(no report file generated)"}`);
482
600
  const nextProjectState = buildStateSnapshot(backend, targetInfo, changeId);
483
601
  await saveState(config.stateFilePath, updateProjectState(stateFile, targetInfo, nextProjectState));
484
602
  stateFile.projects[targetInfo.stateKey] = nextProjectState;