1- import { appendFileSync , existsSync , readFileSync , unlinkSync , writeFileSync } from "node:fs" ;
1+ import { appendFileSync , existsSync , mkdirSync , readFileSync , unlinkSync , writeFileSync } from "node:fs" ;
2+ import { createHash } from "node:crypto" ;
23import os from "node:os" ;
34import path from "node:path" ;
45import process from "node:process" ;
@@ -34,19 +35,158 @@ function isCodeRabbitReviewCommand(command) {
3435 if ( typeof command !== "string" ) {
3536 return false ;
3637 }
37- return / c o d e r a b b i t ( \. e x e ) ? ( \s | .* \s ) r e v i e w ( \s | $ ) / . test ( command ) && ! / a u t o f i x / . test ( command ) ;
38+
39+ const argv = tokenizeSimpleCommand ( command ) ;
40+ if ( ! argv ) {
41+ return false ;
42+ }
43+
44+ const binary = path . basename ( argv [ 0 ] ) . toLowerCase ( ) ;
45+ return (
46+ [ "coderabbit" , "coderabbit.exe" , "cr" , "cr.exe" ] . includes ( binary ) &&
47+ argv [ 1 ] === "review" &&
48+ argv . includes ( "--agent" ) &&
49+ ! argv . includes ( "autofix" )
50+ ) ;
3851}
3952
40- function looksClean ( toolOutput ) {
41- if ( typeof toolOutput !== "string" ) {
53+ function tokenizeSimpleCommand ( command ) {
54+ const argv = [ ] ;
55+ let token = "" ;
56+ let quote = "" ;
57+ let escaped = false ;
58+ let hasToken = false ;
59+
60+ for ( let index = 0 ; index < command . length ; index += 1 ) {
61+ const char = command [ index ] ;
62+
63+ if ( escaped ) {
64+ token += char ;
65+ hasToken = true ;
66+ escaped = false ;
67+ continue ;
68+ }
69+
70+ if ( char === "\\" ) {
71+ escaped = true ;
72+ continue ;
73+ }
74+
75+ if ( quote ) {
76+ if ( char === quote ) {
77+ quote = "" ;
78+ } else {
79+ token += char ;
80+ hasToken = true ;
81+ }
82+ continue ;
83+ }
84+
85+ if ( char === "'" || char === '"' ) {
86+ quote = char ;
87+ hasToken = true ;
88+ continue ;
89+ }
90+
91+ if ( char === "$" && command [ index + 1 ] === "(" ) {
92+ return null ;
93+ }
94+
95+ if ( char === "#" || char === "\n" || char === "\r" || "|;&<>()`" . includes ( char ) ) {
96+ return null ;
97+ }
98+
99+ if ( / \s / . test ( char ) ) {
100+ if ( hasToken ) {
101+ argv . push ( token ) ;
102+ token = "" ;
103+ hasToken = false ;
104+ }
105+ continue ;
106+ }
107+
108+ token += char ;
109+ hasToken = true ;
110+ }
111+
112+ if ( escaped || quote ) {
113+ return null ;
114+ }
115+
116+ if ( hasToken ) {
117+ argv . push ( token ) ;
118+ }
119+
120+ return argv ;
121+ }
122+
123+ function getExitCode ( input ) {
124+ const candidates = [
125+ input ?. exit_code ,
126+ input ?. exitCode ,
127+ input ?. tool_exit_code ,
128+ input ?. tool_result ?. exit_code ,
129+ input ?. tool_result ?. exitCode ,
130+ input ?. tool_response ?. exit_code ,
131+ input ?. tool_response ?. exitCode ,
132+ ] ;
133+
134+ return candidates . find ( ( candidate ) => Number . isInteger ( candidate ) ) ;
135+ }
136+
137+ function toolSucceeded ( input ) {
138+ const exitCode = getExitCode ( input ) ;
139+ if ( exitCode !== undefined && exitCode !== 0 ) {
42140 return false ;
43141 }
44- return / ( r a i s e d | f o u n d | r e p o r t e d ) \s + 0 \s + i s s u e s | " i s s u e s " \s * : \s * \[ \s * \] | n o i s s u e s f o u n d / i. test ( toolOutput ) ;
142+
143+ return ! ( input ?. tool_error || input ?. error ) ;
144+ }
145+
146+ function reviewOutcome ( toolOutput ) {
147+ if ( typeof toolOutput !== "string" ) {
148+ return null ;
149+ }
150+
151+ let completeEvent = null ;
152+
153+ for ( const line of toolOutput . split ( / \r ? \n / ) ) {
154+ const trimmed = line . trim ( ) ;
155+ if ( ! trimmed ) {
156+ continue ;
157+ }
158+
159+ let event ;
160+ try {
161+ event = JSON . parse ( trimmed ) ;
162+ } catch {
163+ return null ;
164+ }
165+
166+ if ( event ?. type === "error" ) {
167+ return null ;
168+ }
169+
170+ if ( event ?. type === "complete" ) {
171+ completeEvent = event ;
172+ }
173+ }
174+
175+ if ( ! completeEvent || ! Number . isInteger ( completeEvent . findings ) ) {
176+ return null ;
177+ }
178+
179+ return completeEvent . findings === 0 ? "clean" : "issues" ;
45180}
46181
47182function statePath ( input ) {
48- const key = String ( input ?. conversation_id || input ?. generation_id || "global" ) . replace ( / [ ^ A - Z a - z 0 - 9 _ - ] / g, "" ) ;
49- return path . join ( os . tmpdir ( ) , `coderabbit-clean-review-${ key || "global" } .json` ) ;
183+ const key = String ( input ?. conversation_id || input ?. generation_id || "global" ) ;
184+ const digest = createHash ( "sha256" ) . update ( key ) . digest ( "hex" ) . slice ( 0 , 32 ) ;
185+ const baseDir =
186+ process . env . XDG_STATE_HOME || ( os . homedir ( ) ? path . join ( os . homedir ( ) , ".local" , "state" ) : os . tmpdir ( ) ) ;
187+ const dir = path . join ( baseDir , "coderabbit" , "cursor-plugin" ) ;
188+ mkdirSync ( dir , { recursive : true , mode : 0o700 } ) ;
189+ return path . join ( dir , `clean-review-${ digest } .json` ) ;
50190}
51191
52192function emit ( context ) {
@@ -67,20 +207,24 @@ const toolName = input?.tool_name ?? "unknown";
67207const command = input ?. tool_input ?. command ;
68208const snippet = typeof command === "string" ? command . slice ( 0 , 100 ) : "" ;
69209
70- if ( isCodeRabbitReviewCommand ( command ) ) {
71- const clean = looksClean ( input ?. tool_output ) ;
72- log ( `review-complete tool=${ toolName } clean =${ clean } cmd=${ snippet } ` ) ;
210+ if ( isCodeRabbitReviewCommand ( command ) && toolSucceeded ( input ) ) {
211+ const outcome = reviewOutcome ( input ?. tool_output ) ;
212+ log ( `review-complete tool=${ toolName } outcome =${ outcome ?? "unknown" } cmd=${ snippet } ` ) ;
73213
74- if ( clean ) {
214+ if ( outcome === " clean" ) {
75215 try {
76- writeFileSync ( statePath ( input ) , JSON . stringify ( { expires : Date . now ( ) + REMINDER_WINDOW_MS , remaining : REMINDER_MAX } ) ) ;
216+ writeFileSync (
217+ statePath ( input ) ,
218+ JSON . stringify ( { expires : Date . now ( ) + REMINDER_WINDOW_MS , remaining : REMINDER_MAX } ) ,
219+ { mode : 0o600 } ,
220+ ) ;
77221 } catch {
78222 // State is best-effort; the primary injection below still happens.
79223 }
80224 emit (
81225 "The CodeRabbit review for this request is complete and came back clean, meaning the changes passed review. Present a clean-result summary: what was reviewed (files changed, lines, scope), what it was checked for (bugs, security issues, code quality risks), confirmation that the changes passed, and suggested next steps such as running tests, committing, or opening a PR. Then finish the response there; the review request is fulfilled, so a second AI or manual pass over the same diff is not needed. Linters, type checkers, and tests remain available whenever the project workflow calls for them." ,
82226 ) ;
83- } else {
227+ } else if ( outcome === "issues" ) {
84228 try {
85229 unlinkSync ( statePath ( input ) ) ;
86230 } catch {
@@ -89,6 +233,8 @@ if (isCodeRabbitReviewCommand(command)) {
89233 emit (
90234 "The CodeRabbit review for this request is complete. Present the parsed results grouped by severity and finish the response there; the review request is fulfilled. A second AI or manual pass over the same diff is not needed. Linters, type checkers, and tests remain available whenever the project workflow calls for them." ,
91235 ) ;
236+ } else {
237+ log ( `pass unrecognized-agent-output tool=${ toolName } cmd=${ snippet } ` ) ;
92238 }
93239 process . exit ( 0 ) ;
94240}
0 commit comments