Skip to content

Commit e9972eb

Browse files
committed
worker: add sugarcraft/candy-shine for ANSI markdown rendering
- Added sugarcraft/candy-shine package for markdown → ANSI color rendering - New verbose_markdown() function colorizes and logs markdown output at -vvvv level - Opencode markdown output now rendered in color when using -vvvv flag - Also logs to file (plain markdown, not ANSI codes for file readability)
1 parent 4672a9b commit e9972eb

2 files changed

Lines changed: 231 additions & 33 deletions

File tree

composer.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
{
2+
"minimum-stability": "dev",
3+
"prefer-stable": true,
24
"require": {
35
"php": ">=7.4",
46
"predis/predis": "^2.0",
5-
"vlucas/phpdotenv": "^5.5"
7+
"vlucas/phpdotenv": "^5.5",
8+
"symfony/console": "^7.4",
9+
"sugarcraft/candy-input": "dev-master",
10+
"sugarcraft/sugar-table": "dev-master",
11+
"sugarcraft/sugar-dash": "dev-master",
12+
"sugarcraft/candy-fuzzy": "dev-master",
13+
"sugarcraft/sugar-toast": "dev-master",
14+
"sugarcraft/sugar-bits": "dev-master",
15+
"sugarcraft/candy-shine": "dev-master"
616
},
717
"require-dev": {
818
"phpunit/phpunit": "^9.0",
@@ -11,6 +21,9 @@
1121
"autoload": {
1222
"classmap": ["src/"]
1323
},
24+
"repositories": [
25+
{"type": "composer", "url": "https://packagist.org", "canonical": true}
26+
],
1427
"scripts": {
1528
"test": "vendor/bin/phpunit",
1629
"analyse": "vendor/bin/phpstan analyse",

scripts/github-code-review.php

Lines changed: 217 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
11681238
function 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

Comments
 (0)