git-coco 0.30.0 → 0.31.0
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/dist/index.d.ts +12 -0
- package/dist/index.esm.mjs +238 -49
- package/dist/index.js +237 -48
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -177,6 +177,18 @@ type BaseConfig = {
|
|
|
177
177
|
* @default true
|
|
178
178
|
*/
|
|
179
179
|
includeBranchName?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* The AI CLI tool to use for auto-fixing review issues.
|
|
182
|
+
* Must match a registered adapter key (e.g. "codex", "claude", "gemini").
|
|
183
|
+
* When unset, the auto-fix action is disabled.
|
|
184
|
+
*/
|
|
185
|
+
autoFixTool?: string;
|
|
186
|
+
/**
|
|
187
|
+
* Additional key-value flags passed to the auto-fix CLI tool.
|
|
188
|
+
* Keys are flag names (without leading dashes); values are flag values.
|
|
189
|
+
* @example { "model": "o4-mini", "approval-mode": "auto-edit" }
|
|
190
|
+
*/
|
|
191
|
+
autoFixToolOptions?: Record<string, string>;
|
|
180
192
|
};
|
|
181
193
|
type ConfigWithServiceObject = BaseConfig & Partial<BaseCommandOptions> & {
|
|
182
194
|
service: LLMService;
|
package/dist/index.esm.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import '@langchain/core/utils/env';
|
|
|
37
37
|
import '@langchain/core/utils/async_caller';
|
|
38
38
|
import { minimatch } from 'minimatch';
|
|
39
39
|
import { encoding_for_model } from 'tiktoken';
|
|
40
|
-
import { exec } from 'child_process';
|
|
40
|
+
import { exec, spawn } from 'child_process';
|
|
41
41
|
import * as readline from 'readline';
|
|
42
42
|
import { pathToFileURL } from 'url';
|
|
43
43
|
|
|
@@ -46,7 +46,7 @@ import { pathToFileURL } from 'url';
|
|
|
46
46
|
/**
|
|
47
47
|
* Current build version from package.json
|
|
48
48
|
*/
|
|
49
|
-
const BUILD_VERSION = "0.
|
|
49
|
+
const BUILD_VERSION = "0.31.0";
|
|
50
50
|
|
|
51
51
|
const isInteractive = (config) => {
|
|
52
52
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -928,6 +928,23 @@ const schema$1 = {
|
|
|
928
928
|
"type": "boolean",
|
|
929
929
|
"description": "Whether to include the current branch name in the commit prompt for context. When enabled, the current git branch name will be included in the prompt.",
|
|
930
930
|
"default": true
|
|
931
|
+
},
|
|
932
|
+
"autoFixTool": {
|
|
933
|
+
"type": "string",
|
|
934
|
+
"description": "The AI CLI tool to use for auto-fixing review issues. Must match a registered adapter key (e.g. \"codex\", \"claude\", \"gemini\"). When unset, the auto-fix action is disabled."
|
|
935
|
+
},
|
|
936
|
+
"autoFixToolOptions": {
|
|
937
|
+
"type": "object",
|
|
938
|
+
"additionalProperties": {
|
|
939
|
+
"type": "string"
|
|
940
|
+
},
|
|
941
|
+
"description": "Additional key-value flags passed to the auto-fix CLI tool. Keys are flag names (without leading dashes); values are flag values.",
|
|
942
|
+
"examples": [
|
|
943
|
+
{
|
|
944
|
+
"model": "o4-mini",
|
|
945
|
+
"approval-mode": "auto-edit"
|
|
946
|
+
}
|
|
947
|
+
]
|
|
931
948
|
}
|
|
932
949
|
},
|
|
933
950
|
"required": [
|
|
@@ -5353,10 +5370,10 @@ class $ZodRegistry {
|
|
|
5353
5370
|
}
|
|
5354
5371
|
}
|
|
5355
5372
|
// registries
|
|
5356
|
-
function registry() {
|
|
5373
|
+
function registry$1() {
|
|
5357
5374
|
return new $ZodRegistry();
|
|
5358
5375
|
}
|
|
5359
|
-
(_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry());
|
|
5376
|
+
(_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry$1());
|
|
5360
5377
|
const globalRegistry = globalThis.__zod_globalRegistry;
|
|
5361
5378
|
|
|
5362
5379
|
// @__NO_SIDE_EFFECTS__
|
|
@@ -7179,6 +7196,11 @@ function refine(fn, _params = {}) {
|
|
|
7179
7196
|
function superRefine(fn) {
|
|
7180
7197
|
return _superRefine(fn);
|
|
7181
7198
|
}
|
|
7199
|
+
// preprocess
|
|
7200
|
+
// /** @deprecated Use `z.pipe()` and `z.transform()` instead. */
|
|
7201
|
+
function preprocess(fn, schema) {
|
|
7202
|
+
return pipe(transform(fn), schema);
|
|
7203
|
+
}
|
|
7182
7204
|
|
|
7183
7205
|
const ChangelogResponseSchema = object({
|
|
7184
7206
|
title: string(),
|
|
@@ -8210,7 +8232,20 @@ function getStatus(file, location = 'index') {
|
|
|
8210
8232
|
return 'unknown';
|
|
8211
8233
|
}
|
|
8212
8234
|
}
|
|
8235
|
+
else if ('binary' in file && file.binary === true) {
|
|
8236
|
+
// DiffResultBinaryFile: has before/after, no changes/insertions/deletions
|
|
8237
|
+
if (file.file.includes('=>'))
|
|
8238
|
+
return 'renamed';
|
|
8239
|
+
if (file.before === 0 && file.after > 0)
|
|
8240
|
+
return 'added';
|
|
8241
|
+
if (file.after === 0 && file.before > 0)
|
|
8242
|
+
return 'deleted';
|
|
8243
|
+
if (file.before > 0 && file.after > 0)
|
|
8244
|
+
return 'modified';
|
|
8245
|
+
return 'untracked';
|
|
8246
|
+
}
|
|
8213
8247
|
else if ('changes' in file && 'binary' in file) {
|
|
8248
|
+
// DiffResultTextFile: has changes/insertions/deletions
|
|
8214
8249
|
if (file.changes === 0)
|
|
8215
8250
|
return 'untracked';
|
|
8216
8251
|
if (file.file.includes('=>'))
|
|
@@ -12125,15 +12160,47 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
|
|
|
12125
12160
|
return summary;
|
|
12126
12161
|
}
|
|
12127
12162
|
|
|
12163
|
+
/**
|
|
12164
|
+
* Detects whether a GitError was caused by a pre-commit hook that modified files.
|
|
12165
|
+
* These hooks (e.g. black, prettier) reformat files and exit non-zero on the first run,
|
|
12166
|
+
* but the commit can succeed once the modified files are re-staged.
|
|
12167
|
+
*/
|
|
12168
|
+
function isPreCommitHookModifiedFilesError(error) {
|
|
12169
|
+
if (!(error instanceof Error))
|
|
12170
|
+
return false;
|
|
12171
|
+
const msg = error.message;
|
|
12172
|
+
// pre-commit framework outputs "files were modified by this hook"
|
|
12173
|
+
// git itself outputs "modified files exist in working tree" in some hook setups
|
|
12174
|
+
return (msg.includes('files were modified by this hook') ||
|
|
12175
|
+
msg.includes('modified by this hook') ||
|
|
12176
|
+
msg.includes('hook id:'));
|
|
12177
|
+
}
|
|
12128
12178
|
/**
|
|
12129
12179
|
* Creates a commit with the specified commit message.
|
|
12180
|
+
* Handles the case where pre-commit hooks modify files (e.g. black, prettier):
|
|
12181
|
+
* when detected, stages the hook-modified files and retries the commit once.
|
|
12130
12182
|
*
|
|
12131
12183
|
* @param message The commit message.
|
|
12132
12184
|
* @param git The SimpleGit instance.
|
|
12185
|
+
* @param onHookModifiedFiles Optional callback invoked before the auto-retry so callers can notify the user.
|
|
12133
12186
|
* @returns A Promise that resolves to the CommitResult.
|
|
12134
12187
|
*/
|
|
12135
|
-
async function createCommit(message, git) {
|
|
12136
|
-
|
|
12188
|
+
async function createCommit(message, git, onHookModifiedFiles) {
|
|
12189
|
+
try {
|
|
12190
|
+
return await git.commit(message);
|
|
12191
|
+
}
|
|
12192
|
+
catch (error) {
|
|
12193
|
+
if (isPreCommitHookModifiedFilesError(error)) {
|
|
12194
|
+
// Notify caller (e.g. to show a spinner message or log)
|
|
12195
|
+
if (onHookModifiedFiles) {
|
|
12196
|
+
await onHookModifiedFiles();
|
|
12197
|
+
}
|
|
12198
|
+
// Stage all hook-modified files and retry the commit once
|
|
12199
|
+
await git.add('.');
|
|
12200
|
+
return await git.commit(message);
|
|
12201
|
+
}
|
|
12202
|
+
throw error;
|
|
12203
|
+
}
|
|
12137
12204
|
}
|
|
12138
12205
|
|
|
12139
12206
|
/**
|
|
@@ -12712,7 +12779,9 @@ IMPORTANT RULES:
|
|
|
12712
12779
|
handleResult({
|
|
12713
12780
|
result: commitMsg,
|
|
12714
12781
|
interactiveModeCallback: async (result) => {
|
|
12715
|
-
await createCommit(result, git)
|
|
12782
|
+
await createCommit(result, git, () => {
|
|
12783
|
+
logger.log('⚠️ Pre-commit hook modified files. Staging changes and retrying commit...', { color: 'yellow' });
|
|
12784
|
+
});
|
|
12716
12785
|
logSuccess();
|
|
12717
12786
|
},
|
|
12718
12787
|
mode: MODE,
|
|
@@ -13563,15 +13632,103 @@ const builder = (yargs) => {
|
|
|
13563
13632
|
return yargs.options(options).usage(getCommandUsageHeader(command));
|
|
13564
13633
|
};
|
|
13565
13634
|
|
|
13635
|
+
async function buildPrompt(item) {
|
|
13636
|
+
const ext = path.extname(item.filePath).slice(1);
|
|
13637
|
+
let fileSection;
|
|
13638
|
+
try {
|
|
13639
|
+
const contents = await fs.promises.readFile(item.filePath, 'utf-8');
|
|
13640
|
+
fileSection = `\`\`\`${ext}\n${contents}\n\`\`\``;
|
|
13641
|
+
}
|
|
13642
|
+
catch {
|
|
13643
|
+
fileSection = `[WARNING: File "${item.filePath}" was not found on disk. Fix based on the issue description alone.]`;
|
|
13644
|
+
}
|
|
13645
|
+
return `You are an expert software engineer. Fix the following code review issue.
|
|
13646
|
+
|
|
13647
|
+
## Issue
|
|
13648
|
+
Title: ${item.title}
|
|
13649
|
+
Category: ${item.category}
|
|
13650
|
+
Severity: ${item.severity}/10
|
|
13651
|
+
File: ${item.filePath}
|
|
13652
|
+
|
|
13653
|
+
## Problem
|
|
13654
|
+
${item.summary}
|
|
13655
|
+
|
|
13656
|
+
## File Contents
|
|
13657
|
+
${fileSection}
|
|
13658
|
+
|
|
13659
|
+
Fix the issue described above. Make only the changes necessary to resolve this specific problem.`;
|
|
13660
|
+
}
|
|
13661
|
+
|
|
13662
|
+
class CodexAdapter {
|
|
13663
|
+
async run(prompt, options, apiKey) {
|
|
13664
|
+
const args = ['exec'];
|
|
13665
|
+
if (options) {
|
|
13666
|
+
for (const [key, value] of Object.entries(options)) {
|
|
13667
|
+
if (key === 'model' || key === 'm') {
|
|
13668
|
+
args.push('--model', value);
|
|
13669
|
+
}
|
|
13670
|
+
else if (key === 'sandbox' || key === 's') {
|
|
13671
|
+
args.push('--sandbox', value);
|
|
13672
|
+
}
|
|
13673
|
+
else {
|
|
13674
|
+
args.push('-c', `${key}=${value}`);
|
|
13675
|
+
}
|
|
13676
|
+
}
|
|
13677
|
+
}
|
|
13678
|
+
args.push('--full-auto', prompt);
|
|
13679
|
+
// Preserve the caller's environment by default and only override the API key
|
|
13680
|
+
// when an explicit non-empty key is provided through auto-fix config.
|
|
13681
|
+
const env = { ...process.env };
|
|
13682
|
+
if (apiKey)
|
|
13683
|
+
env['OPENAI_API_KEY'] = apiKey;
|
|
13684
|
+
return new Promise((resolve, reject) => {
|
|
13685
|
+
const child = spawn('codex', args, { stdio: 'inherit', env });
|
|
13686
|
+
child.on('error', (err) => {
|
|
13687
|
+
if (err.code === 'ENOENT') {
|
|
13688
|
+
reject(new Error('codex binary not found. Please install it: npm i -g @openai/codex'));
|
|
13689
|
+
}
|
|
13690
|
+
else {
|
|
13691
|
+
reject(err);
|
|
13692
|
+
}
|
|
13693
|
+
});
|
|
13694
|
+
child.on('close', (code) => {
|
|
13695
|
+
if (code === 0) {
|
|
13696
|
+
resolve();
|
|
13697
|
+
}
|
|
13698
|
+
else {
|
|
13699
|
+
reject(new Error(`codex exited with code ${code}`));
|
|
13700
|
+
}
|
|
13701
|
+
});
|
|
13702
|
+
});
|
|
13703
|
+
}
|
|
13704
|
+
}
|
|
13705
|
+
|
|
13706
|
+
const registry = {
|
|
13707
|
+
codex: new CodexAdapter(),
|
|
13708
|
+
};
|
|
13709
|
+
async function runAutoFix(item, config) {
|
|
13710
|
+
if (!config.autoFixTool) {
|
|
13711
|
+
return;
|
|
13712
|
+
}
|
|
13713
|
+
const adapter = registry[config.autoFixTool];
|
|
13714
|
+
if (!adapter) {
|
|
13715
|
+
throw new Error(`Unknown autoFixTool: "${config.autoFixTool}"`);
|
|
13716
|
+
}
|
|
13717
|
+
const prompt = await buildPrompt(item);
|
|
13718
|
+
await adapter.run(prompt, config.autoFixToolOptions, config.apiKey);
|
|
13719
|
+
}
|
|
13720
|
+
|
|
13566
13721
|
class TaskList {
|
|
13567
|
-
constructor(items) {
|
|
13722
|
+
constructor(items, config) {
|
|
13568
13723
|
this.currentIndex = 0;
|
|
13569
13724
|
this.items = items.map((item) => ({ ...item, status: 'pending' }));
|
|
13725
|
+
this.config = config;
|
|
13570
13726
|
this.rl = readline.createInterface({
|
|
13571
13727
|
input: process.stdin,
|
|
13572
13728
|
output: process.stdout,
|
|
13573
13729
|
});
|
|
13574
13730
|
process.stdin.setRawMode(true);
|
|
13731
|
+
readline.emitKeypressEvents(process.stdin);
|
|
13575
13732
|
}
|
|
13576
13733
|
async displayCurrentItem() {
|
|
13577
13734
|
const item = this.items[this.currentIndex];
|
|
@@ -13588,18 +13745,12 @@ class TaskList {
|
|
|
13588
13745
|
return [
|
|
13589
13746
|
{ name: `✅ Mark as complete ${hotKey('d')}`, value: 'complete' },
|
|
13590
13747
|
{ name: `📂 Open file ${hotKey('o')}`, value: 'open' },
|
|
13748
|
+
{ name: `🤖 Auto-fix ${hotKey('a')}`, value: 'autofix' },
|
|
13591
13749
|
{ name: `⏩ Skip ${hotKey('s')}`, value: 'skip' },
|
|
13592
13750
|
{ name: `🙈 Omit ${hotKey('x')}`, value: 'omit' },
|
|
13593
13751
|
{ name: `${exitText} ${hotKey('q')}`, value: 'exit' },
|
|
13594
13752
|
];
|
|
13595
13753
|
}
|
|
13596
|
-
async promptAction() {
|
|
13597
|
-
const action = await select({
|
|
13598
|
-
message: 'Choose an action:',
|
|
13599
|
-
choices: this.getChoices(),
|
|
13600
|
-
});
|
|
13601
|
-
return action;
|
|
13602
|
-
}
|
|
13603
13754
|
async openFile() {
|
|
13604
13755
|
const item = this.items[this.currentIndex];
|
|
13605
13756
|
await execPromise(`${process.env.EDITOR || 'code'} ${item.filePath}`);
|
|
@@ -13608,6 +13759,35 @@ class TaskList {
|
|
|
13608
13759
|
this.items[this.currentIndex].status = 'completed';
|
|
13609
13760
|
this.navigate(1);
|
|
13610
13761
|
}
|
|
13762
|
+
async autoFix() {
|
|
13763
|
+
if (!this.config?.autoFixTool) {
|
|
13764
|
+
console.log(chalk.yellow('No autoFixTool configured. Set "autoFixTool" in .coco.config.json'));
|
|
13765
|
+
return;
|
|
13766
|
+
}
|
|
13767
|
+
const item = this.items[this.currentIndex];
|
|
13768
|
+
console.clear();
|
|
13769
|
+
console.log(chalk.bold.cyan(`🤖 Running auto-fix: ${item.title}`));
|
|
13770
|
+
console.log(chalk.dim(`File: ${item.filePath}\n`));
|
|
13771
|
+
// Fully release terminal control before handing off to child process
|
|
13772
|
+
process.stdin.setRawMode(false);
|
|
13773
|
+
process.stdin.pause();
|
|
13774
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
13775
|
+
try {
|
|
13776
|
+
await runAutoFix(item, this.config);
|
|
13777
|
+
this.markAsComplete();
|
|
13778
|
+
console.log(chalk.green('\n✅ Auto-fix completed successfully.'));
|
|
13779
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
13780
|
+
}
|
|
13781
|
+
catch (err) {
|
|
13782
|
+
console.log(chalk.red(`\n❌ Auto-fix failed: ${err.message}`));
|
|
13783
|
+
await new Promise((r) => setTimeout(r, 1800));
|
|
13784
|
+
}
|
|
13785
|
+
finally {
|
|
13786
|
+
// Restore terminal for our keypress handler
|
|
13787
|
+
process.stdin.resume();
|
|
13788
|
+
process.stdin.setRawMode(true);
|
|
13789
|
+
}
|
|
13790
|
+
}
|
|
13611
13791
|
skip() {
|
|
13612
13792
|
this.items[this.currentIndex].status = 'skipped';
|
|
13613
13793
|
this.navigate(1);
|
|
@@ -13617,7 +13797,14 @@ class TaskList {
|
|
|
13617
13797
|
this.navigate(1);
|
|
13618
13798
|
}
|
|
13619
13799
|
navigate(direction) {
|
|
13620
|
-
|
|
13800
|
+
const allDone = this.items.every((item) => item.status !== 'pending');
|
|
13801
|
+
if (allDone)
|
|
13802
|
+
return;
|
|
13803
|
+
let next = (this.currentIndex + direction + this.items.length) % this.items.length;
|
|
13804
|
+
while (this.items[next].status !== 'pending') {
|
|
13805
|
+
next = (next + direction + this.items.length) % this.items.length;
|
|
13806
|
+
}
|
|
13807
|
+
this.currentIndex = next;
|
|
13621
13808
|
}
|
|
13622
13809
|
async start() {
|
|
13623
13810
|
while (true) {
|
|
@@ -13630,6 +13817,9 @@ class TaskList {
|
|
|
13630
13817
|
case 'complete':
|
|
13631
13818
|
this.markAsComplete();
|
|
13632
13819
|
break;
|
|
13820
|
+
case 'autofix':
|
|
13821
|
+
await this.autoFix();
|
|
13822
|
+
break;
|
|
13633
13823
|
case 'skip':
|
|
13634
13824
|
this.skip();
|
|
13635
13825
|
break;
|
|
@@ -13647,43 +13837,39 @@ class TaskList {
|
|
|
13647
13837
|
await this.displaySummary();
|
|
13648
13838
|
return;
|
|
13649
13839
|
}
|
|
13840
|
+
if (this.items.every((item) => item.status !== 'pending')) {
|
|
13841
|
+
this.rl.close();
|
|
13842
|
+
await this.displaySummary();
|
|
13843
|
+
return;
|
|
13844
|
+
}
|
|
13650
13845
|
}
|
|
13651
13846
|
}
|
|
13847
|
+
renderMenu() {
|
|
13848
|
+
const choices = this.getChoices();
|
|
13849
|
+
console.log(chalk.dim('Choose an action:'));
|
|
13850
|
+
choices.forEach((c) => console.log(` ${c.name}`));
|
|
13851
|
+
}
|
|
13652
13852
|
getActionWithKeyboardShortcut() {
|
|
13853
|
+
this.renderMenu();
|
|
13653
13854
|
return new Promise((resolve) => {
|
|
13654
|
-
const
|
|
13655
|
-
|
|
13656
|
-
|
|
13657
|
-
|
|
13658
|
-
|
|
13659
|
-
|
|
13660
|
-
|
|
13661
|
-
|
|
13662
|
-
|
|
13663
|
-
case 's':
|
|
13664
|
-
resolve('skip');
|
|
13665
|
-
break;
|
|
13666
|
-
case 'x':
|
|
13667
|
-
resolve('omit');
|
|
13668
|
-
break;
|
|
13669
|
-
case 'right':
|
|
13670
|
-
resolve('next');
|
|
13671
|
-
break;
|
|
13672
|
-
case 'left':
|
|
13673
|
-
resolve('prev');
|
|
13674
|
-
break;
|
|
13675
|
-
case 'q':
|
|
13676
|
-
resolve('exit');
|
|
13677
|
-
break;
|
|
13678
|
-
}
|
|
13679
|
-
}
|
|
13855
|
+
const actionMap = {
|
|
13856
|
+
o: 'open',
|
|
13857
|
+
a: 'autofix',
|
|
13858
|
+
d: 'complete',
|
|
13859
|
+
s: 'skip',
|
|
13860
|
+
x: 'omit',
|
|
13861
|
+
right: 'next',
|
|
13862
|
+
left: 'prev',
|
|
13863
|
+
q: 'exit',
|
|
13680
13864
|
};
|
|
13681
|
-
|
|
13682
|
-
|
|
13683
|
-
|
|
13684
|
-
|
|
13865
|
+
const handleKeypress = (_, key) => {
|
|
13866
|
+
const action = key ? actionMap[key.name] : undefined;
|
|
13867
|
+
if (!action)
|
|
13868
|
+
return;
|
|
13869
|
+
process.stdin.removeListener('keypress', handleKeypress);
|
|
13685
13870
|
resolve(action);
|
|
13686
|
-
}
|
|
13871
|
+
};
|
|
13872
|
+
process.stdin.on('keypress', handleKeypress);
|
|
13687
13873
|
});
|
|
13688
13874
|
}
|
|
13689
13875
|
async displaySummary() {
|
|
@@ -13744,6 +13930,9 @@ const REVIEW_PROMPT = new PromptTemplate({
|
|
|
13744
13930
|
inputVariables,
|
|
13745
13931
|
});
|
|
13746
13932
|
|
|
13933
|
+
// Some review prompts still produce a single feedback object. Normalize that shape
|
|
13934
|
+
// so the parser always returns an array for the rest of the review flow.
|
|
13935
|
+
const ReviewFeedbackResponseSchema = preprocess((value) => (Array.isArray(value) ? value : [value]), ReviewFeedbackItemArraySchema);
|
|
13747
13936
|
const handler = async (argv, logger) => {
|
|
13748
13937
|
const git = getRepo();
|
|
13749
13938
|
const config = loadConfig(argv);
|
|
@@ -13844,7 +14033,7 @@ const handler = async (argv, logger) => {
|
|
|
13844
14033
|
factory,
|
|
13845
14034
|
parser,
|
|
13846
14035
|
agent: async (context, options) => {
|
|
13847
|
-
const parser = createSchemaParser(
|
|
14036
|
+
const parser = createSchemaParser(ReviewFeedbackResponseSchema, llm);
|
|
13848
14037
|
const formatInstructions = "Respond with a valid JSON object, containing four fields:'title' a string, 'summary' a short summary of the problem (include line number if big file), 'severity' a numeric enum up to ten, 'category' an enum string, and 'filePath' a relative filepath to file as string.";
|
|
13849
14038
|
const prompt = getPrompt({
|
|
13850
14039
|
template: options.prompt,
|
|
@@ -13876,7 +14065,7 @@ const handler = async (argv, logger) => {
|
|
|
13876
14065
|
process.exit(0);
|
|
13877
14066
|
},
|
|
13878
14067
|
});
|
|
13879
|
-
const reviewer = new TaskList(recap);
|
|
14068
|
+
const reviewer = new TaskList(recap, { ...config, apiKey: key ?? undefined });
|
|
13880
14069
|
await reviewer.start();
|
|
13881
14070
|
};
|
|
13882
14071
|
|
package/dist/index.js
CHANGED
|
@@ -68,7 +68,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
68
68
|
/**
|
|
69
69
|
* Current build version from package.json
|
|
70
70
|
*/
|
|
71
|
-
const BUILD_VERSION = "0.
|
|
71
|
+
const BUILD_VERSION = "0.31.0";
|
|
72
72
|
|
|
73
73
|
const isInteractive = (config) => {
|
|
74
74
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -950,6 +950,23 @@ const schema$1 = {
|
|
|
950
950
|
"type": "boolean",
|
|
951
951
|
"description": "Whether to include the current branch name in the commit prompt for context. When enabled, the current git branch name will be included in the prompt.",
|
|
952
952
|
"default": true
|
|
953
|
+
},
|
|
954
|
+
"autoFixTool": {
|
|
955
|
+
"type": "string",
|
|
956
|
+
"description": "The AI CLI tool to use for auto-fixing review issues. Must match a registered adapter key (e.g. \"codex\", \"claude\", \"gemini\"). When unset, the auto-fix action is disabled."
|
|
957
|
+
},
|
|
958
|
+
"autoFixToolOptions": {
|
|
959
|
+
"type": "object",
|
|
960
|
+
"additionalProperties": {
|
|
961
|
+
"type": "string"
|
|
962
|
+
},
|
|
963
|
+
"description": "Additional key-value flags passed to the auto-fix CLI tool. Keys are flag names (without leading dashes); values are flag values.",
|
|
964
|
+
"examples": [
|
|
965
|
+
{
|
|
966
|
+
"model": "o4-mini",
|
|
967
|
+
"approval-mode": "auto-edit"
|
|
968
|
+
}
|
|
969
|
+
]
|
|
953
970
|
}
|
|
954
971
|
},
|
|
955
972
|
"required": [
|
|
@@ -5375,10 +5392,10 @@ class $ZodRegistry {
|
|
|
5375
5392
|
}
|
|
5376
5393
|
}
|
|
5377
5394
|
// registries
|
|
5378
|
-
function registry() {
|
|
5395
|
+
function registry$1() {
|
|
5379
5396
|
return new $ZodRegistry();
|
|
5380
5397
|
}
|
|
5381
|
-
(_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry());
|
|
5398
|
+
(_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry$1());
|
|
5382
5399
|
const globalRegistry = globalThis.__zod_globalRegistry;
|
|
5383
5400
|
|
|
5384
5401
|
// @__NO_SIDE_EFFECTS__
|
|
@@ -7201,6 +7218,11 @@ function refine(fn, _params = {}) {
|
|
|
7201
7218
|
function superRefine(fn) {
|
|
7202
7219
|
return _superRefine(fn);
|
|
7203
7220
|
}
|
|
7221
|
+
// preprocess
|
|
7222
|
+
// /** @deprecated Use `z.pipe()` and `z.transform()` instead. */
|
|
7223
|
+
function preprocess(fn, schema) {
|
|
7224
|
+
return pipe(transform(fn), schema);
|
|
7225
|
+
}
|
|
7204
7226
|
|
|
7205
7227
|
const ChangelogResponseSchema = object({
|
|
7206
7228
|
title: string(),
|
|
@@ -8232,7 +8254,20 @@ function getStatus(file, location = 'index') {
|
|
|
8232
8254
|
return 'unknown';
|
|
8233
8255
|
}
|
|
8234
8256
|
}
|
|
8257
|
+
else if ('binary' in file && file.binary === true) {
|
|
8258
|
+
// DiffResultBinaryFile: has before/after, no changes/insertions/deletions
|
|
8259
|
+
if (file.file.includes('=>'))
|
|
8260
|
+
return 'renamed';
|
|
8261
|
+
if (file.before === 0 && file.after > 0)
|
|
8262
|
+
return 'added';
|
|
8263
|
+
if (file.after === 0 && file.before > 0)
|
|
8264
|
+
return 'deleted';
|
|
8265
|
+
if (file.before > 0 && file.after > 0)
|
|
8266
|
+
return 'modified';
|
|
8267
|
+
return 'untracked';
|
|
8268
|
+
}
|
|
8235
8269
|
else if ('changes' in file && 'binary' in file) {
|
|
8270
|
+
// DiffResultTextFile: has changes/insertions/deletions
|
|
8236
8271
|
if (file.changes === 0)
|
|
8237
8272
|
return 'untracked';
|
|
8238
8273
|
if (file.file.includes('=>'))
|
|
@@ -12147,15 +12182,47 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
|
|
|
12147
12182
|
return summary;
|
|
12148
12183
|
}
|
|
12149
12184
|
|
|
12185
|
+
/**
|
|
12186
|
+
* Detects whether a GitError was caused by a pre-commit hook that modified files.
|
|
12187
|
+
* These hooks (e.g. black, prettier) reformat files and exit non-zero on the first run,
|
|
12188
|
+
* but the commit can succeed once the modified files are re-staged.
|
|
12189
|
+
*/
|
|
12190
|
+
function isPreCommitHookModifiedFilesError(error) {
|
|
12191
|
+
if (!(error instanceof Error))
|
|
12192
|
+
return false;
|
|
12193
|
+
const msg = error.message;
|
|
12194
|
+
// pre-commit framework outputs "files were modified by this hook"
|
|
12195
|
+
// git itself outputs "modified files exist in working tree" in some hook setups
|
|
12196
|
+
return (msg.includes('files were modified by this hook') ||
|
|
12197
|
+
msg.includes('modified by this hook') ||
|
|
12198
|
+
msg.includes('hook id:'));
|
|
12199
|
+
}
|
|
12150
12200
|
/**
|
|
12151
12201
|
* Creates a commit with the specified commit message.
|
|
12202
|
+
* Handles the case where pre-commit hooks modify files (e.g. black, prettier):
|
|
12203
|
+
* when detected, stages the hook-modified files and retries the commit once.
|
|
12152
12204
|
*
|
|
12153
12205
|
* @param message The commit message.
|
|
12154
12206
|
* @param git The SimpleGit instance.
|
|
12207
|
+
* @param onHookModifiedFiles Optional callback invoked before the auto-retry so callers can notify the user.
|
|
12155
12208
|
* @returns A Promise that resolves to the CommitResult.
|
|
12156
12209
|
*/
|
|
12157
|
-
async function createCommit(message, git) {
|
|
12158
|
-
|
|
12210
|
+
async function createCommit(message, git, onHookModifiedFiles) {
|
|
12211
|
+
try {
|
|
12212
|
+
return await git.commit(message);
|
|
12213
|
+
}
|
|
12214
|
+
catch (error) {
|
|
12215
|
+
if (isPreCommitHookModifiedFilesError(error)) {
|
|
12216
|
+
// Notify caller (e.g. to show a spinner message or log)
|
|
12217
|
+
if (onHookModifiedFiles) {
|
|
12218
|
+
await onHookModifiedFiles();
|
|
12219
|
+
}
|
|
12220
|
+
// Stage all hook-modified files and retry the commit once
|
|
12221
|
+
await git.add('.');
|
|
12222
|
+
return await git.commit(message);
|
|
12223
|
+
}
|
|
12224
|
+
throw error;
|
|
12225
|
+
}
|
|
12159
12226
|
}
|
|
12160
12227
|
|
|
12161
12228
|
/**
|
|
@@ -12734,7 +12801,9 @@ IMPORTANT RULES:
|
|
|
12734
12801
|
handleResult({
|
|
12735
12802
|
result: commitMsg,
|
|
12736
12803
|
interactiveModeCallback: async (result) => {
|
|
12737
|
-
await createCommit(result, git)
|
|
12804
|
+
await createCommit(result, git, () => {
|
|
12805
|
+
logger.log('⚠️ Pre-commit hook modified files. Staging changes and retrying commit...', { color: 'yellow' });
|
|
12806
|
+
});
|
|
12738
12807
|
logSuccess();
|
|
12739
12808
|
},
|
|
12740
12809
|
mode: MODE,
|
|
@@ -13585,15 +13654,103 @@ const builder = (yargs) => {
|
|
|
13585
13654
|
return yargs.options(options).usage(getCommandUsageHeader(command));
|
|
13586
13655
|
};
|
|
13587
13656
|
|
|
13657
|
+
async function buildPrompt(item) {
|
|
13658
|
+
const ext = path__namespace.extname(item.filePath).slice(1);
|
|
13659
|
+
let fileSection;
|
|
13660
|
+
try {
|
|
13661
|
+
const contents = await fs__namespace.promises.readFile(item.filePath, 'utf-8');
|
|
13662
|
+
fileSection = `\`\`\`${ext}\n${contents}\n\`\`\``;
|
|
13663
|
+
}
|
|
13664
|
+
catch {
|
|
13665
|
+
fileSection = `[WARNING: File "${item.filePath}" was not found on disk. Fix based on the issue description alone.]`;
|
|
13666
|
+
}
|
|
13667
|
+
return `You are an expert software engineer. Fix the following code review issue.
|
|
13668
|
+
|
|
13669
|
+
## Issue
|
|
13670
|
+
Title: ${item.title}
|
|
13671
|
+
Category: ${item.category}
|
|
13672
|
+
Severity: ${item.severity}/10
|
|
13673
|
+
File: ${item.filePath}
|
|
13674
|
+
|
|
13675
|
+
## Problem
|
|
13676
|
+
${item.summary}
|
|
13677
|
+
|
|
13678
|
+
## File Contents
|
|
13679
|
+
${fileSection}
|
|
13680
|
+
|
|
13681
|
+
Fix the issue described above. Make only the changes necessary to resolve this specific problem.`;
|
|
13682
|
+
}
|
|
13683
|
+
|
|
13684
|
+
class CodexAdapter {
|
|
13685
|
+
async run(prompt, options, apiKey) {
|
|
13686
|
+
const args = ['exec'];
|
|
13687
|
+
if (options) {
|
|
13688
|
+
for (const [key, value] of Object.entries(options)) {
|
|
13689
|
+
if (key === 'model' || key === 'm') {
|
|
13690
|
+
args.push('--model', value);
|
|
13691
|
+
}
|
|
13692
|
+
else if (key === 'sandbox' || key === 's') {
|
|
13693
|
+
args.push('--sandbox', value);
|
|
13694
|
+
}
|
|
13695
|
+
else {
|
|
13696
|
+
args.push('-c', `${key}=${value}`);
|
|
13697
|
+
}
|
|
13698
|
+
}
|
|
13699
|
+
}
|
|
13700
|
+
args.push('--full-auto', prompt);
|
|
13701
|
+
// Preserve the caller's environment by default and only override the API key
|
|
13702
|
+
// when an explicit non-empty key is provided through auto-fix config.
|
|
13703
|
+
const env = { ...process.env };
|
|
13704
|
+
if (apiKey)
|
|
13705
|
+
env['OPENAI_API_KEY'] = apiKey;
|
|
13706
|
+
return new Promise((resolve, reject) => {
|
|
13707
|
+
const child = child_process.spawn('codex', args, { stdio: 'inherit', env });
|
|
13708
|
+
child.on('error', (err) => {
|
|
13709
|
+
if (err.code === 'ENOENT') {
|
|
13710
|
+
reject(new Error('codex binary not found. Please install it: npm i -g @openai/codex'));
|
|
13711
|
+
}
|
|
13712
|
+
else {
|
|
13713
|
+
reject(err);
|
|
13714
|
+
}
|
|
13715
|
+
});
|
|
13716
|
+
child.on('close', (code) => {
|
|
13717
|
+
if (code === 0) {
|
|
13718
|
+
resolve();
|
|
13719
|
+
}
|
|
13720
|
+
else {
|
|
13721
|
+
reject(new Error(`codex exited with code ${code}`));
|
|
13722
|
+
}
|
|
13723
|
+
});
|
|
13724
|
+
});
|
|
13725
|
+
}
|
|
13726
|
+
}
|
|
13727
|
+
|
|
13728
|
+
const registry = {
|
|
13729
|
+
codex: new CodexAdapter(),
|
|
13730
|
+
};
|
|
13731
|
+
async function runAutoFix(item, config) {
|
|
13732
|
+
if (!config.autoFixTool) {
|
|
13733
|
+
return;
|
|
13734
|
+
}
|
|
13735
|
+
const adapter = registry[config.autoFixTool];
|
|
13736
|
+
if (!adapter) {
|
|
13737
|
+
throw new Error(`Unknown autoFixTool: "${config.autoFixTool}"`);
|
|
13738
|
+
}
|
|
13739
|
+
const prompt = await buildPrompt(item);
|
|
13740
|
+
await adapter.run(prompt, config.autoFixToolOptions, config.apiKey);
|
|
13741
|
+
}
|
|
13742
|
+
|
|
13588
13743
|
class TaskList {
|
|
13589
|
-
constructor(items) {
|
|
13744
|
+
constructor(items, config) {
|
|
13590
13745
|
this.currentIndex = 0;
|
|
13591
13746
|
this.items = items.map((item) => ({ ...item, status: 'pending' }));
|
|
13747
|
+
this.config = config;
|
|
13592
13748
|
this.rl = readline__namespace.createInterface({
|
|
13593
13749
|
input: process.stdin,
|
|
13594
13750
|
output: process.stdout,
|
|
13595
13751
|
});
|
|
13596
13752
|
process.stdin.setRawMode(true);
|
|
13753
|
+
readline__namespace.emitKeypressEvents(process.stdin);
|
|
13597
13754
|
}
|
|
13598
13755
|
async displayCurrentItem() {
|
|
13599
13756
|
const item = this.items[this.currentIndex];
|
|
@@ -13610,18 +13767,12 @@ class TaskList {
|
|
|
13610
13767
|
return [
|
|
13611
13768
|
{ name: `✅ Mark as complete ${hotKey('d')}`, value: 'complete' },
|
|
13612
13769
|
{ name: `📂 Open file ${hotKey('o')}`, value: 'open' },
|
|
13770
|
+
{ name: `🤖 Auto-fix ${hotKey('a')}`, value: 'autofix' },
|
|
13613
13771
|
{ name: `⏩ Skip ${hotKey('s')}`, value: 'skip' },
|
|
13614
13772
|
{ name: `🙈 Omit ${hotKey('x')}`, value: 'omit' },
|
|
13615
13773
|
{ name: `${exitText} ${hotKey('q')}`, value: 'exit' },
|
|
13616
13774
|
];
|
|
13617
13775
|
}
|
|
13618
|
-
async promptAction() {
|
|
13619
|
-
const action = await prompts.select({
|
|
13620
|
-
message: 'Choose an action:',
|
|
13621
|
-
choices: this.getChoices(),
|
|
13622
|
-
});
|
|
13623
|
-
return action;
|
|
13624
|
-
}
|
|
13625
13776
|
async openFile() {
|
|
13626
13777
|
const item = this.items[this.currentIndex];
|
|
13627
13778
|
await execPromise(`${process.env.EDITOR || 'code'} ${item.filePath}`);
|
|
@@ -13630,6 +13781,35 @@ class TaskList {
|
|
|
13630
13781
|
this.items[this.currentIndex].status = 'completed';
|
|
13631
13782
|
this.navigate(1);
|
|
13632
13783
|
}
|
|
13784
|
+
async autoFix() {
|
|
13785
|
+
if (!this.config?.autoFixTool) {
|
|
13786
|
+
console.log(chalk.yellow('No autoFixTool configured. Set "autoFixTool" in .coco.config.json'));
|
|
13787
|
+
return;
|
|
13788
|
+
}
|
|
13789
|
+
const item = this.items[this.currentIndex];
|
|
13790
|
+
console.clear();
|
|
13791
|
+
console.log(chalk.bold.cyan(`🤖 Running auto-fix: ${item.title}`));
|
|
13792
|
+
console.log(chalk.dim(`File: ${item.filePath}\n`));
|
|
13793
|
+
// Fully release terminal control before handing off to child process
|
|
13794
|
+
process.stdin.setRawMode(false);
|
|
13795
|
+
process.stdin.pause();
|
|
13796
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
13797
|
+
try {
|
|
13798
|
+
await runAutoFix(item, this.config);
|
|
13799
|
+
this.markAsComplete();
|
|
13800
|
+
console.log(chalk.green('\n✅ Auto-fix completed successfully.'));
|
|
13801
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
13802
|
+
}
|
|
13803
|
+
catch (err) {
|
|
13804
|
+
console.log(chalk.red(`\n❌ Auto-fix failed: ${err.message}`));
|
|
13805
|
+
await new Promise((r) => setTimeout(r, 1800));
|
|
13806
|
+
}
|
|
13807
|
+
finally {
|
|
13808
|
+
// Restore terminal for our keypress handler
|
|
13809
|
+
process.stdin.resume();
|
|
13810
|
+
process.stdin.setRawMode(true);
|
|
13811
|
+
}
|
|
13812
|
+
}
|
|
13633
13813
|
skip() {
|
|
13634
13814
|
this.items[this.currentIndex].status = 'skipped';
|
|
13635
13815
|
this.navigate(1);
|
|
@@ -13639,7 +13819,14 @@ class TaskList {
|
|
|
13639
13819
|
this.navigate(1);
|
|
13640
13820
|
}
|
|
13641
13821
|
navigate(direction) {
|
|
13642
|
-
|
|
13822
|
+
const allDone = this.items.every((item) => item.status !== 'pending');
|
|
13823
|
+
if (allDone)
|
|
13824
|
+
return;
|
|
13825
|
+
let next = (this.currentIndex + direction + this.items.length) % this.items.length;
|
|
13826
|
+
while (this.items[next].status !== 'pending') {
|
|
13827
|
+
next = (next + direction + this.items.length) % this.items.length;
|
|
13828
|
+
}
|
|
13829
|
+
this.currentIndex = next;
|
|
13643
13830
|
}
|
|
13644
13831
|
async start() {
|
|
13645
13832
|
while (true) {
|
|
@@ -13652,6 +13839,9 @@ class TaskList {
|
|
|
13652
13839
|
case 'complete':
|
|
13653
13840
|
this.markAsComplete();
|
|
13654
13841
|
break;
|
|
13842
|
+
case 'autofix':
|
|
13843
|
+
await this.autoFix();
|
|
13844
|
+
break;
|
|
13655
13845
|
case 'skip':
|
|
13656
13846
|
this.skip();
|
|
13657
13847
|
break;
|
|
@@ -13669,43 +13859,39 @@ class TaskList {
|
|
|
13669
13859
|
await this.displaySummary();
|
|
13670
13860
|
return;
|
|
13671
13861
|
}
|
|
13862
|
+
if (this.items.every((item) => item.status !== 'pending')) {
|
|
13863
|
+
this.rl.close();
|
|
13864
|
+
await this.displaySummary();
|
|
13865
|
+
return;
|
|
13866
|
+
}
|
|
13672
13867
|
}
|
|
13673
13868
|
}
|
|
13869
|
+
renderMenu() {
|
|
13870
|
+
const choices = this.getChoices();
|
|
13871
|
+
console.log(chalk.dim('Choose an action:'));
|
|
13872
|
+
choices.forEach((c) => console.log(` ${c.name}`));
|
|
13873
|
+
}
|
|
13674
13874
|
getActionWithKeyboardShortcut() {
|
|
13875
|
+
this.renderMenu();
|
|
13675
13876
|
return new Promise((resolve) => {
|
|
13676
|
-
const
|
|
13677
|
-
|
|
13678
|
-
|
|
13679
|
-
|
|
13680
|
-
|
|
13681
|
-
|
|
13682
|
-
|
|
13683
|
-
|
|
13684
|
-
|
|
13685
|
-
case 's':
|
|
13686
|
-
resolve('skip');
|
|
13687
|
-
break;
|
|
13688
|
-
case 'x':
|
|
13689
|
-
resolve('omit');
|
|
13690
|
-
break;
|
|
13691
|
-
case 'right':
|
|
13692
|
-
resolve('next');
|
|
13693
|
-
break;
|
|
13694
|
-
case 'left':
|
|
13695
|
-
resolve('prev');
|
|
13696
|
-
break;
|
|
13697
|
-
case 'q':
|
|
13698
|
-
resolve('exit');
|
|
13699
|
-
break;
|
|
13700
|
-
}
|
|
13701
|
-
}
|
|
13877
|
+
const actionMap = {
|
|
13878
|
+
o: 'open',
|
|
13879
|
+
a: 'autofix',
|
|
13880
|
+
d: 'complete',
|
|
13881
|
+
s: 'skip',
|
|
13882
|
+
x: 'omit',
|
|
13883
|
+
right: 'next',
|
|
13884
|
+
left: 'prev',
|
|
13885
|
+
q: 'exit',
|
|
13702
13886
|
};
|
|
13703
|
-
|
|
13704
|
-
|
|
13705
|
-
|
|
13706
|
-
|
|
13887
|
+
const handleKeypress = (_, key) => {
|
|
13888
|
+
const action = key ? actionMap[key.name] : undefined;
|
|
13889
|
+
if (!action)
|
|
13890
|
+
return;
|
|
13891
|
+
process.stdin.removeListener('keypress', handleKeypress);
|
|
13707
13892
|
resolve(action);
|
|
13708
|
-
}
|
|
13893
|
+
};
|
|
13894
|
+
process.stdin.on('keypress', handleKeypress);
|
|
13709
13895
|
});
|
|
13710
13896
|
}
|
|
13711
13897
|
async displaySummary() {
|
|
@@ -13766,6 +13952,9 @@ const REVIEW_PROMPT = new prompts$1.PromptTemplate({
|
|
|
13766
13952
|
inputVariables,
|
|
13767
13953
|
});
|
|
13768
13954
|
|
|
13955
|
+
// Some review prompts still produce a single feedback object. Normalize that shape
|
|
13956
|
+
// so the parser always returns an array for the rest of the review flow.
|
|
13957
|
+
const ReviewFeedbackResponseSchema = preprocess((value) => (Array.isArray(value) ? value : [value]), ReviewFeedbackItemArraySchema);
|
|
13769
13958
|
const handler = async (argv, logger) => {
|
|
13770
13959
|
const git = getRepo();
|
|
13771
13960
|
const config = loadConfig(argv);
|
|
@@ -13866,7 +14055,7 @@ const handler = async (argv, logger) => {
|
|
|
13866
14055
|
factory,
|
|
13867
14056
|
parser,
|
|
13868
14057
|
agent: async (context, options) => {
|
|
13869
|
-
const parser = createSchemaParser(
|
|
14058
|
+
const parser = createSchemaParser(ReviewFeedbackResponseSchema, llm);
|
|
13870
14059
|
const formatInstructions = "Respond with a valid JSON object, containing four fields:'title' a string, 'summary' a short summary of the problem (include line number if big file), 'severity' a numeric enum up to ten, 'category' an enum string, and 'filePath' a relative filepath to file as string.";
|
|
13871
14060
|
const prompt = getPrompt({
|
|
13872
14061
|
template: options.prompt,
|
|
@@ -13898,7 +14087,7 @@ const handler = async (argv, logger) => {
|
|
|
13898
14087
|
process.exit(0);
|
|
13899
14088
|
},
|
|
13900
14089
|
});
|
|
13901
|
-
const reviewer = new TaskList(recap);
|
|
14090
|
+
const reviewer = new TaskList(recap, { ...config, apiKey: key ?? undefined });
|
|
13902
14091
|
await reviewer.start();
|
|
13903
14092
|
};
|
|
13904
14093
|
|