kodevu 0.1.0 → 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 CHANGED
@@ -18,62 +18,48 @@ A Node.js tool that polls new SVN revisions or Git commits, fetches each change
18
18
  ## Setup
19
19
 
20
20
  ```bash
21
- npm install
22
- copy config.example.json config.json
21
+ npx kodevu init
23
22
  ```
24
23
 
25
- Then edit `config.json` and set `target`.
24
+ This creates `config.json` in the current directory from the packaged `config.example.json`.
26
25
 
27
- Install as a CLI package:
26
+ If you want a different path:
28
27
 
29
28
  ```bash
30
- npm install -g kodevu
31
- copy config.example.json config.json
29
+ npx kodevu init --config ./config.current.json
32
30
  ```
33
31
 
34
- ## Run
32
+ Then edit `config.json` and set `target`.
35
33
 
36
- Run one cycle:
34
+ `config.json` is the default config file. If you do not pass `--config`, Kodevu will load `./config.json` from the current directory.
37
35
 
38
- ```bash
39
- npm run once
40
- ```
36
+ ## Run
41
37
 
42
- Or run directly as the published CLI:
38
+ Run one cycle:
43
39
 
44
40
  ```bash
45
- kodevu --once
41
+ npx kodevu --once
46
42
  ```
47
43
 
48
- Use `npx` without installing globally:
44
+ Run one cycle with debug logs:
49
45
 
50
46
  ```bash
51
- npx kodevu --once --config ./config.json
47
+ npx kodevu --once --debug
52
48
  ```
53
49
 
54
50
  Start the scheduler:
55
51
 
56
52
  ```bash
57
- npm start
53
+ npx kodevu
58
54
  ```
59
55
 
60
- Published CLI form:
56
+ Use a custom config path only when needed:
61
57
 
62
58
  ```bash
63
- kodevu --config ./config.json
59
+ npx kodevu --config ./config.current.json --once
64
60
  ```
65
61
 
66
- Use a custom config path:
67
-
68
- ```bash
69
- node src/index.js --config ./config.json --once
70
- ```
71
-
72
- Equivalent `npx` usage:
73
-
74
- ```bash
75
- npx kodevu --config ./config.json --once
76
- ```
62
+ `--debug` / `-d` is a CLI-only switch. It is not read from `config.json`.
77
63
 
78
64
  ## Config
79
65
 
@@ -84,14 +70,12 @@ npx kodevu --config ./config.json --once
84
70
  - `reviewPrompt`: saved into the report as review context
85
71
  - `outputDir`: report output directory; default `./reports`
86
72
  - `commandTimeoutMs`: timeout for a single review command execution in milliseconds
87
- - `bootstrapToLatest`: if no state exists, start by reviewing only the current latest change instead of replaying the full history
88
73
  - `maxRevisionsPerRun`: cap the number of pending changes per polling cycle
89
74
 
90
75
  Internal defaults:
91
76
 
92
- - review state is always stored in `./data/state.json`
93
- - the tool always invokes `git`, `svn`, and the configured reviewer CLI from `PATH`
94
- - command output is decoded as `utf8`
77
+ - review state is always stored in `./data/state.json`, and first run starts from the current latest change instead of replaying full history
78
+ - Kodevu invokes `git`, `svn`, and the configured reviewer CLI from `PATH`; debug logging is enabled only by passing `--debug` or `-d`
95
79
 
96
80
  ## Target Rules
97
81
 
@@ -104,6 +88,7 @@ Internal defaults:
104
88
 
105
89
  - `reviewer: "codex"` uses `codex exec` with the diff embedded in the prompt.
106
90
  - `reviewer: "gemini"` uses `gemini -p` in non-interactive mode.
91
+ - Large diffs are truncated before being sent to the reviewer or written into the report once they exceed the configured line or character limits.
107
92
  - 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.
108
93
  - For remote SVN URLs without a local working copy, the review still relies on the diff and change metadata only.
109
94
  - SVN reports keep the `r123.md` naming style.
@@ -6,6 +6,5 @@
6
6
  "reviewPrompt": "请严格审查当前变更,优先指出 bug、回归风险、兼容性问题、安全问题、边界条件缺陷和缺失测试。请使用简体中文输出 Markdown;如果没有明确缺陷,请写“未发现明确缺陷”,并补充剩余风险。",
7
7
  "outputDir": "./reports",
8
8
  "commandTimeoutMs": 600000,
9
- "bootstrapToLatest": true,
10
9
  "maxRevisionsPerRun": 5
11
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodevu",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Poll SVN revisions or Git commits, send each change diff to a reviewer CLI, and write Markdown review reports.",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
+ import { constants as fsConstants } from "node:fs";
2
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
 
4
6
  const defaultConfig = {
5
7
  vcs: "auto",
@@ -10,20 +12,28 @@ const defaultConfig = {
10
12
  commandTimeoutMs: 600000,
11
13
  reviewPrompt:
12
14
  "请严格审查当前变更,优先指出 bug、回归风险、兼容性问题、安全问题、边界条件缺陷和缺失测试。请使用简体中文输出 Markdown;如果没有明确缺陷,请写“未发现明确缺陷”,并补充剩余风险。",
13
- bootstrapToLatest: true,
14
15
  maxRevisionsPerRun: 20
15
16
  };
16
17
 
17
18
  export function parseCliArgs(argv) {
18
19
  const args = {
20
+ command: "run",
19
21
  configPath: "config.json",
20
22
  once: false,
21
- help: false
23
+ debug: false,
24
+ help: false,
25
+ commandExplicitlySet: false
22
26
  };
23
27
 
24
28
  for (let index = 0; index < argv.length; index += 1) {
25
29
  const value = argv[index];
26
30
 
31
+ if (value === "init" && !args.commandExplicitlySet && index === 0) {
32
+ args.command = "init";
33
+ args.commandExplicitlySet = true;
34
+ continue;
35
+ }
36
+
27
37
  if (value === "--once") {
28
38
  args.once = true;
29
39
  continue;
@@ -34,17 +44,27 @@ export function parseCliArgs(argv) {
34
44
  continue;
35
45
  }
36
46
 
47
+ if (value === "--debug" || value === "-d") {
48
+ args.debug = true;
49
+ continue;
50
+ }
51
+
37
52
  if (value === "--config" || value === "-c") {
38
- args.configPath = argv[index + 1];
53
+ const configPath = argv[index + 1];
54
+ if (!configPath || configPath.startsWith("-")) {
55
+ throw new Error(`Missing value for ${value}`);
56
+ }
57
+ args.configPath = configPath;
39
58
  index += 1;
40
59
  continue;
41
60
  }
42
61
  }
43
62
 
63
+ delete args.commandExplicitlySet;
44
64
  return args;
45
65
  }
46
66
 
47
- export async function loadConfig(configPath) {
67
+ export async function loadConfig(configPath, cliArgs = {}) {
48
68
  const absoluteConfigPath = path.resolve(configPath);
49
69
  const raw = await fs.readFile(absoluteConfigPath, "utf8");
50
70
  const config = {
@@ -62,6 +82,7 @@ export async function loadConfig(configPath) {
62
82
 
63
83
  config.vcs = String(config.vcs || "auto").toLowerCase();
64
84
  config.reviewer = String(config.reviewer || "codex").toLowerCase();
85
+ config.debug = Boolean(cliArgs.debug);
65
86
 
66
87
  if (!["auto", "svn", "git"].includes(config.vcs)) {
67
88
  throw new Error(`"vcs" must be one of "auto", "svn", or "git" in ${absoluteConfigPath}`);
@@ -93,11 +114,14 @@ export function printHelp() {
93
114
  console.log(`Kodevu
94
115
 
95
116
  Usage:
117
+ kodevu init
118
+ npx kodevu init
96
119
  kodevu [--config config.json] [--once]
97
120
  npx kodevu [--config config.json] [--once]
98
121
 
99
122
  Options:
100
- --config, -c Path to config json. Default: ./config.json
123
+ --config, -c Path to config json. Default: ./config.json in the current directory
124
+ --debug, -d Print extra debug information to the console
101
125
  --once Run one polling cycle and exit
102
126
  --help, -h Show help
103
127
 
@@ -107,3 +131,21 @@ Config highlights:
107
131
  target Repository target path (Git) or SVN working copy / URL
108
132
  `);
109
133
  }
134
+
135
+ export async function initConfig(targetPath = "config.json") {
136
+ const absoluteTargetPath = path.resolve(targetPath);
137
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
138
+ const templatePath = path.join(packageRoot, "config.example.json");
139
+
140
+ await fs.mkdir(path.dirname(absoluteTargetPath), { recursive: true });
141
+ try {
142
+ await fs.copyFile(templatePath, absoluteTargetPath, fsConstants.COPYFILE_EXCL);
143
+ } catch (error) {
144
+ if (error?.code === "EEXIST") {
145
+ throw new Error(`Config file already exists: ${absoluteTargetPath}`);
146
+ }
147
+ throw error;
148
+ }
149
+
150
+ return absoluteTargetPath;
151
+ }
package/src/git-client.js CHANGED
@@ -31,6 +31,7 @@ async function statPath(targetPath) {
31
31
  async function runGit(config, args, options = {}) {
32
32
  return await runCommand(GIT_COMMAND, args, {
33
33
  encoding: COMMAND_ENCODING,
34
+ debug: config.debug,
34
35
  ...options
35
36
  });
36
37
  }
package/src/index.js CHANGED
@@ -1,17 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import cron from "node-cron";
4
- import { loadConfig, parseCliArgs, printHelp } from "./config.js";
4
+ import { initConfig, loadConfig, parseCliArgs, printHelp } from "./config.js";
5
5
  import { runReviewCycle } from "./review-runner.js";
6
6
 
7
- const cliArgs = parseCliArgs(process.argv.slice(2));
7
+ let cliArgs;
8
+
9
+ try {
10
+ cliArgs = parseCliArgs(process.argv.slice(2));
11
+ } catch (error) {
12
+ console.error(error?.message || String(error));
13
+ printHelp();
14
+ process.exit(1);
15
+ }
8
16
 
9
17
  if (cliArgs.help) {
10
18
  printHelp();
11
19
  process.exit(0);
12
20
  }
13
21
 
14
- const config = await loadConfig(cliArgs.configPath);
22
+ if (cliArgs.command === "init") {
23
+ try {
24
+ const createdPath = await initConfig(cliArgs.configPath);
25
+ console.log(`Created config: ${createdPath}`);
26
+ process.exit(0);
27
+ } catch (error) {
28
+ console.error(error?.stack || String(error));
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ const config = await loadConfig(cliArgs.configPath, cliArgs);
34
+
35
+ if (config.debug) {
36
+ console.error(
37
+ `[debug] Loaded config: ${JSON.stringify({
38
+ configPath: config.configPath,
39
+ vcs: config.vcs,
40
+ reviewer: config.reviewer,
41
+ target: config.target,
42
+ pollCron: config.pollCron,
43
+ outputDir: config.outputDir,
44
+ debug: config.debug,
45
+ once: cliArgs.once
46
+ })}`
47
+ );
48
+ }
15
49
 
16
50
  let running = false;
17
51
 
@@ -4,6 +4,24 @@ import path from "node:path";
4
4
  import { runCommand } from "./shell.js";
5
5
  import { resolveRepositoryContext } from "./vcs-client.js";
6
6
 
7
+ const DIFF_LIMITS = {
8
+ review: {
9
+ maxLines: 4000,
10
+ maxChars: 120000
11
+ },
12
+ report: {
13
+ maxLines: 1500,
14
+ maxChars: 40000
15
+ },
16
+ tailLines: 200
17
+ };
18
+
19
+ function debugLog(config, message) {
20
+ if (config.debug) {
21
+ console.error(`[debug] ${message}`);
22
+ }
23
+ }
24
+
7
25
  const REVIEWERS = {
8
26
  codex: {
9
27
  displayName: "Codex",
@@ -29,7 +47,8 @@ const REVIEWERS = {
29
47
  cwd: workingDir,
30
48
  input: [promptText, "Unified diff:", diffText].join("\n\n"),
31
49
  allowFailure: true,
32
- timeoutMs: config.commandTimeoutMs
50
+ timeoutMs: config.commandTimeoutMs,
51
+ debug: config.debug
33
52
  });
34
53
 
35
54
  let message = "";
@@ -58,7 +77,8 @@ const REVIEWERS = {
58
77
  cwd: workingDir,
59
78
  input: ["Unified diff:", diffText].join("\n\n"),
60
79
  allowFailure: true,
61
- timeoutMs: config.commandTimeoutMs
80
+ timeoutMs: config.commandTimeoutMs,
81
+ debug: config.debug
62
82
  });
63
83
 
64
84
  return {
@@ -159,7 +179,106 @@ function getReviewWorkspaceRoot(config, backend, targetInfo) {
159
179
  return config.baseDir;
160
180
  }
161
181
 
162
- function buildPrompt(config, backend, targetInfo, details) {
182
+ function countLines(text) {
183
+ if (!text) {
184
+ return 0;
185
+ }
186
+
187
+ return text.split(/\r?\n/).length;
188
+ }
189
+
190
+ function trimBlockToChars(text, maxChars, keepTail = false) {
191
+ if (text.length <= maxChars) {
192
+ return text;
193
+ }
194
+
195
+ if (maxChars <= 3) {
196
+ return ".".repeat(Math.max(maxChars, 0));
197
+ }
198
+
199
+ return keepTail ? `...${text.slice(-(maxChars - 3))}` : `${text.slice(0, maxChars - 3)}...`;
200
+ }
201
+
202
+ function truncateDiffText(diffText, maxLines, maxChars, tailLines, purposeLabel) {
203
+ const normalizedDiff = diffText.replace(/\r\n/g, "\n");
204
+ const originalLineCount = countLines(normalizedDiff);
205
+ const originalCharCount = normalizedDiff.length;
206
+
207
+ if (originalLineCount <= maxLines && originalCharCount <= maxChars) {
208
+ return {
209
+ text: diffText,
210
+ wasTruncated: false,
211
+ originalLineCount,
212
+ originalCharCount,
213
+ outputLineCount: originalLineCount,
214
+ outputCharCount: originalCharCount
215
+ };
216
+ }
217
+
218
+ const lines = normalizedDiff.split("\n");
219
+ const safeTailLines = Math.min(Math.max(tailLines, 0), Math.max(maxLines - 2, 0));
220
+ const headLineCount = Math.max(maxLines - safeTailLines - 1, 1);
221
+ let headBlock = lines.slice(0, headLineCount).join("\n");
222
+ let tailBlock = safeTailLines > 0 ? lines.slice(-safeTailLines).join("\n") : "";
223
+ const omittedLineCount = Math.max(originalLineCount - headLineCount - safeTailLines, 0);
224
+ const markerBlock = [
225
+ `... diff truncated for ${purposeLabel} ...`,
226
+ `original lines: ${originalLineCount}, original chars: ${originalCharCount}`,
227
+ `omitted lines: ${omittedLineCount}`
228
+ ].join("\n");
229
+
230
+ let truncatedText = [headBlock, markerBlock, tailBlock].filter(Boolean).join("\n");
231
+
232
+ if (truncatedText.length > maxChars) {
233
+ const reservedChars = markerBlock.length + (tailBlock ? 2 : 1);
234
+ const remainingChars = Math.max(maxChars - reservedChars, 0);
235
+ const headBudget = tailBlock ? Math.floor(remainingChars * 0.7) : remainingChars;
236
+ const tailBudget = tailBlock ? Math.max(remainingChars - headBudget, 0) : 0;
237
+ headBlock = trimBlockToChars(headBlock, headBudget, false);
238
+ tailBlock = trimBlockToChars(tailBlock, tailBudget, true);
239
+ truncatedText = [headBlock, markerBlock, tailBlock].filter(Boolean).join("\n");
240
+ }
241
+
242
+ return {
243
+ text: truncatedText,
244
+ wasTruncated: true,
245
+ originalLineCount,
246
+ originalCharCount,
247
+ outputLineCount: countLines(truncatedText),
248
+ outputCharCount: truncatedText.length
249
+ };
250
+ }
251
+
252
+ function prepareDiffPayloads(config, diffText) {
253
+ return {
254
+ review: truncateDiffText(
255
+ diffText,
256
+ DIFF_LIMITS.review.maxLines,
257
+ DIFF_LIMITS.review.maxChars,
258
+ DIFF_LIMITS.tailLines,
259
+ "reviewer input"
260
+ ),
261
+ report: truncateDiffText(
262
+ diffText,
263
+ DIFF_LIMITS.report.maxLines,
264
+ DIFF_LIMITS.report.maxChars,
265
+ Math.min(DIFF_LIMITS.tailLines, DIFF_LIMITS.report.maxLines),
266
+ "report output"
267
+ )
268
+ };
269
+ }
270
+
271
+ function formatDiffHandling(diffPayload, label) {
272
+ return [
273
+ `- ${label} Original Lines: \`${diffPayload.originalLineCount}\``,
274
+ `- ${label} Original Chars: \`${diffPayload.originalCharCount}\``,
275
+ `- ${label} Included Lines: \`${diffPayload.outputLineCount}\``,
276
+ `- ${label} Included Chars: \`${diffPayload.outputCharCount}\``,
277
+ `- ${label} Truncated: \`${diffPayload.wasTruncated ? "yes" : "no"}\``
278
+ ].join("\n");
279
+ }
280
+
281
+ function buildPrompt(config, backend, targetInfo, details, reviewDiffPayload) {
163
282
  const fileList = details.changedPaths.map((item) => `${item.action} ${item.relativePath}`).join("\n");
164
283
  const workspaceRoot = getReviewWorkspaceRoot(config, backend, targetInfo);
165
284
  const canReadRelatedFiles = backend.kind === "git" || Boolean(targetInfo.workingCopyPath);
@@ -173,17 +292,19 @@ function buildPrompt(config, backend, targetInfo, details) {
173
292
  ? "Besides the diff below, you may read other related files in the workspace when needed to understand call sites, shared utilities, configuration, tests, or data flow. Do not modify files or rely on shell commands."
174
293
  : "Review primarily from the diff below. Do not assume access to other local files, shell commands, or repository history.",
175
294
  "Use plain text file references like path/to/file.js:123. Do not emit clickable workspace links.",
176
- "Write the final review in Simplified Chinese.",
177
295
  `Repository Type: ${backend.displayName}`,
178
296
  `Change ID: ${details.displayId}`,
179
297
  `Author: ${details.author}`,
180
298
  `Date: ${details.date || "unknown"}`,
181
299
  `Changed files:\n${fileList || "(none)"}`,
182
- `Commit message:\n${details.message || "(empty)"}`
300
+ `Commit message:\n${details.message || "(empty)"}`,
301
+ reviewDiffPayload.wasTruncated
302
+ ? `Diff delivery note: the diff was truncated before being sent to the reviewer to stay within configured size limits. Original diff size was ${reviewDiffPayload.originalLineCount} lines / ${reviewDiffPayload.originalCharCount} chars, and the included excerpt is ${reviewDiffPayload.outputLineCount} lines / ${reviewDiffPayload.outputCharCount} chars. Use the changed file list and inspect related workspace files when needed.`
303
+ : `Diff delivery note: the full diff is included. Size is ${reviewDiffPayload.originalLineCount} lines / ${reviewDiffPayload.originalCharCount} chars.`
183
304
  ].join("\n\n");
184
305
  }
185
306
 
186
- function buildReport(config, backend, targetInfo, details, diffText, reviewer, reviewerResult) {
307
+ function buildReport(config, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult) {
187
308
  const lines = [
188
309
  `# ${backend.displayName} Review Report: ${details.displayId}`,
189
310
  "",
@@ -208,13 +329,18 @@ function buildReport(config, backend, targetInfo, details, diffText, reviewer, r
208
329
  "## Review Context",
209
330
  "",
210
331
  "```text",
211
- buildPrompt(config, backend, targetInfo, details),
332
+ buildPrompt(config, backend, targetInfo, details, diffPayloads.review),
212
333
  "```",
213
334
  "",
335
+ "## Diff Handling",
336
+ "",
337
+ formatDiffHandling(diffPayloads.review, "Reviewer Input"),
338
+ formatDiffHandling(diffPayloads.report, "Report Diff"),
339
+ "",
214
340
  "## Diff",
215
341
  "",
216
342
  "```diff",
217
- diffText.trim() || "(empty diff)",
343
+ diffPayloads.report.text.trim() || "(empty diff)",
218
344
  "```",
219
345
  "",
220
346
  `## ${reviewer.responseSectionTitle}`,
@@ -228,10 +354,12 @@ function buildReport(config, backend, targetInfo, details, diffText, reviewer, r
228
354
  async function runReviewerPrompt(config, backend, targetInfo, details, diffText) {
229
355
  const reviewer = REVIEWERS[config.reviewer];
230
356
  const reviewWorkspaceRoot = getReviewWorkspaceRoot(config, backend, targetInfo);
231
- const promptText = buildPrompt(config, backend, targetInfo, details);
357
+ const diffPayloads = prepareDiffPayloads(config, diffText);
358
+ const promptText = buildPrompt(config, backend, targetInfo, details, diffPayloads.review);
232
359
  return {
233
360
  reviewer,
234
- result: await reviewer.run(config, reviewWorkspaceRoot, promptText, diffText)
361
+ diffPayloads,
362
+ result: await reviewer.run(config, reviewWorkspaceRoot, promptText, diffPayloads.review.text)
235
363
  };
236
364
  }
237
365
 
@@ -274,8 +402,14 @@ async function reviewChange(config, backend, targetInfo, changeId) {
274
402
  }
275
403
 
276
404
  const diffText = await backend.getChangeDiff(config, targetInfo, changeId);
277
- const { reviewer, result: reviewerResult } = await runReviewerPrompt(config, backend, targetInfo, details, diffText);
278
- const report = buildReport(config, backend, targetInfo, details, diffText, reviewer, reviewerResult);
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);
279
413
  const outputFile = path.join(config.outputDir, backend.getReportFileName(changeId));
280
414
  await writeTextFile(outputFile, report);
281
415
 
@@ -294,10 +428,18 @@ export async function runReviewCycle(config) {
294
428
  await ensureDir(config.outputDir);
295
429
 
296
430
  const { backend, targetInfo } = await resolveRepositoryContext(config);
431
+ debugLog(
432
+ config,
433
+ `Resolved repository context: backend=${backend.kind} target=${targetInfo.targetDisplay || config.target} stateKey=${targetInfo.stateKey}`
434
+ );
297
435
  const latestChangeId = await backend.getLatestChangeId(config, targetInfo);
298
436
  const stateFile = await loadState(config.stateFilePath);
299
437
  const projectState = getProjectState(stateFile, targetInfo);
300
438
  let lastReviewedId = readLastReviewedId(projectState, backend, targetInfo);
439
+ debugLog(
440
+ config,
441
+ `Checkpoint status: latest=${backend.formatChangeId(latestChangeId)} lastReviewed=${lastReviewedId ? backend.formatChangeId(lastReviewedId) : "(none)"}`
442
+ );
301
443
 
302
444
  if (lastReviewedId) {
303
445
  const checkpointIsValid = await backend.isValidCheckpoint(config, targetInfo, lastReviewedId, latestChangeId);
@@ -310,7 +452,7 @@ export async function runReviewCycle(config) {
310
452
 
311
453
  let changeIdsToReview = [];
312
454
 
313
- if (!lastReviewedId && config.bootstrapToLatest) {
455
+ if (!lastReviewedId) {
314
456
  changeIdsToReview = [latestChangeId];
315
457
  console.log(`Initialized state to review the latest ${backend.changeName} ${backend.formatChangeId(latestChangeId)} first.`);
316
458
  } else {
@@ -323,6 +465,8 @@ export async function runReviewCycle(config) {
323
465
  );
324
466
  }
325
467
 
468
+ debugLog(config, `Planned ${changeIdsToReview.length} ${backend.changeName}(s) for this cycle.`);
469
+
326
470
  if (changeIdsToReview.length === 0) {
327
471
  const lastKnownId = lastReviewedId ? backend.formatChangeId(lastReviewedId) : "(none)";
328
472
  console.log(`No new ${backend.changeName}s. Last reviewed: ${lastKnownId}`);
@@ -332,11 +476,13 @@ export async function runReviewCycle(config) {
332
476
  console.log(`Reviewing ${backend.displayName} ${backend.changeName}s ${formatChangeList(backend, changeIdsToReview)}`);
333
477
 
334
478
  for (const changeId of changeIdsToReview) {
479
+ debugLog(config, `Starting review for ${backend.formatChangeId(changeId)}.`);
335
480
  const result = await reviewChange(config, backend, targetInfo, changeId);
336
481
  console.log(`Reviewed ${backend.formatChangeId(changeId)}: ${result.outputFile}`);
337
482
  const nextProjectState = buildStateSnapshot(backend, targetInfo, changeId);
338
483
  await saveState(config.stateFilePath, updateProjectState(stateFile, targetInfo, nextProjectState));
339
484
  stateFile.projects[targetInfo.stateKey] = nextProjectState;
485
+ debugLog(config, `Saved checkpoint for ${backend.formatChangeId(changeId)} to ${config.stateFilePath}.`);
340
486
  }
341
487
 
342
488
  const remainingChanges = await backend.getPendingChangeIds(
package/src/shell.js CHANGED
@@ -1,6 +1,21 @@
1
1
  import spawn from "cross-spawn";
2
2
  import iconv from "iconv-lite";
3
3
 
4
+ function debugLog(enabled, message) {
5
+ if (enabled) {
6
+ console.error(`[debug] ${message}`);
7
+ }
8
+ }
9
+
10
+ function summarizeOutput(text) {
11
+ if (!text) {
12
+ return "(empty)";
13
+ }
14
+
15
+ const normalized = text.replace(/\s+/g, " ").trim();
16
+ return normalized.length > 400 ? `${normalized.slice(0, 400)}...` : normalized;
17
+ }
18
+
4
19
  export async function runCommand(command, args = [], options = {}) {
5
20
  const {
6
21
  cwd,
@@ -9,9 +24,15 @@ export async function runCommand(command, args = [], options = {}) {
9
24
  encoding = "utf8",
10
25
  allowFailure = false,
11
26
  timeoutMs = 0,
12
- trim = false
27
+ trim = false,
28
+ debug = false
13
29
  } = options;
14
30
 
31
+ debugLog(
32
+ debug,
33
+ `run: ${command} ${args.join(" ")}${cwd ? ` | cwd=${cwd}` : ""}${timeoutMs > 0 ? ` | timeoutMs=${timeoutMs}` : ""}`
34
+ );
35
+
15
36
  return await new Promise((resolve, reject) => {
16
37
  const child = spawn(command, args, {
17
38
  cwd,
@@ -51,6 +72,11 @@ export async function runCommand(command, args = [], options = {}) {
51
72
  stderr: trim ? stderr.trim() : stderr
52
73
  };
53
74
 
75
+ debugLog(
76
+ debug,
77
+ `exit: ${command} code=${result.code} timedOut=${result.timedOut} stdout=${summarizeOutput(result.stdout)} stderr=${summarizeOutput(result.stderr)}`
78
+ );
79
+
54
80
  if ((result.code !== 0 || result.timedOut) && !allowFailure) {
55
81
  const error = new Error(
56
82
  `Command failed: ${command} ${args.join(" ")}\n${result.stderr || result.stdout}`.trim()
package/src/svn-client.js CHANGED
@@ -38,7 +38,8 @@ function repoPathFromUrl(rootUrl, url) {
38
38
  export async function getTargetInfo(config) {
39
39
  const result = await runCommand(SVN_COMMAND, ["info", "--xml", config.target], {
40
40
  encoding: COMMAND_ENCODING,
41
- trim: true
41
+ trim: true,
42
+ debug: config.debug
42
43
  });
43
44
  const parsed = xmlParser.parse(result.stdout);
44
45
  const entry = parsed?.info?.entry;
@@ -70,7 +71,7 @@ export async function getLatestRevision(config, targetInfo) {
70
71
  const result = await runCommand(
71
72
  SVN_COMMAND,
72
73
  ["log", "--xml", "-r", "HEAD:1", "-l", "1", getRemoteTarget(targetInfo, config)],
73
- { encoding: COMMAND_ENCODING, trim: true }
74
+ { encoding: COMMAND_ENCODING, trim: true, debug: config.debug }
74
75
  );
75
76
  const parsed = xmlParser.parse(result.stdout);
76
77
  const entry = parsed?.log?.logentry;
@@ -100,7 +101,7 @@ export async function getPendingRevisions(config, targetInfo, startExclusive, en
100
101
  `${startRevision}:${endInclusive}`,
101
102
  getRemoteTarget(targetInfo, config)
102
103
  ],
103
- { encoding: COMMAND_ENCODING, trim: true }
104
+ { encoding: COMMAND_ENCODING, trim: true, debug: config.debug }
104
105
  );
105
106
  const parsed = xmlParser.parse(result.stdout);
106
107
 
@@ -115,7 +116,7 @@ export async function getRevisionDiff(config, revision) {
115
116
  const result = await runCommand(
116
117
  SVN_COMMAND,
117
118
  ["diff", "--git", "--internal-diff", "--ignore-properties", "-c", String(revision), config.target],
118
- { encoding: COMMAND_ENCODING, trim: false }
119
+ { encoding: COMMAND_ENCODING, trim: false, debug: config.debug }
119
120
  );
120
121
 
121
122
  return result.stdout;
@@ -145,7 +146,7 @@ export async function getRevisionDetails(config, targetInfo, revision) {
145
146
  const result = await runCommand(
146
147
  SVN_COMMAND,
147
148
  ["log", "--xml", "-v", "-c", String(revision), getRemoteTarget(targetInfo, config)],
148
- { encoding: COMMAND_ENCODING, trim: true }
149
+ { encoding: COMMAND_ENCODING, trim: true, debug: config.debug }
149
150
  );
150
151
  const parsed = xmlParser.parse(result.stdout);
151
152
  const entry = parsed?.log?.logentry;