kodevu 0.1.50 → 0.1.51

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
@@ -29,13 +29,18 @@ npx kodevu [target] [options]
29
29
  ### Options
30
30
 
31
31
  - `target`: Repository path (Git) or SVN URL/Working copy (default: `.`).
32
- - `--reviewer, -r`: `codex`, `gemini`, `copilot`, or `auto` (default: `auto`).
32
+ - `--reviewer, -r`: `codex`, `gemini`, `copilot`, `openai`, or `auto` (default: `auto`).
33
33
  - `--rev, -v`: A specific revision or commit hash to review.
34
34
  - `--last, -n`: Number of latest revisions to review (default: 1). Use negative values (e.g., `-3`) to review only the 3rd commit from the top.
35
35
  - `--lang, -l`: Output language (e.g., `zh`, `en`, `auto`).
36
36
  - `--prompt, -p`: Additional instructions for the reviewer. Use `@file.txt` to read from a file.
37
37
  - `--output, -o`: Report output directory (default: `~/.kodevu`).
38
38
  - `--format, -f`: Output formats (e.g., `markdown`, `json`, or `markdown,json`).
39
+ - `--openai-api-key`: API key used when `--reviewer openai`.
40
+ - `--openai-base-url`: Base URL used when `--reviewer openai` (default: `https://api.openai.com/v1`).
41
+ - `--openai-model`: Model used when `--reviewer openai` (default: `gpt-5-mini`).
42
+ - `--openai-org`: Optional OpenAI organization ID.
43
+ - `--openai-project`: Optional OpenAI project ID.
39
44
  - `--debug, -d`: Print debug information.
40
45
  - `--version, -V`: Print the current version and exit.
41
46
 
@@ -51,6 +56,11 @@ You can set these in your shell to change default behavior without typing flags
51
56
  - `KODEVU_OUTPUT_DIR`: Default output directory.
52
57
  - `KODEVU_PROMPT`: Default prompt instructions.
53
58
  - `KODEVU_TIMEOUT`: Reviewer execution timeout in milliseconds.
59
+ - `KODEVU_OPENAI_API_KEY`: API key for `openai`.
60
+ - `KODEVU_OPENAI_BASE_URL`: Base URL for `openai`.
61
+ - `KODEVU_OPENAI_MODEL`: Model for `openai`.
62
+ - `KODEVU_OPENAI_ORG`: Optional organization ID for `openai`.
63
+ - `KODEVU_OPENAI_PROJECT`: Optional project ID for `openai`.
54
64
 
55
65
  ## Examples
56
66
 
@@ -91,11 +101,29 @@ export KODEVU_REVIEWER=gemini
91
101
  npx kodevu .
92
102
  ```
93
103
 
104
+ Use the OpenAI API directly with a small set of extra settings:
105
+ ```bash
106
+ export KODEVU_REVIEWER=openai
107
+ export KODEVU_OPENAI_API_KEY=sk-...
108
+ export KODEVU_OPENAI_MODEL=gpt-5-mini
109
+ npx kodevu .
110
+ ```
111
+
112
+ Use a custom OpenAI-compatible endpoint:
113
+ ```bash
114
+ npx kodevu . \
115
+ --reviewer openai \
116
+ --openai-api-key sk-... \
117
+ --openai-base-url https://your-gateway.example.com/v1 \
118
+ --openai-model gpt-5-mini
119
+ ```
120
+
94
121
  ## How it Works
95
122
 
96
123
  - **Git Targets**: `target` must be a local repository or subdirectory.
97
124
  - **SVN Targets**: `target` can be a working copy path or repository URL.
98
125
  - **Reviewer "auto"**: Probes `codex`, `gemini`, and `copilot` in your `PATH` and selects one.
126
+ - **Reviewer "openai"**: Calls the OpenAI Chat Completions API directly. `auto` does not select `openai`, so API-based use stays explicit.
99
127
  - **Contextual Review**: For local repositories, the reviewer can inspect related files beyond the diff to provide deeper insights.
100
128
 
101
129
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodevu",
3
- "version": "0.1.50",
3
+ "version": "0.1.51",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Poll SVN revisions or Git commits, send each change diff to a reviewer CLI, and write configurable review reports.",
package/src/config.js CHANGED
@@ -8,7 +8,8 @@ const require = createRequire(import.meta.url);
8
8
  const { version: packageVersion } = require("../package.json");
9
9
 
10
10
  const defaultStorageDir = path.join(os.homedir(), ".kodevu");
11
- const SUPPORTED_REVIEWERS = ["codex", "gemini", "copilot"];
11
+ const SUPPORTED_REVIEWERS = ["codex", "gemini", "copilot", "openai"];
12
+ const AUTO_SUPPORTED_REVIEWERS = ["codex", "gemini", "copilot"];
12
13
 
13
14
  const defaultConfig = {
14
15
  reviewer: "auto",
@@ -21,7 +22,12 @@ const defaultConfig = {
21
22
  maxRevisionsPerRun: 5,
22
23
  outputFormats: ["markdown"],
23
24
  rev: "",
24
- last: 0
25
+ last: 0,
26
+ openaiApiKey: "",
27
+ openaiBaseUrl: "https://api.openai.com/v1",
28
+ openaiModel: "gpt-5-mini",
29
+ openaiOrganization: "",
30
+ openaiProject: ""
25
31
  };
26
32
 
27
33
  const ENV_MAP = {
@@ -31,7 +37,12 @@ const ENV_MAP = {
31
37
  KODEVU_PROMPT: "prompt",
32
38
  KODEVU_TIMEOUT: "commandTimeoutMs",
33
39
  KODEVU_MAX_REVISIONS: "maxRevisionsPerRun",
34
- KODEVU_FORMATS: "outputFormats"
40
+ KODEVU_FORMATS: "outputFormats",
41
+ KODEVU_OPENAI_API_KEY: "openaiApiKey",
42
+ KODEVU_OPENAI_BASE_URL: "openaiBaseUrl",
43
+ KODEVU_OPENAI_MODEL: "openaiModel",
44
+ KODEVU_OPENAI_ORG: "openaiOrganization",
45
+ KODEVU_OPENAI_PROJECT: "openaiProject"
35
46
  };
36
47
 
37
48
  function resolvePath(value) {
@@ -84,13 +95,13 @@ export function detectLanguage() {
84
95
 
85
96
  async function resolveAutoReviewers(debug) {
86
97
  const availableReviewers = [];
87
- for (const reviewerName of SUPPORTED_REVIEWERS) {
98
+ for (const reviewerName of AUTO_SUPPORTED_REVIEWERS) {
88
99
  const commandPath = await findCommandOnPath(reviewerName, { debug });
89
100
  if (commandPath) availableReviewers.push({ reviewerName, commandPath });
90
101
  }
91
102
 
92
103
  if (availableReviewers.length === 0) {
93
- throw new Error(`No reviewer CLI found in PATH. Install one of: ${SUPPORTED_REVIEWERS.join(", ")}`);
104
+ throw new Error(`No reviewer CLI found in PATH. Install one of: ${AUTO_SUPPORTED_REVIEWERS.join(", ")}`);
94
105
  }
95
106
 
96
107
  // Shuffle for variety
@@ -114,7 +125,12 @@ export function parseCliArgs(argv) {
114
125
  rev: "",
115
126
  last: "",
116
127
  outputDir: "",
117
- outputFormats: ""
128
+ outputFormats: "",
129
+ openaiApiKey: "",
130
+ openaiBaseUrl: "",
131
+ openaiModel: "",
132
+ openaiOrganization: "",
133
+ openaiProject: ""
118
134
  };
119
135
 
120
136
  for (let index = 0; index < argv.length; index += 1) {
@@ -188,6 +204,41 @@ export function parseCliArgs(argv) {
188
204
  continue;
189
205
  }
190
206
 
207
+ if (value === "--openai-api-key") {
208
+ if (!hasNextValue) throw new Error(`Missing value for ${value}`);
209
+ args.openaiApiKey = nextValue;
210
+ index += 1;
211
+ continue;
212
+ }
213
+
214
+ if (value === "--openai-base-url") {
215
+ if (!hasNextValue) throw new Error(`Missing value for ${value}`);
216
+ args.openaiBaseUrl = nextValue;
217
+ index += 1;
218
+ continue;
219
+ }
220
+
221
+ if (value === "--openai-model") {
222
+ if (!hasNextValue) throw new Error(`Missing value for ${value}`);
223
+ args.openaiModel = nextValue;
224
+ index += 1;
225
+ continue;
226
+ }
227
+
228
+ if (value === "--openai-org") {
229
+ if (!hasNextValue) throw new Error(`Missing value for ${value}`);
230
+ args.openaiOrganization = nextValue;
231
+ index += 1;
232
+ continue;
233
+ }
234
+
235
+ if (value === "--openai-project") {
236
+ if (!hasNextValue) throw new Error(`Missing value for ${value}`);
237
+ args.openaiProject = nextValue;
238
+ index += 1;
239
+ continue;
240
+ }
241
+
191
242
  if (!value.startsWith("-") && !args.target) {
192
243
  args.target = value;
193
244
  continue;
@@ -210,7 +261,21 @@ export async function resolveConfig(cliArgs = {}) {
210
261
  }
211
262
 
212
263
  // 2. Merge CLI Arguments
213
- for (const key of ["target", "reviewer", "prompt", "lang", "rev", "last", "outputDir", "outputFormats"]) {
264
+ for (const key of [
265
+ "target",
266
+ "reviewer",
267
+ "prompt",
268
+ "lang",
269
+ "rev",
270
+ "last",
271
+ "outputDir",
272
+ "outputFormats",
273
+ "openaiApiKey",
274
+ "openaiBaseUrl",
275
+ "openaiModel",
276
+ "openaiOrganization",
277
+ "openaiProject"
278
+ ]) {
214
279
  if (cliArgs[key] !== undefined && cliArgs[key] !== "") {
215
280
  config[key] = cliArgs[key];
216
281
  }
@@ -257,11 +322,20 @@ export async function resolveConfig(cliArgs = {}) {
257
322
  config.commandTimeoutMs = Number(config.commandTimeoutMs);
258
323
  config.last = Number(config.last);
259
324
  config.outputFormats = normalizeOutputFormats(config.outputFormats);
325
+ config.openaiApiKey = String(config.openaiApiKey || "").trim();
326
+ config.openaiBaseUrl = String(config.openaiBaseUrl || defaultConfig.openaiBaseUrl).trim().replace(/\/+$/, "");
327
+ config.openaiModel = String(config.openaiModel || defaultConfig.openaiModel).trim();
328
+ config.openaiOrganization = String(config.openaiOrganization || "").trim();
329
+ config.openaiProject = String(config.openaiProject || "").trim();
260
330
 
261
331
  if (!config.rev && (isNaN(config.last) || config.last === 0)) {
262
332
  config.last = 1;
263
333
  }
264
334
 
335
+ if (config.reviewer === "openai" && !config.openaiApiKey) {
336
+ throw new Error('Reviewer "openai" requires an API key. Set KODEVU_OPENAI_API_KEY or pass --openai-api-key.');
337
+ }
338
+
265
339
  return config;
266
340
  }
267
341
 
@@ -273,13 +347,18 @@ Usage:
273
347
 
274
348
  Options:
275
349
  --target, <path> Target repository path (default: current directory)
276
- --reviewer, -r Reviewer (codex | gemini | copilot | auto, default: auto)
350
+ --reviewer, -r Reviewer (codex | gemini | copilot | openai | auto, default: auto)
277
351
  --prompt, -p Additional instructions or @file.txt to read from file
278
352
  --lang, -l Output language (e.g. zh, en, auto)
279
353
  --rev, -v Review specific revision(s), hashes, branches or ranges (comma-separated)
280
354
  --last, -n Review the latest N revisions; use negative (-N) to review only the Nth-from-last revision (default: 1)
281
355
  --output, -o Output directory (default: ~/.kodevu)
282
356
  --format, -f Output formats (markdown, json, comma-separated)
357
+ --openai-api-key API key used when reviewer=openai
358
+ --openai-base-url Base URL used when reviewer=openai (default: https://api.openai.com/v1)
359
+ --openai-model Model used when reviewer=openai (default: gpt-5-mini)
360
+ --openai-org Optional OpenAI organization ID
361
+ --openai-project Optional OpenAI project ID
283
362
  --debug, -d Print extra debug information
284
363
  --help, -h Show help
285
364
  --version, -V Show version
@@ -290,6 +369,11 @@ Environment Variables:
290
369
  KODEVU_OUTPUT_DIR Default output directory
291
370
  KODEVU_PROMPT Default prompt text
292
371
  KODEVU_TIMEOUT Reviewer timeout in ms
372
+ KODEVU_OPENAI_API_KEY API key for reviewer=openai
373
+ KODEVU_OPENAI_BASE_URL Base URL for reviewer=openai
374
+ KODEVU_OPENAI_MODEL Model for reviewer=openai
375
+ KODEVU_OPENAI_ORG Organization ID for reviewer=openai
376
+ KODEVU_OPENAI_PROJECT Project ID for reviewer=openai
293
377
  `);
294
378
  }
295
379
 
package/src/index.js CHANGED
@@ -40,6 +40,10 @@ try {
40
40
  reviewer: config.reviewer,
41
41
  reviewerCommandPath: config.reviewerCommandPath,
42
42
  reviewerWasAutoSelected: config.reviewerWasAutoSelected,
43
+ openaiBaseUrl: config.openaiBaseUrl,
44
+ openaiModel: config.openaiModel,
45
+ openaiOrganization: config.openaiOrganization,
46
+ openaiProject: config.openaiProject,
43
47
  target: config.target,
44
48
  outputDir: config.outputDir,
45
49
  lang: config.lang,
package/src/reviewers.js CHANGED
@@ -6,6 +6,48 @@ import { prepareDiffPayloads } from "./diff-processor.js";
6
6
  import { buildPrompt, getReviewWorkspaceRoot } from "./report-generator.js";
7
7
  import { resolveTokenUsage } from "./token-usage.js";
8
8
 
9
+ function buildOpenAiRequestHeaders(config) {
10
+ const headers = {
11
+ "content-type": "application/json",
12
+ authorization: `Bearer ${config.openaiApiKey}`
13
+ };
14
+
15
+ if (config.openaiOrganization) {
16
+ headers["OpenAI-Organization"] = config.openaiOrganization;
17
+ }
18
+
19
+ if (config.openaiProject) {
20
+ headers["OpenAI-Project"] = config.openaiProject;
21
+ }
22
+
23
+ return headers;
24
+ }
25
+
26
+ function extractOpenAiMessageContent(content) {
27
+ if (typeof content === "string") {
28
+ return content;
29
+ }
30
+
31
+ if (!Array.isArray(content)) {
32
+ return "";
33
+ }
34
+
35
+ return content
36
+ .map((item) => {
37
+ if (typeof item === "string") {
38
+ return item;
39
+ }
40
+
41
+ if (item?.type === "text" && typeof item.text === "string") {
42
+ return item.text;
43
+ }
44
+
45
+ return "";
46
+ })
47
+ .filter(Boolean)
48
+ .join("\n");
49
+ }
50
+
9
51
  export const REVIEWERS = {
10
52
  codex: {
11
53
  displayName: "Codex",
@@ -122,6 +164,78 @@ export const REVIEWERS = {
122
164
  await fs.rm(tempDir, { recursive: true, force: true });
123
165
  }
124
166
  }
167
+ },
168
+ openai: {
169
+ displayName: "OpenAI API",
170
+ responseSectionTitle: "OpenAI Response",
171
+ emptyResponseText: "_No final response returned from the OpenAI API._",
172
+ async run(config, workingDir, promptText, diffText) {
173
+ const requestBody = {
174
+ model: config.openaiModel,
175
+ messages: [
176
+ {
177
+ role: "user",
178
+ content: [promptText, "Unified diff:", diffText].join("\n\n")
179
+ }
180
+ ]
181
+ };
182
+
183
+ try {
184
+ const response = await fetch(`${config.openaiBaseUrl}/chat/completions`, {
185
+ method: "POST",
186
+ headers: buildOpenAiRequestHeaders(config),
187
+ body: JSON.stringify(requestBody),
188
+ signal: AbortSignal.timeout(config.commandTimeoutMs)
189
+ });
190
+
191
+ const responseText = await response.text();
192
+ let payload;
193
+
194
+ try {
195
+ payload = responseText ? JSON.parse(responseText) : {};
196
+ } catch {
197
+ payload = null;
198
+ }
199
+
200
+ if (!response.ok) {
201
+ const errorMessage = payload?.error?.message || responseText || `HTTP ${response.status}`;
202
+ return {
203
+ code: response.status,
204
+ timedOut: false,
205
+ stdout: "",
206
+ stderr: errorMessage,
207
+ message: ""
208
+ };
209
+ }
210
+
211
+ const message = extractOpenAiMessageContent(payload?.choices?.[0]?.message?.content);
212
+ const usage = payload?.usage
213
+ ? {
214
+ inputTokens: Number(payload.usage.prompt_tokens || 0),
215
+ outputTokens: Number(payload.usage.completion_tokens || 0),
216
+ totalTokens: Number(payload.usage.total_tokens || 0)
217
+ }
218
+ : null;
219
+
220
+ return {
221
+ code: 0,
222
+ timedOut: false,
223
+ stdout: responseText,
224
+ stderr: "",
225
+ message,
226
+ usage
227
+ };
228
+ } catch (error) {
229
+ const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError";
230
+ return {
231
+ code: 1,
232
+ timedOut,
233
+ stdout: "",
234
+ stderr: error?.message || String(error),
235
+ message: ""
236
+ };
237
+ }
238
+ }
125
239
  }
126
240
  };
127
241
 
@@ -133,6 +247,7 @@ export async function runReviewerPrompt(config, backend, targetInfo, details, di
133
247
  const result = await reviewer.run(config, reviewWorkspaceRoot, promptText, diffPayloads.review.text);
134
248
  const tokenUsage = resolveTokenUsage(
135
249
  config.reviewer,
250
+ result.usage,
136
251
  result.stderr,
137
252
  promptText,
138
253
  diffPayloads.review.text,
@@ -32,7 +32,29 @@ export function parseTokenUsage(stderr) {
32
32
  return { inputTokens, outputTokens, totalTokens };
33
33
  }
34
34
 
35
- export function resolveTokenUsage(reviewerName, stderr, promptText, diffText, responseText) {
35
+ function normalizeUsageObject(usage) {
36
+ if (!usage || typeof usage !== "object") {
37
+ return null;
38
+ }
39
+
40
+ const inputTokens = Number(usage.inputTokens || 0);
41
+ const outputTokens = Number(usage.outputTokens || 0);
42
+ const totalTokens = Number(usage.totalTokens || (inputTokens + outputTokens));
43
+
44
+ if (totalTokens <= 0 && inputTokens <= 0 && outputTokens <= 0) {
45
+ return null;
46
+ }
47
+
48
+ return { inputTokens, outputTokens, totalTokens };
49
+ }
50
+
51
+ export function resolveTokenUsage(reviewerName, usage, stderr, promptText, diffText, responseText) {
52
+ const normalizedUsage = normalizeUsageObject(usage);
53
+
54
+ if (normalizedUsage) {
55
+ return { ...normalizedUsage, source: "reviewer" };
56
+ }
57
+
36
58
  const parsed = parseTokenUsage(stderr);
37
59
 
38
60
  if (parsed && parsed.totalTokens > 0) {