@@ -113,6 +113,30 @@ function verbose_raw(string $label, string $output): void
113113 }
114114}
115115
116+ /**
117+ * Render and log markdown with ANSI colors using candy-shine (only at -vvvv level)
118+ */
119+ function verbose_markdown (string $ label , string $ markdown ): void
120+ {
121+ global $ verbose , $ logFile ;
122+ if ($ verbose >= 4 ) {
123+ $ prefix = '[MD] ' ;
124+ $ line = "github-code-review: {$ prefix } {$ label }" ;
125+ error_log ($ line );
126+ try {
127+ $ colored = \SugarCraft \Shine \Renderer::renderMarkdown ($ markdown );
128+ error_log ($ colored );
129+ } catch (\Throwable $ e ) {
130+ // Fallback to plain text if rendering fails
131+ error_log ($ markdown );
132+ }
133+ if ($ logFile !== null ) {
134+ $ timestamp = date ('Y-m-d H:i:s. ' ) . sprintf ('%06d ' , (microtime (true ) - floor (microtime (true ))) * 1000000 );
135+ file_put_contents ($ logFile , "[ {$ timestamp }] {$ line }\n[ {$ timestamp }] {$ markdown }\n" , FILE_APPEND );
136+ }
137+ }
138+ }
139+
116140/**
117141 * Look up a PR ref (head or base branch) via GitHub API with retry logic
118142 *
@@ -815,7 +839,10 @@ function runCommandWithTimeout(string $cmd, int $timeoutSecs = 30): array
815839/**
816840 * Parse opencode improve output to extract fixed content
817841 *
818- * @param string $rawOutput Raw JSON/text output from opencode
842+ * Opencode outputs NDJSON format - each line is a separate JSON object.
843+ * Fixed content may be in JSON code blocks within text events.
844+ *
845+ * @param string $rawOutput Raw NDJSON output from opencode
819846 * @param string $file File path
820847 * @param string $originalContent Original file content
821848 * @return array{success: bool, content: string|null}
@@ -827,53 +854,92 @@ function parseImproveOutput(string $rawOutput, string $file, string $originalCon
827854 }
828855
829856 $ data = json_decode ($ rawOutput , true );
830- if (! is_array ($ data )) {
831- // Try to extract JSON from output
832- if (preg_match ( ' /\{.*\}/s ' , $ rawOutput , $ matches ) ) {
833- $ data = json_decode ( $ matches [ 0 ], true ) ;
857+ if (is_array ($ data )) {
858+ $ result = extractFixedContent ( $ data , $ file );
859+ if ($ result !== null ) {
860+ return [ ' success ' => true , ' content ' => $ result ] ;
834861 }
835862 }
836863
837- if (!is_array ($ data )) {
838- return ['success ' => false , 'content ' => null ];
864+ $ fullText = '' ;
865+ $ lines = explode ("\n" , $ rawOutput );
866+ foreach ($ lines as $ line ) {
867+ $ line = trim ($ line );
868+ if ($ line === '' ) {
869+ continue ;
870+ }
871+
872+ $ event = json_decode ($ line , true );
873+ if (!is_array ($ event )) {
874+ continue ;
875+ }
876+
877+ if (($ event ['type ' ] ?? '' ) === 'text ' ) {
878+ $ text = $ event ['part ' ]['text ' ] ?? '' ;
879+ $ fullText .= $ text . "\n" ;
880+
881+ if (preg_match ('/```(?:json|php)?\s*(\{[\s\S]*?\})\s*```/ ' , $ text , $ matches )) {
882+ $ json = json_decode ($ matches [1 ], true );
883+ if (is_array ($ json )) {
884+ $ result = extractFixedContent ($ json , $ file );
885+ if ($ result !== null ) {
886+ return ['success ' => true , 'content ' => $ result ];
887+ }
888+ }
889+ }
890+
891+ if (preg_match ('/```(?:php)?\s*(<\?php[\s\S]*?)\s*```/ ' , $ text , $ matches )) {
892+ return ['success ' => true , 'content ' => $ matches [1 ]];
893+ }
894+ }
895+ }
896+
897+ if ($ fullText !== '' ) {
898+ if (preg_match ('/```php\s*(<\?php[\s\S]+?)\s*```/i ' , $ fullText , $ matches )) {
899+ return ['success ' => true , 'content ' => $ matches [1 ]];
900+ }
901+ if (preg_match ('/```\s*(<\?php[\s\S]+?)\s*```/i ' , $ fullText , $ matches )) {
902+ return ['success ' => true , 'content ' => $ matches [1 ]];
903+ }
839904 }
840905
841- // Look for fixed file content in various output structures
842- // Pattern 1: data.files[filename].content
906+ return ['success ' => false , 'content ' => null ];
907+ }
908+
909+ /**
910+ * Extract fixed content from parsed JSON structure
911+ */
912+ function extractFixedContent (array $ data , string $ file ): ?string
913+ {
843914 if (isset ($ data ['files ' ][$ file ]['content ' ])) {
844- return [ ' success ' => true , ' content ' => $ data ['files ' ][$ file ]['content ' ] ];
915+ return $ data ['files ' ][$ file ]['content ' ];
845916 }
846917
847- // Pattern 2: data[filename] = fixed content string
848918 if (isset ($ data [$ file ]) && is_string ($ data [$ file ])) {
849- return [ ' success ' => true , ' content ' => $ data [$ file] ];
919+ return $ data [$ file ];
850920 }
851921
852- // Pattern 3: data.content = the fixed content
853922 if (isset ($ data ['content ' ]) && is_string ($ data ['content ' ])) {
854- return [ ' success ' => true , ' content ' => $ data ['content ' ] ];
923+ return $ data ['content ' ];
855924 }
856925
857- // Pattern 4: data.fixes[] array with file+content
858926 if (isset ($ data ['fixes ' ]) && is_array ($ data ['fixes ' ])) {
859927 foreach ($ data ['fixes ' ] as $ fix ) {
860928 if (($ fix ['file ' ] ?? '' ) === $ file && isset ($ fix ['content ' ])) {
861- return [ ' success ' => true , ' content ' => $ fix ['content ' ] ];
929+ return $ fix ['content ' ];
862930 }
863931 }
864932 }
865933
866- // Pattern 5: data.improved[filename]
867934 if (isset ($ data ['improved ' ][$ file ])) {
868- return [ ' success ' => true , ' content ' => $ data ['improved ' ][$ file] ];
935+ return $ data ['improved ' ][$ file ];
869936 }
870937
871- // Pattern 6: data.diff - individual patch
872938 if (isset ($ data ['diff ' ]) && is_string ($ data ['diff ' ])) {
873- return [ ' success ' => true , ' content ' => $ data ['diff ' ] ];
939+ return $ data ['diff ' ];
874940 }
875941
876- return [ ' success ' => false , ' content ' => null ] ;
942+ return null ;
877943}
878944
879945/**
@@ -1153,7 +1219,7 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate): s
11531219
11541220 // At level 4 (RAW), log the full opencode output for debugging
11551221 if (strlen ($ result ) > 0 ) {
1156- verbose_raw ("opencode_output " , $ result );
1222+ verbose_markdown ("opencode_output " , $ result );
11571223 }
11581224
11591225 return $ result ;
@@ -1163,19 +1229,23 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate): s
11631229 * Parse opencode JSON output into structured issues
11641230 *
11651231 * Opencode outputs JSON Lines (NDJSON) format - each line is a separate JSON object.
1166- * The actual issues are embedded in 'text' events, wrapped in markdown code blocks.
1232+ * The AI produces markdown code review reports (not JSON) with emoji-prefixed issues:
1233+ * 🔴 Critical | 🟠 Major | 🟡 Minor | 🟢 Nitpick
1234+ * e.g., "#### 🔴 SQL Injection Vulnerability — include/file.php:207"
1235+ *
1236+ * Also supports JSON code blocks for backward compatibility.
11671237 */
11681238function parseAnalysisOutput (string $ rawOutput ): array
11691239{
11701240 $ issues = [];
11711241
11721242 if ($ rawOutput === '' ) {
1173- return $ issues ; // No output = no issues (not an error)
1243+ return $ issues ;
11741244 }
11751245
11761246 $ hadValidParse = false ;
1247+ $ fullText = '' ;
11771248
1178- // Split by lines and parse each as JSON (JSON Lines format)
11791249 $ lines = explode ("\n" , $ rawOutput );
11801250 foreach ($ lines as $ line ) {
11811251 $ line = trim ($ line );
@@ -1188,39 +1258,154 @@ function parseAnalysisOutput(string $rawOutput): array
11881258 continue ;
11891259 }
11901260
1191- // Look for text events that might contain JSON in code blocks
11921261 if (($ event ['type ' ] ?? '' ) === 'text ' ) {
11931262 $ text = $ event ['part ' ]['text ' ] ?? '' ;
1194- // Extract JSON from markdown code blocks
1263+ $ fullText .= $ text . "\n" ;
1264+
11951265 if (preg_match ('/```json\s*(\[[\s\S]*?\]|\{[\s\S]*?\})\s*```/ ' , $ text , $ matches )) {
11961266 $ json = json_decode ($ matches [1 ], true );
11971267 if (is_array ($ json )) {
11981268 $ hadValidParse = true ;
1199- // If it's an array of issues, merge them
12001269 if (isset ($ json [0 ]) && is_array ($ json [0 ])) {
12011270 foreach ($ json as $ issue ) {
12021271 if (is_array ($ issue ) && (isset ($ issue ['severity ' ]) || isset ($ issue ['line ' ]) || isset ($ issue ['message ' ]))) {
1203- $ issues [] = $ issue ;
1272+ $ issues [] = normalizeIssue ( $ issue) ;
12041273 }
12051274 }
12061275 } elseif (isset ($ json ['file ' ]) || isset ($ json ['line ' ])) {
1207- // Single issue object
1208- $ issues [] = $ json ;
1276+ $ issues [] = normalizeIssue ($ json );
12091277 }
12101278 }
12111279 }
12121280 }
12131281 }
12141282
1283+ if (!$ hadValidParse && $ fullText !== '' ) {
1284+ $ issues = array_merge ($ issues , parseMarkdownIssues ($ fullText ));
1285+ if (!empty ($ issues )) {
1286+ $ hadValidParse = true ;
1287+ }
1288+ }
1289+
12151290 if (!$ hadValidParse ) {
1216- verbose_log ('failed to parse opencode output - no valid JSON found in output ' , 1 );
1291+ verbose_log ('failed to parse opencode output - no valid JSON or markdown issues found ' , 1 );
12171292 } elseif (empty ($ issues )) {
12181293 verbose_log ('opencode output parsed but no issues found ' , 3 );
12191294 }
12201295
12211296 return $ issues ;
12221297}
12231298
1299+ /**
1300+ * Normalize issue array to ensure consistent field names
1301+ */
1302+ function normalizeIssue (array $ issue ): array
1303+ {
1304+ $ normalized = [
1305+ 'file ' => $ issue ['file ' ] ?? $ issue ['path ' ] ?? '' ,
1306+ 'line ' => isset ($ issue ['line ' ]) ? (int )$ issue ['line ' ] : ($ issue ['line_number ' ] ?? 0 ),
1307+ 'severity ' => $ issue ['severity ' ] ?? 'warning ' ,
1308+ 'message ' => $ issue ['message ' ] ?? $ issue ['description ' ] ?? '' ,
1309+ ];
1310+
1311+ $ sev = strtolower ($ normalized ['severity ' ]);
1312+ if (in_array ($ sev , ['critical ' , 'crit ' , 'blocker ' , 'error ' ], true )) {
1313+ $ normalized ['severity ' ] = 'critical ' ;
1314+ } elseif (in_array ($ sev , ['major ' , 'error ' ], true )) {
1315+ $ normalized ['severity ' ] = 'major ' ;
1316+ } elseif (in_array ($ sev , ['minor ' , 'warning ' , 'warn ' ], true )) {
1317+ $ normalized ['severity ' ] = 'minor ' ;
1318+ } elseif (in_array ($ sev , ['nitpick ' , 'info ' , 'suggestion ' , 'note ' ], true )) {
1319+ $ normalized ['severity ' ] = 'info ' ;
1320+ }
1321+
1322+ return $ normalized ;
1323+ }
1324+
1325+ /**
1326+ * Parse issues from markdown code review format
1327+ *
1328+ * Extracts issues from markdown like:
1329+ * #### 🔴 SQL Injection Vulnerability — include/file.php:207
1330+ * #### 🟠 Missing Error Handling — include/file.php:42
1331+ * #### 🟡 Code Style Issue — include/file.php:100
1332+ */
1333+ function parseMarkdownIssues (string $ text ): array
1334+ {
1335+ $ issues = [];
1336+ $ severityMap = [
1337+ '🔴 ' => 'critical ' ,
1338+ '🟠 ' => 'major ' ,
1339+ '🟡 ' => 'minor ' ,
1340+ '🟢 ' => 'info ' ,
1341+ ];
1342+
1343+ $ lines = explode ("\n" , $ text );
1344+ $ currentIssue = null ;
1345+ $ currentDescription = [];
1346+
1347+ foreach ($ lines as $ line ) {
1348+ $ line = rtrim ($ line );
1349+ if ($ line === '' ) {
1350+ if ($ currentIssue !== null && !empty ($ currentDescription )) {
1351+ $ currentIssue ['message ' ] = trim (implode ("\n" , $ currentDescription ));
1352+ if ($ currentIssue ['message ' ] !== '' ) {
1353+ $ issues [] = $ currentIssue ;
1354+ }
1355+ $ currentIssue = null ;
1356+ $ currentDescription = [];
1357+ }
1358+ continue ;
1359+ }
1360+
1361+ if (preg_match ('/^#{1,6}\s*([🔴🟠🟡🟢])\s+(.+?)(?:[\s—–-]+([^:\s]+):(\d+))?$/ ' , $ line , $ matches )) {
1362+ if ($ currentIssue !== null && !empty ($ currentDescription )) {
1363+ $ currentIssue ['message ' ] = trim (implode ("\n" , $ currentDescription ));
1364+ if ($ currentIssue ['message ' ] !== '' ) {
1365+ $ issues [] = $ currentIssue ;
1366+ }
1367+ }
1368+
1369+ $ emoji = $ matches [1 ];
1370+ $ title = trim ($ matches [2 ]);
1371+ $ file = $ matches [3 ] ?? '' ;
1372+ $ lineNum = isset ($ matches [4 ]) ? (int )$ matches [4 ] : 0 ;
1373+
1374+ $ currentIssue = [
1375+ 'file ' => $ file ,
1376+ 'line ' => $ lineNum ,
1377+ 'severity ' => $ severityMap [$ emoji ] ?? 'minor ' ,
1378+ 'message ' => $ title ,
1379+ 'title ' => $ title ,
1380+ ];
1381+ $ currentDescription = [];
1382+ } elseif ($ currentIssue !== null ) {
1383+ if (preg_match ('/^#{1,6}\s+[^🔴🟠🟡🟢]/ ' , $ line )) {
1384+ $ currentIssue ['message ' ] = trim (implode ("\n" , $ currentDescription ));
1385+ if ($ currentIssue ['message ' ] !== '' ) {
1386+ $ issues [] = $ currentIssue ;
1387+ }
1388+ $ currentIssue = null ;
1389+ $ currentDescription = [];
1390+ } else {
1391+ $ cleanLine = ltrim ($ line , ' `-* ' );
1392+ if ($ cleanLine !== '' && !preg_match ('/^\*\*[A-Z][a-z]+ [A-Z][a-z]+ \*\*$/ ' , $ cleanLine )) {
1393+ $ currentDescription [] = $ cleanLine ;
1394+ }
1395+ }
1396+ }
1397+ }
1398+
1399+ if ($ currentIssue !== null && !empty ($ currentDescription )) {
1400+ $ currentIssue ['message ' ] = trim (implode ("\n" , $ currentDescription ));
1401+ if ($ currentIssue ['message ' ] !== '' ) {
1402+ $ issues [] = $ currentIssue ;
1403+ }
1404+ }
1405+
1406+ return $ issues ;
1407+ }
1408+
12241409/**
12251410 * Post a regular comment to a GitHub PR (not a review comment)
12261411 * @return string True on success, error message on failure
0 commit comments