kodevu 0.1.23 → 0.1.25
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/LICENSE +21 -0
- package/README.md +4 -0
- package/package.json +2 -1
- package/src/config.js +3 -0
- package/src/index.js +26 -21
- package/src/logger.js +107 -0
- package/src/review-runner.js +14 -19
- package/src/shell.js +3 -9
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gyt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -111,3 +111,7 @@ Internal defaults:
|
|
|
111
111
|
- `~/.kodevu/state.json` stores per-project checkpoints keyed by repository identity; only the v2 multi-project structure is supported.
|
|
112
112
|
- 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.
|
|
113
113
|
- 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`.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -11,6 +11,7 @@ const defaultConfig = {
|
|
|
11
11
|
target: "",
|
|
12
12
|
outputDir: defaultStorageDir,
|
|
13
13
|
stateFilePath: path.join(defaultStorageDir, "state.json"),
|
|
14
|
+
logsDir: path.join(defaultStorageDir, "logs"),
|
|
14
15
|
commandTimeoutMs: 600000,
|
|
15
16
|
prompt:
|
|
16
17
|
"请严格审查当前变更,优先指出 bug、回归风险、兼容性问题、安全问题、边界条件缺陷和缺失测试。请使用简体中文输出 Markdown;如果没有明确缺陷,请写“未发现明确缺陷”,并补充剩余风险。",
|
|
@@ -24,6 +25,7 @@ const configTemplate = {
|
|
|
24
25
|
prompt: defaultConfig.prompt,
|
|
25
26
|
outputDir: "~/.kodevu",
|
|
26
27
|
stateFilePath: "~/.kodevu/state.json",
|
|
28
|
+
logsDir: "~/.kodevu/logs",
|
|
27
29
|
commandTimeoutMs: defaultConfig.commandTimeoutMs,
|
|
28
30
|
maxRevisionsPerRun: defaultConfig.maxRevisionsPerRun,
|
|
29
31
|
outputFormats: defaultConfig.outputFormats
|
|
@@ -231,6 +233,7 @@ export async function loadConfig(configPath, cliArgs = {}) {
|
|
|
231
233
|
config.baseDir = baseDir;
|
|
232
234
|
config.outputDir = resolveConfigPath(config.baseDir, config.outputDir);
|
|
233
235
|
config.stateFilePath = resolveConfigPath(config.baseDir, config.stateFilePath);
|
|
236
|
+
config.logsDir = resolveConfigPath(config.baseDir, config.logsDir);
|
|
234
237
|
config.maxRevisionsPerRun = Number(config.maxRevisionsPerRun);
|
|
235
238
|
config.commandTimeoutMs = Number(config.commandTimeoutMs);
|
|
236
239
|
config.outputFormats = normalizeOutputFormats(config.outputFormats, loadedConfigPath);
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { initConfig, loadConfig, parseCliArgs, printHelp } from "./config.js";
|
|
4
4
|
import { runReviewCycle } from "./review-runner.js";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
5
6
|
|
|
6
7
|
let cliArgs;
|
|
7
8
|
|
|
@@ -29,32 +30,36 @@ if (cliArgs.command === "init") {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
try {
|
|
34
|
+
const config = await loadConfig(cliArgs.configPath, cliArgs);
|
|
35
|
+
logger.init(config);
|
|
33
36
|
|
|
34
|
-
if (config.reviewerWasAutoSelected) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
37
|
+
if (config.reviewerWasAutoSelected) {
|
|
38
|
+
logger.info(
|
|
39
|
+
`Reviewer "auto" selected ${config.reviewer}${config.reviewerCommandPath ? ` (${config.reviewerCommandPath})` : ""}.`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
39
42
|
|
|
40
|
-
if (config.debug) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
43
|
+
if (config.debug) {
|
|
44
|
+
logger.debug(
|
|
45
|
+
`Loaded config: ${JSON.stringify({
|
|
46
|
+
configPath: config.configPath,
|
|
47
|
+
reviewer: config.reviewer,
|
|
48
|
+
reviewerCommandPath: config.reviewerCommandPath,
|
|
49
|
+
reviewerWasAutoSelected: config.reviewerWasAutoSelected,
|
|
50
|
+
target: config.target,
|
|
51
|
+
outputDir: config.outputDir,
|
|
52
|
+
debug: config.debug
|
|
53
|
+
})}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
53
56
|
|
|
54
|
-
|
|
57
|
+
logger.info(`Session started. Target: ${config.target}`);
|
|
55
58
|
await runReviewCycle(config);
|
|
59
|
+
logger.info("Session completed successfully.");
|
|
56
60
|
} catch (error) {
|
|
57
|
-
|
|
61
|
+
// If config was loaded, logger might be initialized, otherwise it will fall back to stderr
|
|
62
|
+
logger.error("Session failed with error", error);
|
|
58
63
|
process.exitCode = 1;
|
|
59
64
|
}
|
|
60
65
|
process.exit(process.exitCode || 0);
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
class Logger {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.config = null;
|
|
7
|
+
this.logFile = null;
|
|
8
|
+
this.progressDisplay = null;
|
|
9
|
+
this.initialized = false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
init(config) {
|
|
13
|
+
if (this.initialized) return;
|
|
14
|
+
this.config = config;
|
|
15
|
+
|
|
16
|
+
if (config.logsDir) {
|
|
17
|
+
try {
|
|
18
|
+
if (!fs.existsSync(config.logsDir)) {
|
|
19
|
+
fs.mkdirSync(config.logsDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
const date = new Date().toISOString().split("T")[0];
|
|
22
|
+
this.logFile = path.join(config.logsDir, `run-${date}.log`);
|
|
23
|
+
|
|
24
|
+
// Simple rotation: Clean up logs older than 7 days
|
|
25
|
+
this._cleanupOldLogs(config.logsDir);
|
|
26
|
+
this.initialized = true;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error(`[logger] Failed to initialize log file: ${err.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setProgressDisplay(pd) {
|
|
34
|
+
this.progressDisplay = pd;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
info(message) {
|
|
38
|
+
this._log("INFO", message);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
error(message, error) {
|
|
42
|
+
let msg = message;
|
|
43
|
+
if (error) {
|
|
44
|
+
msg += `\n${error.stack || error}`;
|
|
45
|
+
}
|
|
46
|
+
this._log("ERROR", msg);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
debug(message) {
|
|
50
|
+
if (this.config?.debug) {
|
|
51
|
+
this._log("DEBUG", message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_log(level, message) {
|
|
56
|
+
const timestamp = new Date().toISOString();
|
|
57
|
+
const logLine = `[${timestamp}] [${level}] ${message}`;
|
|
58
|
+
|
|
59
|
+
// Write to file
|
|
60
|
+
if (this.logFile) {
|
|
61
|
+
try {
|
|
62
|
+
fs.appendFileSync(this.logFile, logLine + "\n");
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// Ignore file errors during logging to prevent crashes
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Console output
|
|
69
|
+
const isDebug = level === "DEBUG";
|
|
70
|
+
const isError = level === "ERROR";
|
|
71
|
+
|
|
72
|
+
// If it's debug and debug mode is off, skip console
|
|
73
|
+
if (isDebug && !this.config?.debug) return;
|
|
74
|
+
|
|
75
|
+
if (this.progressDisplay) {
|
|
76
|
+
this.progressDisplay.log(logLine);
|
|
77
|
+
} else {
|
|
78
|
+
if (isError) {
|
|
79
|
+
console.error(logLine);
|
|
80
|
+
} else {
|
|
81
|
+
console.error(logLine);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_cleanupOldLogs(logsDir) {
|
|
87
|
+
try {
|
|
88
|
+
const files = fs.readdirSync(logsDir);
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
91
|
+
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
if (file.startsWith("run-") && file.endsWith(".log")) {
|
|
94
|
+
const filePath = path.join(logsDir, file);
|
|
95
|
+
const stats = fs.statSync(filePath);
|
|
96
|
+
if (now - stats.mtimeMs > MAX_AGE_MS) {
|
|
97
|
+
fs.unlinkSync(filePath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// Ignore cleanup errors
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const logger = new Logger();
|
package/src/review-runner.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { ProgressDisplay } from "./progress-ui.js";
|
|
5
5
|
import { runCommand } from "./shell.js";
|
|
6
6
|
import { resolveRepositoryContext } from "./vcs-client.js";
|
|
7
|
+
import { logger } from "./logger.js";
|
|
7
8
|
|
|
8
9
|
const DIFF_LIMITS = {
|
|
9
10
|
review: {
|
|
@@ -17,11 +18,6 @@ const DIFF_LIMITS = {
|
|
|
17
18
|
tailLines: 200
|
|
18
19
|
};
|
|
19
20
|
|
|
20
|
-
function debugLog(config, message) {
|
|
21
|
-
if (config.debug) {
|
|
22
|
-
console.error(`[debug] ${message}`);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
21
|
|
|
26
22
|
function estimateTokenCount(text) {
|
|
27
23
|
if (!text) {
|
|
@@ -634,7 +630,7 @@ async function reviewChange(config, backend, targetInfo, changeId, progress) {
|
|
|
634
630
|
|
|
635
631
|
for (const reviewerName of reviewersToTry) {
|
|
636
632
|
currentReviewerConfig = { ...config, reviewer: reviewerName };
|
|
637
|
-
|
|
633
|
+
logger.debug(`Trying reviewer: ${reviewerName}`);
|
|
638
634
|
progress?.update(0.45, `running reviewer ${reviewerName}`);
|
|
639
635
|
|
|
640
636
|
const res = await runReviewerPrompt(
|
|
@@ -659,7 +655,7 @@ async function reviewChange(config, backend, targetInfo, changeId, progress) {
|
|
|
659
655
|
}
|
|
660
656
|
|
|
661
657
|
progress?.update(0.82, "writing report");
|
|
662
|
-
|
|
658
|
+
logger.debug(`Token usage: input=${tokenUsage.inputTokens} output=${tokenUsage.outputTokens} total=${tokenUsage.totalTokens} source=${tokenUsage.source}`);
|
|
663
659
|
const report = buildReport(currentReviewerConfig, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult, tokenUsage);
|
|
664
660
|
const outputFile = path.join(config.outputDir, backend.getReportFileName(changeId));
|
|
665
661
|
const jsonOutputFile = outputFile.replace(/\.md$/i, ".json");
|
|
@@ -708,16 +704,14 @@ export async function runReviewCycle(config) {
|
|
|
708
704
|
await ensureDir(config.outputDir);
|
|
709
705
|
|
|
710
706
|
const { backend, targetInfo } = await resolveRepositoryContext(config);
|
|
711
|
-
|
|
712
|
-
config,
|
|
707
|
+
logger.debug(
|
|
713
708
|
`Resolved repository context: backend=${backend.kind} target=${targetInfo.targetDisplay || config.target} stateKey=${targetInfo.stateKey}`
|
|
714
709
|
);
|
|
715
710
|
const latestChangeId = await backend.getLatestChangeId(config, targetInfo);
|
|
716
711
|
const stateFile = await loadState(config.stateFilePath);
|
|
717
712
|
const projectState = getProjectState(stateFile, targetInfo);
|
|
718
713
|
let lastReviewedId = readLastReviewedId(projectState, backend, targetInfo);
|
|
719
|
-
|
|
720
|
-
config,
|
|
714
|
+
logger.debug(
|
|
721
715
|
`Checkpoint status: latest=${backend.formatChangeId(latestChangeId)} lastReviewed=${lastReviewedId ? backend.formatChangeId(lastReviewedId) : "(none)"}`
|
|
722
716
|
);
|
|
723
717
|
|
|
@@ -725,7 +719,7 @@ export async function runReviewCycle(config) {
|
|
|
725
719
|
const checkpointIsValid = await backend.isValidCheckpoint(config, targetInfo, lastReviewedId, latestChangeId);
|
|
726
720
|
|
|
727
721
|
if (!checkpointIsValid) {
|
|
728
|
-
|
|
722
|
+
logger.info("Saved review state no longer matches repository history. Resetting checkpoint.");
|
|
729
723
|
lastReviewedId = null;
|
|
730
724
|
}
|
|
731
725
|
}
|
|
@@ -734,7 +728,7 @@ export async function runReviewCycle(config) {
|
|
|
734
728
|
|
|
735
729
|
if (!lastReviewedId) {
|
|
736
730
|
changeIdsToReview = [latestChangeId];
|
|
737
|
-
|
|
731
|
+
logger.info(`Initialized state to review the latest ${backend.changeName} ${backend.formatChangeId(latestChangeId)} first.`);
|
|
738
732
|
} else {
|
|
739
733
|
changeIdsToReview = await backend.getPendingChangeIds(
|
|
740
734
|
config,
|
|
@@ -745,21 +739,22 @@ export async function runReviewCycle(config) {
|
|
|
745
739
|
);
|
|
746
740
|
}
|
|
747
741
|
|
|
748
|
-
|
|
742
|
+
logger.debug(`Planned ${changeIdsToReview.length} ${backend.changeName}(s) for this cycle.`);
|
|
749
743
|
|
|
750
744
|
if (changeIdsToReview.length === 0) {
|
|
751
745
|
const lastKnownId = lastReviewedId ? backend.formatChangeId(lastReviewedId) : "(none)";
|
|
752
|
-
|
|
746
|
+
logger.info(`No new ${backend.changeName}s. Last reviewed: ${lastKnownId}`);
|
|
753
747
|
return;
|
|
754
748
|
}
|
|
755
749
|
|
|
756
|
-
|
|
750
|
+
logger.info(`Reviewing ${backend.displayName} ${backend.changeName}s ${formatChangeList(backend, changeIdsToReview)}`);
|
|
757
751
|
const progressDisplay = new ProgressDisplay();
|
|
752
|
+
logger.setProgressDisplay(progressDisplay);
|
|
758
753
|
const progress = progressDisplay.createItem(`${backend.displayName} ${backend.changeName} batch`);
|
|
759
754
|
progress.start("0/" + changeIdsToReview.length + " completed");
|
|
760
755
|
|
|
761
756
|
for (const [index, changeId] of changeIdsToReview.entries()) {
|
|
762
|
-
|
|
757
|
+
logger.debug(`Starting review for ${backend.formatChangeId(changeId)}.`);
|
|
763
758
|
const displayId = backend.formatChangeId(changeId);
|
|
764
759
|
updateOverallProgress(progress, index, changeIdsToReview.length, 0, `starting ${displayId}`);
|
|
765
760
|
|
|
@@ -784,7 +779,7 @@ export async function runReviewCycle(config) {
|
|
|
784
779
|
const nextProjectState = buildStateSnapshot(backend, targetInfo, changeId);
|
|
785
780
|
await saveState(config.stateFilePath, updateProjectState(stateFile, targetInfo, nextProjectState));
|
|
786
781
|
stateFile.projects[targetInfo.stateKey] = nextProjectState;
|
|
787
|
-
|
|
782
|
+
logger.debug(`Saved checkpoint for ${backend.formatChangeId(changeId)} to ${config.stateFilePath}.`);
|
|
788
783
|
progress.log(`[done] reviewed ${displayId}: ${outputLabels.join(" | ") || "(no report file generated)"}`);
|
|
789
784
|
updateOverallProgress(progress, index + 1, changeIdsToReview.length, 0, `finished ${displayId}`);
|
|
790
785
|
}
|
|
@@ -800,6 +795,6 @@ export async function runReviewCycle(config) {
|
|
|
800
795
|
);
|
|
801
796
|
|
|
802
797
|
if (remainingChanges.length > 0) {
|
|
803
|
-
|
|
798
|
+
logger.info(`Backlog remains. Latest ${backend.changeName} is ${backend.formatChangeId(latestChangeId)}.`);
|
|
804
799
|
}
|
|
805
800
|
}
|
package/src/shell.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
2
|
import iconv from "iconv-lite";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
3
4
|
|
|
4
|
-
function debugLog(enabled, message) {
|
|
5
|
-
if (enabled) {
|
|
6
|
-
console.error(`[debug] ${message}`);
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
5
|
|
|
10
6
|
function summarizeOutput(text) {
|
|
11
7
|
if (!text) {
|
|
@@ -28,8 +24,7 @@ export async function runCommand(command, args = [], options = {}) {
|
|
|
28
24
|
debug = false
|
|
29
25
|
} = options;
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
debug,
|
|
27
|
+
logger.debug(
|
|
33
28
|
`run: ${command} ${args.join(" ")}${cwd ? ` | cwd=${cwd}` : ""}${timeoutMs > 0 ? ` | timeoutMs=${timeoutMs}` : ""}`
|
|
34
29
|
);
|
|
35
30
|
|
|
@@ -72,8 +67,7 @@ export async function runCommand(command, args = [], options = {}) {
|
|
|
72
67
|
stderr: trim ? stderr.trim() : stderr
|
|
73
68
|
};
|
|
74
69
|
|
|
75
|
-
|
|
76
|
-
debug,
|
|
70
|
+
logger.debug(
|
|
77
71
|
`exit: ${command} code=${result.code} timedOut=${result.timedOut} stdout=${summarizeOutput(result.stdout)} stderr=${summarizeOutput(result.stderr)}`
|
|
78
72
|
);
|
|
79
73
|
|