kodevu 0.1.30 → 0.1.32
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 +44 -69
- package/package.json +1 -1
- package/src/config.js +43 -5
- package/src/git-client.js +10 -0
- package/src/report-generator.js +101 -27
- package/src/review-runner.js +7 -49
- package/src/svn-client.js +14 -0
- package/src/vcs-client.js +4 -24
- package/src/state-manager.js +0 -61
package/README.md
CHANGED
|
@@ -1,63 +1,55 @@
|
|
|
1
1
|
# Kodevu
|
|
2
2
|
|
|
3
|
-
A Node.js tool that
|
|
3
|
+
A Node.js tool that fetches Git commits or SVN revisions, sends the diff to a supported AI reviewer CLI, and writes review results to report files.
|
|
4
4
|
|
|
5
5
|
## Workflow
|
|
6
6
|
|
|
7
7
|
1. Detect the repository type automatically (Git or SVN).
|
|
8
|
-
2.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
|
|
8
|
+
2. Fetch the specified revision(s) as requested by the user:
|
|
9
|
+
- A single specific revision/commit via `--rev`.
|
|
10
|
+
- The latest $N$ revisions/commits via `--last` (default: 1).
|
|
11
|
+
3. For each change:
|
|
12
|
+
- Load metadata and changed paths from SVN or Git.
|
|
13
|
+
- Generate a unified diff for that single revision or commit.
|
|
14
|
+
- Send the diff and change metadata to the configured reviewer CLI.
|
|
15
|
+
- Allow the reviewer to inspect related local repository files in read-only mode when a local workspace is available.
|
|
16
|
+
- Write the result to `~/.kodevu/` (Markdown by default; optional JSON via config).
|
|
17
|
+
|
|
18
|
+
**Note**: Kodevu is stateless. It does not track which changes have been reviewed previously.
|
|
17
19
|
|
|
18
20
|
## Quick start
|
|
19
21
|
|
|
22
|
+
Review the latest commit in your repository:
|
|
23
|
+
|
|
20
24
|
```bash
|
|
21
25
|
npx kodevu /path/to/your/repo
|
|
22
26
|
```
|
|
23
27
|
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
## Setup
|
|
28
|
+
Review the latest 3 commits:
|
|
30
29
|
|
|
31
30
|
```bash
|
|
32
|
-
npx kodevu
|
|
31
|
+
npx kodevu /path/to/your/repo --last 3
|
|
33
32
|
```
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
You only need this when you want to override defaults such as `reviewer` or output paths.
|
|
37
|
-
|
|
38
|
-
If you want a different path:
|
|
34
|
+
Review a specific commit:
|
|
39
35
|
|
|
40
36
|
```bash
|
|
41
|
-
npx kodevu
|
|
37
|
+
npx kodevu /path/to/your/repo --rev abc1234
|
|
42
38
|
```
|
|
43
39
|
|
|
44
|
-
|
|
40
|
+
Review reports are written to `~/.kodevu/` as Markdown (`.md`) by default.
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
## Run
|
|
42
|
+
## Setup
|
|
49
43
|
|
|
50
|
-
|
|
44
|
+
If you want to customize settings beyond the defaults:
|
|
51
45
|
|
|
52
46
|
```bash
|
|
53
|
-
npx kodevu
|
|
47
|
+
npx kodevu init
|
|
54
48
|
```
|
|
55
49
|
|
|
56
|
-
|
|
50
|
+
This creates `config.json` in the current directory. You only need this when you want to override defaults such as `reviewer` or output paths.
|
|
57
51
|
|
|
58
|
-
|
|
59
|
-
npx kodevu /path/to/your/repo --debug
|
|
60
|
-
```
|
|
52
|
+
## Run
|
|
61
53
|
|
|
62
54
|
Specify the output language (e.g., Chinese):
|
|
63
55
|
|
|
@@ -65,60 +57,43 @@ Specify the output language (e.g., Chinese):
|
|
|
65
57
|
npx kodevu /path/to/your/repo --lang zh
|
|
66
58
|
```
|
|
67
59
|
|
|
68
|
-
|
|
60
|
+
Run with debug logs:
|
|
69
61
|
|
|
70
62
|
```bash
|
|
71
|
-
npx kodevu --
|
|
63
|
+
npx kodevu /path/to/your/repo --debug
|
|
72
64
|
```
|
|
73
65
|
|
|
74
|
-
|
|
66
|
+
Use a custom config path:
|
|
75
67
|
|
|
76
68
|
```bash
|
|
77
|
-
npx kodevu
|
|
69
|
+
npx kodevu --config ./my-config.json
|
|
78
70
|
```
|
|
79
71
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
## Config
|
|
83
|
-
|
|
84
|
-
- `target`: required repository target; can be provided by config or as the CLI positional argument
|
|
85
|
-
- `reviewer`: `codex`, `gemini`, `copilot`, or `auto`; default `auto`
|
|
86
|
-
- `prompt`: additional instructions for the reviewer; usually empty by default as the core instructions are built-in
|
|
87
|
-
- `lang`: output language for the review (e.g., `zh`, `en`, `auto`); default `auto`
|
|
88
|
-
- `outputDir`: report output directory; default `~/.kodevu`
|
|
89
|
-
- `outputFormats`: report formats to generate; supports `markdown` and `json`; default `["markdown"]`
|
|
90
|
-
- `stateFilePath`: review state file path; default `~/.kodevu/state.json`
|
|
91
|
-
- `commandTimeoutMs`: timeout for a single review command execution in milliseconds
|
|
92
|
-
- `maxRevisionsPerRun`: cap the number of pending changes per polling cycle
|
|
93
|
-
|
|
94
|
-
Internal defaults:
|
|
72
|
+
## Config / CLI Options
|
|
95
73
|
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
-
|
|
74
|
+
- `target`: Repository target path or SVN URL. (CLI positional argument overrides config)
|
|
75
|
+
- `reviewer`: `codex`, `gemini`, `copilot`, or `auto` (default: `auto`).
|
|
76
|
+
- `rev`: A specific revision or commit hash to review.
|
|
77
|
+
- `last`: Number of latest revisions to review (default: 1 if `rev` is not set).
|
|
78
|
+
- `lang`: Output language for the review (e.g., `zh`, `en`, `auto`).
|
|
79
|
+
- `prompt`: Additional instructions for the reviewer.
|
|
80
|
+
- `outputDir`: Report output directory (default: `~/.kodevu`).
|
|
81
|
+
- `outputFormats`: Report formats to generate (supports `markdown` and `json`; default: `["markdown"]`).
|
|
82
|
+
- `commandTimeoutMs`: Timeout for a single review command execution in milliseconds.
|
|
83
|
+
- `maxRevisionsPerRun`: Cap the number of revisions handled in one run.
|
|
99
84
|
|
|
100
85
|
## Target Rules
|
|
101
86
|
|
|
102
87
|
- For SVN, `target` can be a working copy path or repository URL.
|
|
103
88
|
- For Git, `target` must be a local repository path or a subdirectory inside a local repository.
|
|
104
|
-
- The tool tries Git first
|
|
89
|
+
- The tool tries Git first, then falls back to SVN.
|
|
105
90
|
|
|
106
91
|
## Notes
|
|
107
92
|
|
|
108
|
-
- `reviewer: "
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
- Large diffs are truncated before being sent to the reviewer or written into the report once they exceed the configured line or character limits.
|
|
113
|
-
- 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.
|
|
114
|
-
- For remote SVN URLs without a local working copy, the review still relies on the diff and change metadata only.
|
|
115
|
-
- By default, SVN reports are written as `<YYYYMMDD-HHmmss>-svn-r<revision>.md`.
|
|
116
|
-
- By default, Git reports are written as `<YYYYMMDD-HHmmss>-git-<short-commit-hash>.md`.
|
|
117
|
-
- If `outputFormats` includes `json`, matching `.json` files are generated alongside Markdown reports.
|
|
118
|
-
- `~/.kodevu/state.json` stores per-project checkpoints keyed by repository identity; only the v2 multi-project structure is supported.
|
|
119
|
-
- 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.
|
|
120
|
-
- Review instructions are built into the tool in English to ensure consistent logic across reviewers. The `lang` setting (CLI `--lang` or config `lang`) determines the language AI uses for the resulting review. When set to `auto`, Kodevu detects the language from the system environment (`LANG`, `LC_ALL`, or system locale).
|
|
121
|
-
- Each report includes a `Token Usage` section recording token consumption for the review task. When the reviewer CLI outputs token statistics (via stderr), those are used directly (`source: "reviewer"`). Otherwise tokens are estimated at ~4 characters per token (`source: "estimate"`). The JSON report contains a `tokenUsage` object with `inputTokens`, `outputTokens`, `totalTokens`, and `source`.
|
|
93
|
+
- `reviewer: "auto"` probes `codex`, `gemini`, and `copilot` in `PATH`, then selects an available one.
|
|
94
|
+
- Large diffs are truncated based on internal limits to fit AI context windows.
|
|
95
|
+
- 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.
|
|
96
|
+
- If the reviewer command exits non-zero or times out, the report is still written containing the error details.
|
|
122
97
|
|
|
123
98
|
## License
|
|
124
99
|
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -12,12 +12,13 @@ const defaultConfig = {
|
|
|
12
12
|
target: "",
|
|
13
13
|
lang: "auto",
|
|
14
14
|
outputDir: defaultStorageDir,
|
|
15
|
-
stateFilePath: path.join(defaultStorageDir, "state.json"),
|
|
16
15
|
logsDir: path.join(defaultStorageDir, "logs"),
|
|
17
16
|
commandTimeoutMs: 600000,
|
|
18
17
|
prompt: "",
|
|
19
18
|
maxRevisionsPerRun: 5,
|
|
20
|
-
outputFormats: ["markdown"]
|
|
19
|
+
outputFormats: ["markdown"],
|
|
20
|
+
rev: "",
|
|
21
|
+
last: 0
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
const configTemplate = {
|
|
@@ -26,11 +27,12 @@ const configTemplate = {
|
|
|
26
27
|
lang: defaultConfig.lang,
|
|
27
28
|
prompt: defaultConfig.prompt,
|
|
28
29
|
outputDir: "~/.kodevu",
|
|
29
|
-
stateFilePath: "~/.kodevu/state.json",
|
|
30
30
|
logsDir: "~/.kodevu/logs",
|
|
31
31
|
commandTimeoutMs: defaultConfig.commandTimeoutMs,
|
|
32
32
|
maxRevisionsPerRun: defaultConfig.maxRevisionsPerRun,
|
|
33
|
-
outputFormats: defaultConfig.outputFormats
|
|
33
|
+
outputFormats: defaultConfig.outputFormats,
|
|
34
|
+
rev: "",
|
|
35
|
+
last: 1
|
|
34
36
|
};
|
|
35
37
|
|
|
36
38
|
function resolveConfigPath(baseDir, value) {
|
|
@@ -145,6 +147,8 @@ export function parseCliArgs(argv) {
|
|
|
145
147
|
reviewer: "",
|
|
146
148
|
lang: "",
|
|
147
149
|
prompt: "",
|
|
150
|
+
rev: "",
|
|
151
|
+
last: "",
|
|
148
152
|
commandExplicitlySet: false
|
|
149
153
|
};
|
|
150
154
|
|
|
@@ -208,6 +212,26 @@ export function parseCliArgs(argv) {
|
|
|
208
212
|
continue;
|
|
209
213
|
}
|
|
210
214
|
|
|
215
|
+
if (value === "--rev" || value === "-v") {
|
|
216
|
+
const rev = argv[index + 1];
|
|
217
|
+
if (!rev || rev.startsWith("-")) {
|
|
218
|
+
throw new Error(`Missing value for ${value}`);
|
|
219
|
+
}
|
|
220
|
+
args.rev = rev;
|
|
221
|
+
index += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (value === "--last" || value === "-n") {
|
|
226
|
+
const last = argv[index + 1];
|
|
227
|
+
if (!last || last.startsWith("-")) {
|
|
228
|
+
throw new Error(`Missing value for ${value}`);
|
|
229
|
+
}
|
|
230
|
+
args.last = last;
|
|
231
|
+
index += 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
211
235
|
if (!value.startsWith("-") && args.command === "run" && !args.target) {
|
|
212
236
|
args.target = value;
|
|
213
237
|
continue;
|
|
@@ -258,6 +282,14 @@ export async function loadConfig(configPath, cliArgs = {}) {
|
|
|
258
282
|
config.lang = cliArgs.lang;
|
|
259
283
|
}
|
|
260
284
|
|
|
285
|
+
if (cliArgs.rev) {
|
|
286
|
+
config.rev = cliArgs.rev;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (cliArgs.last) {
|
|
290
|
+
config.last = cliArgs.last;
|
|
291
|
+
}
|
|
292
|
+
|
|
261
293
|
if (!config.target) {
|
|
262
294
|
throw new Error('Missing target. Pass `npx kodevu <repo-path>` or set "target" in config.json.');
|
|
263
295
|
}
|
|
@@ -284,12 +316,16 @@ export async function loadConfig(configPath, cliArgs = {}) {
|
|
|
284
316
|
config.configPath = loadedConfigPath;
|
|
285
317
|
config.baseDir = baseDir;
|
|
286
318
|
config.outputDir = resolveConfigPath(config.baseDir, config.outputDir);
|
|
287
|
-
config.stateFilePath = resolveConfigPath(config.baseDir, config.stateFilePath);
|
|
288
319
|
config.logsDir = resolveConfigPath(config.baseDir, config.logsDir);
|
|
289
320
|
config.maxRevisionsPerRun = Number(config.maxRevisionsPerRun);
|
|
290
321
|
config.commandTimeoutMs = Number(config.commandTimeoutMs);
|
|
322
|
+
config.last = Number(config.last);
|
|
291
323
|
config.outputFormats = normalizeOutputFormats(config.outputFormats, loadedConfigPath);
|
|
292
324
|
|
|
325
|
+
if (!config.rev && (isNaN(config.last) || config.last <= 0)) {
|
|
326
|
+
config.last = 1;
|
|
327
|
+
}
|
|
328
|
+
|
|
293
329
|
if (!Number.isInteger(config.maxRevisionsPerRun) || config.maxRevisionsPerRun <= 0) {
|
|
294
330
|
throw new Error(`"maxRevisionsPerRun" must be a positive integer${loadedConfigPath ? ` in ${loadedConfigPath}` : ""}`);
|
|
295
331
|
}
|
|
@@ -317,6 +353,8 @@ Options:
|
|
|
317
353
|
--reviewer, -r Override reviewer (codex | gemini | copilot | auto)
|
|
318
354
|
--prompt, -p Override prompt
|
|
319
355
|
--lang, -l Override output language (e.g. zh, en, auto)
|
|
356
|
+
--rev, -v Review a specific revision or commit hash
|
|
357
|
+
--last, -n Review the latest N revisions (default: 1)
|
|
320
358
|
--debug, -d Print extra debug information to the console
|
|
321
359
|
--help, -h Show help
|
|
322
360
|
|
package/src/git-client.js
CHANGED
|
@@ -124,6 +124,16 @@ export async function getPendingCommits(config, targetInfo, startExclusive, endI
|
|
|
124
124
|
return splitLines(result.stdout).slice(0, limit);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
export async function getLatestCommitIds(config, targetInfo, limit) {
|
|
128
|
+
const result = await runGit(
|
|
129
|
+
config,
|
|
130
|
+
["rev-list", "-n", String(limit), "HEAD", ...buildPathArgs(targetInfo)],
|
|
131
|
+
{ cwd: targetInfo.repoRootPath, trim: true }
|
|
132
|
+
);
|
|
133
|
+
// Reverse to get chronological order (oldest to newest among the latest n)
|
|
134
|
+
return splitLines(result.stdout).reverse();
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
export async function getCommitDiff(config, targetInfo, commitHash) {
|
|
128
138
|
const result = await runGit(
|
|
129
139
|
config,
|
package/src/report-generator.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
1
|
+
export function getCoreReviewInstruction(lang) {
|
|
2
|
+
const lowLang = (lang || "").toLowerCase();
|
|
3
|
+
if (lowLang.startsWith("zh")) {
|
|
4
|
+
if (lowLang === "zh-tw" || lowLang === "zh-hk") {
|
|
5
|
+
return "請嚴格審查目前的更改,優先考慮錯誤、回歸風險、相容性問題、安全問題、邊界條件缺陷和缺失的測試。請使用 Markdown 進行回覆。如果未發現明顯缺陷,請寫「未發現明顯缺陷」並補充風險。";
|
|
6
|
+
}
|
|
7
|
+
return "请严格审查当前的更改,优先处理 Bug、回归风险、兼容性问题、安全问题、边界条件缺陷和缺失的测试。请使用 Markdown 进行回复。如果未发现明显缺陷,请写“未发现明显缺陷”并补充残留风险。";
|
|
8
|
+
}
|
|
9
|
+
return "Please strictly review the current changes, prioritizing bugs, regression risks, compatibility issues, security concerns, boundary condition flaws, and missing tests. Please use Markdown for your response. If no clear flaws are found, write \"No clear flaws found\" and supplement with residual risks.";
|
|
10
|
+
}
|
|
3
11
|
|
|
4
12
|
export function getReviewWorkspaceRoot(config, backend, targetInfo) {
|
|
5
13
|
if (backend.kind === "git" && targetInfo.repoRootPath) {
|
|
@@ -30,13 +38,77 @@ function getLanguageDisplayName(lang) {
|
|
|
30
38
|
return lang;
|
|
31
39
|
}
|
|
32
40
|
|
|
41
|
+
const LOCALIZED_PHRASES = {
|
|
42
|
+
en: {
|
|
43
|
+
workspaceRoot: "You are running inside a read-only workspace rooted at:",
|
|
44
|
+
noWorkspace: "No local repository workspace is available for this review run.",
|
|
45
|
+
besidesDiff: "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.",
|
|
46
|
+
reviewFromDiff: "Review primarily from the diff below. Do not assume access to other local files, shell commands, or repository history.",
|
|
47
|
+
fileRefs: "Use plain text file references like path/to/file.js:123. Do not emit clickable workspace links.",
|
|
48
|
+
repoType: "Repository Type",
|
|
49
|
+
changeId: "Change ID",
|
|
50
|
+
author: "Author",
|
|
51
|
+
date: "Date",
|
|
52
|
+
changedFiles: "Changed files",
|
|
53
|
+
commitMessage: "Commit message",
|
|
54
|
+
diffNoteTruncated: "Diff delivery note: the diff was truncated before being sent to the reviewer to stay within configured size limits. Original diff size was {originalLineCount} lines / {originalCharCount} chars, and the included excerpt is {outputLineCount} lines / {outputCharCount} chars. Use the changed file list and inspect related workspace files when needed.",
|
|
55
|
+
diffNoteFull: "Diff delivery note: the full diff is included. Size is {originalLineCount} lines / {originalCharCount} chars.",
|
|
56
|
+
langRule: "--- IMPORTANT LANGUAGE RULE ---\nYou MUST respond strictly in {langName}. No other language should be used for the explanation and comments."
|
|
57
|
+
},
|
|
58
|
+
zh: {
|
|
59
|
+
workspaceRoot: "你正运行在一个只读工作区内,根目录为:",
|
|
60
|
+
noWorkspace: "此审查运行没有可用的本地仓库工作区。",
|
|
61
|
+
besidesDiff: "除了下面的 Diff,你可以在需要时阅读工作区中的其他相关文件,以了解调用点、共享工具、配置、测试或数据流。请勿修改文件或依赖 Shell 命令。",
|
|
62
|
+
reviewFromDiff: "主要根据下面的 Diff 进行审查。不要假设可以访问其他本地文件、Shell 命令或仓库历史。",
|
|
63
|
+
fileRefs: "使用纯文本文件引用,如 path/to/file.js:123。不要生成可点击的工作区链接。",
|
|
64
|
+
repoType: "仓库类型",
|
|
65
|
+
changeId: "变更 ID",
|
|
66
|
+
author: "作者",
|
|
67
|
+
date: "日期",
|
|
68
|
+
changedFiles: "已变更文件",
|
|
69
|
+
commitMessage: "提交信息",
|
|
70
|
+
diffNoteTruncated: "Diff 交付说明:Diff 在发送给审查者之前已被截斷,以保持在配置的大小限制内。原始 Diff 大小为 {originalLineCount} 行 / {originalCharCount} 个字符,包含的摘录为 {outputLineCount} 行 / {outputCharCount} 个字符。在需要时使用已更正文件列表并检查相关工作区文件。",
|
|
71
|
+
diffNoteFull: "Diff 交付说明:包含完整的 Diff。大小为 {originalLineCount} 行 / {originalCharCount} 个字符。",
|
|
72
|
+
langRule: "--- 重要语言规则 ---\n你必须严格使用 {langName} 进行回复。解释、评论和总结均不得使用其他语言。"
|
|
73
|
+
},
|
|
74
|
+
"zh-tw": {
|
|
75
|
+
workspaceRoot: "你正運行在一個唯讀工作區內,根目錄為:",
|
|
76
|
+
noWorkspace: "此審查運行沒有可用的本地倉庫工作區。",
|
|
77
|
+
besidesDiff: "除了下面的 Diff,你可以在需要時閱讀工作區中的其他相關文件,以了解調用點、共享工具、配置、測試或資料流。請勿修改文件或依賴 Shell 命令。",
|
|
78
|
+
reviewFromDiff: "主要根據下面的 Diff 進行審查。不要假設可以訪問其他本地文件、Shell 命令或倉庫歷史。",
|
|
79
|
+
fileRefs: "使用純文本文件引用,如 path/to/file.js:123。不要生成可點擊的工作區連結。",
|
|
80
|
+
repoType: "倉庫類型",
|
|
81
|
+
changeId: "變更 ID",
|
|
82
|
+
author: "作者",
|
|
83
|
+
date: "日期",
|
|
84
|
+
changedFiles: "已變更文件",
|
|
85
|
+
commitMessage: "提交信息",
|
|
86
|
+
diffNoteTruncated: "Diff 交付說明:Diff 在傳送給審查者之前已被截斷,以保持在配置的大小限制內。原始 Diff 大小為 {originalLineCount} 行 / {originalCharCount} 個字符,包含的摘錄為 {outputLineCount} 行 / {outputCharCount} 個字符。在需要時使用已更正文件列表並檢查相關工作區文件。",
|
|
87
|
+
diffNoteFull: "Diff 交付說明:包含完整的 Diff。大小為 {originalLineCount} 行 / {originalCharCount} 個字符。",
|
|
88
|
+
langRule: "--- 重要語言規則 ---\n你必須嚴格使用 {langName} 進行回覆。解釋、評論和總結均不得使用其他語言。"
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function getPhrase(key, lang, placeholders = {}) {
|
|
93
|
+
const lowLang = (lang || "en").toLowerCase();
|
|
94
|
+
const langKey = lowLang.startsWith("zh") ? (lowLang === "zh-tw" || lowLang === "zh-hk" ? "zh-tw" : "zh") : "en";
|
|
95
|
+
let phrase = LOCALIZED_PHRASES[langKey][key] || LOCALIZED_PHRASES.en[key];
|
|
96
|
+
|
|
97
|
+
for (const [k, v] of Object.entries(placeholders)) {
|
|
98
|
+
phrase = phrase.replace(`{${k}}`, v);
|
|
99
|
+
}
|
|
100
|
+
return phrase;
|
|
101
|
+
}
|
|
102
|
+
|
|
33
103
|
export function buildPrompt(config, backend, targetInfo, details, reviewDiffPayload) {
|
|
34
104
|
const fileList = details.changedPaths.map((item) => `${item.action} ${item.relativePath}`).join("\n");
|
|
35
105
|
const workspaceRoot = getReviewWorkspaceRoot(config, backend, targetInfo);
|
|
36
106
|
const canReadRelatedFiles = backend.kind === "git" || Boolean(targetInfo.workingCopyPath);
|
|
37
107
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
108
|
+
const lang = config.resolvedLang || "en";
|
|
109
|
+
const langName = getLanguageDisplayName(lang);
|
|
110
|
+
const lowLang = lang.toLowerCase();
|
|
111
|
+
|
|
40
112
|
let langInstruction = `IMPORTANT: Your entire response MUST be in ${langName}. All explanations, comments, and structure should strictly follow the ${langName} language rules.`;
|
|
41
113
|
|
|
42
114
|
if (lowLang.startsWith("zh")) {
|
|
@@ -47,39 +119,41 @@ export function buildPrompt(config, backend, targetInfo, details, reviewDiffPayl
|
|
|
47
119
|
}
|
|
48
120
|
}
|
|
49
121
|
|
|
50
|
-
|
|
51
|
-
|
|
52
122
|
return [
|
|
53
|
-
CORE_REVIEW_INSTRUCTION,
|
|
54
123
|
langInstruction,
|
|
124
|
+
getCoreReviewInstruction(lang),
|
|
55
125
|
config.prompt,
|
|
56
126
|
canReadRelatedFiles
|
|
57
|
-
?
|
|
58
|
-
: "
|
|
127
|
+
? `${getPhrase("workspaceRoot", lang)} ${workspaceRoot}`
|
|
128
|
+
: getPhrase("noWorkspace", lang),
|
|
59
129
|
canReadRelatedFiles
|
|
60
|
-
? "
|
|
61
|
-
: "
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
130
|
+
? getPhrase("besidesDiff", lang)
|
|
131
|
+
: getPhrase("reviewFromDiff", lang),
|
|
132
|
+
getPhrase("fileRefs", lang),
|
|
133
|
+
`${getPhrase("repoType", lang)}: ${backend.displayName}`,
|
|
134
|
+
`${getPhrase("changeId", lang)}: ${details.displayId}`,
|
|
135
|
+
`${getPhrase("author", lang)}: ${details.author}`,
|
|
136
|
+
`${getPhrase("date", lang)}: ${details.date || "unknown"}`,
|
|
137
|
+
`${getPhrase("changedFiles", lang)}:\n${fileList || "(none)"}`,
|
|
138
|
+
`${getPhrase("commitMessage", lang)}:\n${details.message || "(empty)"}`,
|
|
69
139
|
reviewDiffPayload.wasTruncated
|
|
70
|
-
?
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
140
|
+
? getPhrase("diffNoteTruncated", lang, {
|
|
141
|
+
originalLineCount: reviewDiffPayload.originalLineCount,
|
|
142
|
+
originalCharCount: reviewDiffPayload.originalCharCount,
|
|
143
|
+
outputLineCount: reviewDiffPayload.outputLineCount,
|
|
144
|
+
outputCharCount: reviewDiffPayload.outputCharCount
|
|
145
|
+
})
|
|
146
|
+
: getPhrase("diffNoteFull", lang, {
|
|
147
|
+
originalLineCount: reviewDiffPayload.originalLineCount,
|
|
148
|
+
originalCharCount: reviewDiffPayload.originalCharCount
|
|
149
|
+
}),
|
|
150
|
+
getPhrase("langRule", lang, { langName }) +
|
|
151
|
+
(lowLang.startsWith("zh")
|
|
74
152
|
? lowLang === "zh-tw" || lowLang === "zh-hk"
|
|
75
153
|
? "\n請務必完全使用繁體中文進行回覆,所有的審查分析、注釋和總結都必須使用繁體中文。"
|
|
76
154
|
: "\n请务必完全使用简体中文进行回复,所有的审查分析、注释和总结都必须使用简体中文。"
|
|
77
|
-
: ""
|
|
78
|
-
}`
|
|
79
|
-
|
|
155
|
+
: "")
|
|
80
156
|
].filter(Boolean).join("\n\n");
|
|
81
|
-
|
|
82
|
-
|
|
83
157
|
}
|
|
84
158
|
|
|
85
159
|
export function formatTokenUsage(tokenUsage) {
|
package/src/review-runner.js
CHANGED
|
@@ -7,14 +7,6 @@ import {
|
|
|
7
7
|
writeTextFile,
|
|
8
8
|
writeJsonFile
|
|
9
9
|
} from "./utils.js";
|
|
10
|
-
import {
|
|
11
|
-
loadState,
|
|
12
|
-
saveState,
|
|
13
|
-
getProjectState,
|
|
14
|
-
updateProjectState,
|
|
15
|
-
readLastReviewedId,
|
|
16
|
-
buildStateSnapshot
|
|
17
|
-
} from "./state-manager.js";
|
|
18
10
|
import {
|
|
19
11
|
shouldWriteFormat,
|
|
20
12
|
buildReport,
|
|
@@ -158,45 +150,19 @@ export async function runReviewCycle(config) {
|
|
|
158
150
|
|
|
159
151
|
const { backend, targetInfo } = await resolveRepositoryContext(config);
|
|
160
152
|
logger.debug(
|
|
161
|
-
`Resolved repository context: backend=${backend.kind} target=${targetInfo.targetDisplay || config.target}
|
|
162
|
-
);
|
|
163
|
-
const latestChangeId = await backend.getLatestChangeId(config, targetInfo);
|
|
164
|
-
const stateFile = await loadState(config.stateFilePath);
|
|
165
|
-
const projectState = getProjectState(stateFile, targetInfo);
|
|
166
|
-
let lastReviewedId = readLastReviewedId(projectState, backend, targetInfo);
|
|
167
|
-
logger.debug(
|
|
168
|
-
`Checkpoint status: latest=${backend.formatChangeId(latestChangeId)} lastReviewed=${lastReviewedId ? backend.formatChangeId(lastReviewedId) : "(none)"}`
|
|
153
|
+
`Resolved repository context: backend=${backend.kind} target=${targetInfo.targetDisplay || config.target}`
|
|
169
154
|
);
|
|
170
155
|
|
|
171
|
-
if (lastReviewedId) {
|
|
172
|
-
const checkpointIsValid = await backend.isValidCheckpoint(config, targetInfo, lastReviewedId, latestChangeId);
|
|
173
|
-
|
|
174
|
-
if (!checkpointIsValid) {
|
|
175
|
-
logger.info("Saved review state no longer matches repository history. Resetting checkpoint.");
|
|
176
|
-
lastReviewedId = null;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
156
|
let changeIdsToReview = [];
|
|
181
157
|
|
|
182
|
-
if (
|
|
183
|
-
changeIdsToReview = [
|
|
184
|
-
logger.info(`Initialized state to review the latest ${backend.changeName} ${backend.formatChangeId(latestChangeId)} first.`);
|
|
158
|
+
if (config.rev) {
|
|
159
|
+
changeIdsToReview = [config.rev];
|
|
185
160
|
} else {
|
|
186
|
-
changeIdsToReview = await backend.
|
|
187
|
-
config,
|
|
188
|
-
targetInfo,
|
|
189
|
-
lastReviewedId,
|
|
190
|
-
latestChangeId,
|
|
191
|
-
config.maxRevisionsPerRun
|
|
192
|
-
);
|
|
161
|
+
changeIdsToReview = await backend.getLatestChangeIds(config, targetInfo, config.last || 1);
|
|
193
162
|
}
|
|
194
163
|
|
|
195
|
-
logger.debug(`Planned ${changeIdsToReview.length} ${backend.changeName}(s) for this cycle.`);
|
|
196
|
-
|
|
197
164
|
if (changeIdsToReview.length === 0) {
|
|
198
|
-
|
|
199
|
-
logger.info(`No new ${backend.changeName}s. Last reviewed: ${lastKnownId}`);
|
|
165
|
+
logger.info("No changes found to review.");
|
|
200
166
|
return;
|
|
201
167
|
}
|
|
202
168
|
|
|
@@ -215,20 +181,12 @@ export async function runReviewCycle(config) {
|
|
|
215
181
|
updateOverallProgress(progress, index, changeIdsToReview.length, fraction, `${displayId} | ${stage}`);
|
|
216
182
|
};
|
|
217
183
|
|
|
218
|
-
let result;
|
|
219
|
-
|
|
220
184
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
185
|
+
await reviewChange(config, backend, targetInfo, changeId, { update: syncOverallProgress, log: (message) => progress.log(message) });
|
|
186
|
+
updateOverallProgress(progress, index + 1, changeIdsToReview.length, 0, `finished ${displayId}`);
|
|
223
187
|
} catch (error) {
|
|
224
188
|
progress.fail(`failed at ${displayId} (${index}/${changeIdsToReview.length} completed)`);
|
|
225
189
|
throw error;
|
|
226
190
|
}
|
|
227
|
-
|
|
228
|
-
const nextProjectSnapshot = buildStateSnapshot(backend, targetInfo, changeId);
|
|
229
|
-
await saveState(config.stateFilePath, updateProjectState(stateFile, targetInfo, nextProjectSnapshot));
|
|
230
|
-
stateFile.projects[targetInfo.stateKey] = nextProjectSnapshot;
|
|
231
|
-
logger.debug(`Saved checkpoint for ${backend.formatChangeId(changeId)} to ${config.stateFilePath}.`);
|
|
232
|
-
updateOverallProgress(progress, index + 1, changeIdsToReview.length, 0, `finished ${displayId}`);
|
|
233
191
|
}
|
|
234
192
|
}
|
package/src/svn-client.js
CHANGED
|
@@ -112,6 +112,20 @@ export async function getPendingRevisions(config, targetInfo, startExclusive, en
|
|
|
112
112
|
.slice(0, limit);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
export async function getLatestRevisionIds(config, targetInfo, limit) {
|
|
116
|
+
const result = await runCommand(
|
|
117
|
+
SVN_COMMAND,
|
|
118
|
+
["log", "--xml", "--quiet", "-l", String(limit), "-r", "HEAD:1", getRemoteTarget(targetInfo, config)],
|
|
119
|
+
{ encoding: COMMAND_ENCODING, trim: true, debug: config.debug }
|
|
120
|
+
);
|
|
121
|
+
const parsed = xmlParser.parse(result.stdout);
|
|
122
|
+
|
|
123
|
+
return asArray(parsed?.log?.logentry)
|
|
124
|
+
.map((entry) => Number(entry?.revision))
|
|
125
|
+
.filter((revision) => Number.isInteger(revision))
|
|
126
|
+
.sort((left, right) => left - right);
|
|
127
|
+
}
|
|
128
|
+
|
|
115
129
|
export async function getRevisionDiff(config, revision) {
|
|
116
130
|
const result = await runCommand(
|
|
117
131
|
SVN_COMMAND,
|
package/src/vcs-client.js
CHANGED
|
@@ -42,8 +42,8 @@ function createSvnBackend() {
|
|
|
42
42
|
async getLatestChangeId(config, targetInfo) {
|
|
43
43
|
return await svnClient.getLatestRevision(config, targetInfo);
|
|
44
44
|
},
|
|
45
|
-
async
|
|
46
|
-
return await svnClient.
|
|
45
|
+
async getLatestChangeIds(config, targetInfo, limit) {
|
|
46
|
+
return await svnClient.getLatestRevisionIds(config, targetInfo, limit);
|
|
47
47
|
},
|
|
48
48
|
async getChangeDiff(config, targetInfo, revision) {
|
|
49
49
|
return await svnClient.getRevisionDiff(config, revision);
|
|
@@ -59,16 +59,6 @@ function createSvnBackend() {
|
|
|
59
59
|
message: details.message,
|
|
60
60
|
changedPaths: details.changedPaths
|
|
61
61
|
};
|
|
62
|
-
},
|
|
63
|
-
async isValidCheckpoint() {
|
|
64
|
-
return true;
|
|
65
|
-
},
|
|
66
|
-
toStateValue(revision) {
|
|
67
|
-
return Number(revision);
|
|
68
|
-
},
|
|
69
|
-
fromStateValue(state) {
|
|
70
|
-
const id = state.lastReviewedId;
|
|
71
|
-
return Number.isInteger(id) ? id : null;
|
|
72
62
|
}
|
|
73
63
|
};
|
|
74
64
|
}
|
|
@@ -99,8 +89,8 @@ function createGitBackend() {
|
|
|
99
89
|
async getLatestChangeId(config, targetInfo) {
|
|
100
90
|
return await gitClient.getLatestCommit(config, targetInfo);
|
|
101
91
|
},
|
|
102
|
-
async
|
|
103
|
-
return await gitClient.
|
|
92
|
+
async getLatestChangeIds(config, targetInfo, limit) {
|
|
93
|
+
return await gitClient.getLatestCommitIds(config, targetInfo, limit);
|
|
104
94
|
},
|
|
105
95
|
async getChangeDiff(config, targetInfo, commitHash) {
|
|
106
96
|
return await gitClient.getCommitDiff(config, targetInfo, commitHash);
|
|
@@ -116,16 +106,6 @@ function createGitBackend() {
|
|
|
116
106
|
message: details.message,
|
|
117
107
|
changedPaths: details.changedPaths
|
|
118
108
|
};
|
|
119
|
-
},
|
|
120
|
-
async isValidCheckpoint(config, targetInfo, checkpointCommit, latestCommit) {
|
|
121
|
-
return await gitClient.isValidCheckpoint(config, targetInfo, checkpointCommit, latestCommit);
|
|
122
|
-
},
|
|
123
|
-
toStateValue(commitHash) {
|
|
124
|
-
return String(commitHash);
|
|
125
|
-
},
|
|
126
|
-
fromStateValue(state) {
|
|
127
|
-
const id = state.lastReviewedId;
|
|
128
|
-
return typeof id === "string" && id ? id : null;
|
|
129
109
|
}
|
|
130
110
|
};
|
|
131
111
|
}
|
package/src/state-manager.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { pathExists, ensureDir } from "./utils.js";
|
|
4
|
-
|
|
5
|
-
function normalizeStateFile(state) {
|
|
6
|
-
if (!state || typeof state !== "object" || Array.isArray(state)) {
|
|
7
|
-
throw new Error('State file must be a JSON object with shape {"version":2,"projects":{...}}.');
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
if (state.version !== 2) {
|
|
11
|
-
throw new Error('State file version must be 2.');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
if (!state.projects || typeof state.projects !== "object" || Array.isArray(state.projects)) {
|
|
15
|
-
throw new Error('State file must contain a "projects" object.');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
version: 2,
|
|
20
|
-
projects: state.projects
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function loadState(stateFile) {
|
|
25
|
-
if (!(await pathExists(stateFile))) {
|
|
26
|
-
return { version: 2, projects: {} };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const raw = await fs.readFile(stateFile, "utf8");
|
|
30
|
-
return normalizeStateFile(JSON.parse(raw));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function saveState(stateFile, state) {
|
|
34
|
-
await ensureDir(path.dirname(stateFile));
|
|
35
|
-
await fs.writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function getProjectState(stateFile, targetInfo) {
|
|
39
|
-
return stateFile.projects?.[targetInfo.stateKey] ?? {};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function updateProjectState(stateFile, targetInfo, projectState) {
|
|
43
|
-
return {
|
|
44
|
-
version: 2,
|
|
45
|
-
projects: {
|
|
46
|
-
...(stateFile.projects || {}),
|
|
47
|
-
[targetInfo.stateKey]: projectState
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function readLastReviewedId(state, backend, targetInfo) {
|
|
53
|
-
return backend.fromStateValue(state);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function buildStateSnapshot(backend, targetInfo, changeId) {
|
|
57
|
-
return {
|
|
58
|
-
lastReviewedId: backend.toStateValue(changeId),
|
|
59
|
-
updatedAt: new Date().toISOString()
|
|
60
|
-
};
|
|
61
|
-
}
|