Skip to content

Commit 15e602d

Browse files
committed
feat: stream opencode agent messages in real-time with --show-agent flag
1 parent 70ebd47 commit 15e602d

1 file changed

Lines changed: 236 additions & 11 deletions

File tree

scripts/github-code-review.php

Lines changed: 236 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,18 @@
4040
$running = true;
4141
$logFile = null;
4242
$optind = 0;
43-
$args = getopt('vhwb', ['verbose', 'help', 'wait-if-empty', 'review-bots', 'log::'], $optind);
43+
$args = getopt('vhwb:a:', ['verbose', 'help', 'wait-if-empty', 'review-bots', 'show-agent', 'show-all-issues', 'log::'], $optind);
4444
if (isset($args['h']) || isset($args['help'])) {
45-
fwrite(STDOUT, "Usage: php scripts/github-code-review.php [-v|--verbose]... [-w|--wait-if-empty] [-b|--review-bots] [--log=FILE]\n");
45+
fwrite(STDOUT, "Usage: php scripts/github-code-review.php [-v|--verbose]... [-w|--wait-if-empty] [-b|--review-bots] [-a|--show-agent] [--show-all-issues] [--log=FILE]\n");
4646
fwrite(STDOUT, " -v, --verbose Increase verbosity (can stack: -vv, -vvv, -vvvv)\n");
4747
fwrite(STDOUT, " -v [INFO] Essential progress (job start/complete/errors)\n");
4848
fwrite(STDOUT, " -vv [DEBUG] Detailed progress (checkpoints, function entry)\n");
4949
fwrite(STDOUT, " -vvv [TRACE] Full trace (loop iterations, gh commands)\n");
5050
fwrite(STDOUT, " -vvvv [RAW] Everything: raw gh output, API responses, full command output\n");
5151
fwrite(STDOUT, " -w, --wait-if-empty Wait for jobs when queue is empty (default: exit)\n");
5252
fwrite(STDOUT, " -b, --review-bots Also review PRs from bots like dependabot (default: skip)\n");
53+
fwrite(STDOUT, " -a, --show-agent Stream opencode agent messages in real-time (colorized at -vvvv)\n");
54+
fwrite(STDOUT, " --show-all-issues Include issues without file/line in report (default: skip summary sections)\n");
5355
fwrite(STDOUT, " --log=FILE Append all log output to FILE (in addition to stderr)\n");
5456
fwrite(STDOUT, " -h, --help Show this help message\n");
5557
fwrite(STDOUT, "\n Press Ctrl-C to exit gracefully at any time.\n");
@@ -63,6 +65,8 @@
6365
}
6466
$waitIfEmpty = isset($args['w']) || isset($args['wait-if-empty']);
6567
$reviewBots = isset($args['b']) || isset($args['review-bots']);
68+
$showAgent = isset($args['a']) || isset($args['show-agent']);
69+
$showAllIssues = isset($args['show-all-issues']);
6670
if (isset($args['log'])) {
6771
$logFile = $args['log'] !== false ? $args['log'] : 'github-code-review.log';
6872
}
@@ -137,6 +141,154 @@ function verbose_markdown(string $label, string $markdown): void
137141
}
138142
}
139143

144+
/**
145+
* Parse an opencode NDJSON event line and return a display string or null
146+
*
147+
* Event types:
148+
* - text → display part.text (truncate at 5000 chars)
149+
* - tool_use → display 🔧 [tool] filePath or prompt snippet
150+
* - step_start → display step-start: {id}
151+
* - step_finish → display ✓ step {tokens.input+output}
152+
* - stop → display ■ analysis complete
153+
*
154+
* @param string $line Raw NDJSON line
155+
* @param int $verbose Current verbosity level
156+
* @return string|null Formatted display string, or null to skip
157+
*/
158+
function parseOpencodeEventLine(string $line, int $verbose = 4): ?string
159+
{
160+
$line = trim($line);
161+
if ($line === '') {
162+
return null;
163+
}
164+
165+
$event = json_decode($line, true);
166+
if (!is_array($event)) {
167+
return null;
168+
}
169+
170+
$type = $event['type'] ?? '';
171+
172+
switch ($type) {
173+
case 'text':
174+
$part = $event['part'] ?? [];
175+
$text = is_array($part) ? ($part['text'] ?? '') : (is_string($part) ? $part : '');
176+
if ($text === '') {
177+
return null;
178+
}
179+
// Truncate at 5000 chars
180+
if (strlen($text) > 5000) {
181+
$text = substr($text, 0, 5000) . '... [truncated]';
182+
}
183+
// Colorize markdown at verbosity 4
184+
if ($verbose >= 4) {
185+
try {
186+
return \SugarCraft\Shine\Renderer::renderMarkdown($text);
187+
} catch (\Throwable $e) {
188+
return $text;
189+
}
190+
}
191+
return $text;
192+
193+
case 'tool_use':
194+
$part = $event['part'] ?? [];
195+
$tool = is_array($part) ? ($part['tool'] ?? 'unknown') : 'unknown';
196+
$state = $event['part']['state'] ?? [];
197+
$input = is_array($state) ? ($state['input'] ?? []) : [];
198+
199+
if (isset($input['filePath'])) {
200+
$path = $input['filePath'];
201+
// Shorten long paths
202+
if (strlen($path) > 60) {
203+
$path = '...' . substr($path, -57);
204+
}
205+
return "🔧 [{$tool}] {$path}";
206+
}
207+
if (isset($input['prompt'])) {
208+
$prompt = $input['prompt'];
209+
// Truncate prompt display
210+
if (strlen($prompt) > 60) {
211+
$prompt = substr($prompt, 0, 60) . '...';
212+
}
213+
return "🔧 [{$tool}] {$prompt}";
214+
}
215+
// Just show tool name if no input context
216+
if (isset($input['query'])) {
217+
$query = $input['query'];
218+
if (strlen($query) > 60) {
219+
$query = substr($query, 0, 60) . '...';
220+
}
221+
return "🔧 [{$tool}] {$query}";
222+
}
223+
return "🔧 [{$tool}]";
224+
225+
case 'step_start':
226+
$stepId = $event['part']['stepId'] ?? $event['part']['id'] ?? 'unknown';
227+
return "▶ step-start: {$stepId}";
228+
229+
case 'step_finish':
230+
$part = $event['part'] ?? [];
231+
$tokens = $part['tokens'] ?? [];
232+
$inputTokens = is_array($tokens) ? ($tokens['input'] ?? 0) : 0;
233+
$outputTokens = is_array($tokens) ? ($tokens['output'] ?? 0) : 0;
234+
return "✓ step {$inputTokens}+{$outputTokens} tokens";
235+
236+
case 'stop':
237+
return '■ analysis complete';
238+
239+
default:
240+
// For unknown types, show nothing (no spam)
241+
return null;
242+
}
243+
}
244+
245+
/**
246+
* Stream opencode output in real-time, parsing NDJSON lines and displaying them
247+
*
248+
* @param resource $stdout Pipe from proc_open
249+
* @param int $verbose Verbosity level
250+
* @param string|null $logFile Optional log file path
251+
* @return string Accumulated raw output
252+
*/
253+
function streamOpencodeOutput($stdout, int $verbose, ?string $logFile = null): string
254+
{
255+
$accumulated = '';
256+
$renderer = null;
257+
258+
if ($verbose >= 4) {
259+
try {
260+
$renderer = new \SugarCraft\Shine\Renderer();
261+
} catch (\Throwable $e) {
262+
$renderer = null;
263+
}
264+
}
265+
266+
while (!feof($stdout)) {
267+
$line = fgets($stdout);
268+
if ($line === false) {
269+
break;
270+
}
271+
272+
$accumulated .= $line;
273+
274+
// Parse and display the event in real-time
275+
$display = parseOpencodeEventLine($line, $verbose);
276+
if ($display !== null && $display !== '') {
277+
// Output to stderr for real-time display
278+
fwrite(STDERR, $display . "\n");
279+
fflush(STDERR);
280+
281+
// Also log to file if configured
282+
if ($logFile !== null) {
283+
$timestamp = date('Y-m-d H:i:s.') . sprintf('%06d', (microtime(true) - floor(microtime(true))) * 1000000);
284+
file_put_contents($logFile, "[{$timestamp}] [AGENT] {$display}\n", FILE_APPEND);
285+
}
286+
}
287+
}
288+
289+
return $accumulated;
290+
}
291+
140292
/**
141293
* Look up a PR ref (head or base branch) via GitHub API with retry logic
142294
*
@@ -477,7 +629,7 @@ function main(): void
477629
*/
478630
function processJob(array $job): string
479631
{
480-
global $githubToken, $checkoutRoot, $opencodeAnalyzeCmd, $opencodeImproveCmd, $verbose, $reviewBots, $running;
632+
global $githubToken, $checkoutRoot, $opencodeAnalyzeCmd, $opencodeImproveCmd, $verbose, $reviewBots, $running, $showAgent, $showAllIssues;
481633

482634
// Check if shutdown was requested before starting work
483635
if (!$running) {
@@ -568,10 +720,10 @@ function processJob(array $job): string
568720
initGitRepo($checkoutPath);
569721

570722
verbose_log("starting opencode analysis for {$repo}#{$prNumber} in {$checkoutPath}", 2);
571-
$analysisOutput = runOpencodeAnalysis($checkoutPath, $jobId, $opencodeAnalyzeCmd);
723+
$analysisOutput = runOpencodeAnalysis($checkoutPath, $jobId, $opencodeAnalyzeCmd, $showAgent);
572724
verbose_log("analysis command completed", 3);
573725

574-
$issues = parseAnalysisOutput($analysisOutput);
726+
$issues = parseAnalysisOutput($analysisOutput, $showAllIssues);
575727
verbose_log("found " . count($issues) . " issues", 2);
576728

577729
// At DEBUG level, log the actual JSON issues found
@@ -1211,7 +1363,7 @@ function checkoutBranch(string $repo, string $branch, string $checkoutPath): str
12111363
* @param string $cmdTemplate Unused, kept for signature compatibility
12121364
* @return string Raw opencode output
12131365
*/
1214-
function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate): string
1366+
function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate, bool $showAgent = false): string
12151367
{
12161368
// Load review prompt from file (allows easy tweaking without code changes)
12171369
static $reviewPrompt = null;
@@ -1239,18 +1391,72 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate): s
12391391

12401392
// Use bash -c with $(cat ...) to read prompt from file - avoids all escaping issues
12411393
$opencodeCmd = sprintf(
1242-
'bash -c \'cd %s && git diff --name-only && git diff && opencode run "$(cat %s)" --format json\' 2>&1',
1394+
'cd %s && git diff --name-only && git diff && opencode run "$(cat %s)" --format json',
12431395
escapeshellarg($dir),
12441396
escapeshellarg($promptFile)
12451397
);
12461398

1247-
verbose_log("runOpencodeAnalysis: executing analysis in {$dir}", 3);
1399+
verbose_log("runOpencodeAnalysis: executing analysis in {$dir}" . ($showAgent ? ' [streaming]' : ''), 3);
1400+
1401+
global $running, $verbose, $logFile;
1402+
1403+
// When showAgent is enabled, use proc_open with streaming
1404+
if ($showAgent) {
1405+
$desc = [
1406+
0 => ['pipe', 'r'], // stdin
1407+
1 => ['pipe', 'w'], // stdout
1408+
2 => ['pipe', 'w'], // stderr (capture but don't stream separately)
1409+
];
1410+
1411+
$proc = proc_open($opencodeCmd, $desc, $pipes, $dir);
1412+
1413+
if ($proc === false) {
1414+
verbose_log("runOpencodeAnalysis: proc_open failed", 1);
1415+
return '';
1416+
}
1417+
1418+
// Set stdout to non-blocking for streaming reads
1419+
stream_set_blocking($pipes[1], false);
1420+
1421+
$result = streamOpencodeOutput($pipes[1], $verbose, $logFile);
12481422

1423+
// Read any remaining stderr
1424+
$stderr = '';
1425+
if (!feof($pipes[2])) {
1426+
$stderr = stream_get_contents($pipes[2]);
1427+
if ($stderr !== false && $stderr !== '') {
1428+
verbose_raw("opencode_stderr", $stderr);
1429+
}
1430+
}
1431+
1432+
// Clean up pipes
1433+
foreach ($pipes as $pipe) {
1434+
fclose($pipe);
1435+
}
1436+
1437+
$exitCode = proc_close($proc);
1438+
1439+
if (!$running) {
1440+
verbose_log("runOpencodeAnalysis: shutdown requested during streaming, discarding output", 2);
1441+
return '';
1442+
}
1443+
1444+
verbose_log("runOpencodeAnalysis: streaming completed (exit={$exitCode}), output length=" . strlen($result), 3);
1445+
1446+
// At level 4 (RAW), log the full opencode output for debugging
1447+
// (already streamed, but log the full accumulated output for log file)
1448+
if (strlen($result) > 0 && $verbose >= 4) {
1449+
verbose_markdown("opencode_output", $result);
1450+
}
1451+
1452+
return $result;
1453+
}
1454+
1455+
// Standard buffered execution (original behavior)
12491456
$output = [];
12501457
$ret = 0;
1251-
exec($opencodeCmd, $output, $ret);
1458+
exec($opencodeCmd . ' 2>&1', $output, $ret);
12521459

1253-
global $running;
12541460
if (!$running) {
12551461
verbose_log("runOpencodeAnalysis: shutdown requested during exec, discarding output", 2);
12561462
return '';
@@ -1277,8 +1483,10 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate): s
12771483
*
12781484
* Also supports JSON code blocks for backward compatibility.
12791485
*/
1280-
function parseAnalysisOutput(string $rawOutput): array
1486+
function parseAnalysisOutput(string $rawOutput, bool $showAllIssues = false): array
12811487
{
1488+
global $verbose, $showAgent;
1489+
12821490
$issues = [];
12831491

12841492
if ($rawOutput === '') {
@@ -1330,12 +1538,29 @@ function parseAnalysisOutput(string $rawOutput): array
13301538
}
13311539
}
13321540

1541+
// Display full analysis markdown at verbosity 3+ when --show-agent is set
1542+
if ($showAgent && $verbose >= 3 && $fullText !== '') {
1543+
verbose_markdown("code_review_report", $fullText);
1544+
}
1545+
13331546
if (!$hadValidParse) {
13341547
verbose_log('failed to parse opencode output - no valid JSON or markdown issues found', 1);
13351548
} elseif (empty($issues)) {
13361549
verbose_log('opencode output parsed but no issues found', 3);
13371550
}
13381551

1552+
// Filter issues: only keep those with BOTH file path AND line number,
1553+
// unless --show-all-issues is passed (to include summary sections)
1554+
if (!$showAllIssues) {
1555+
$issues = array_filter($issues, function (array $issue): bool {
1556+
$file = $issue['file'] ?? '';
1557+
$line = $issue['line'] ?? 0;
1558+
// Keep only issues that have BOTH file and line
1559+
return $file !== '' && $line > 0;
1560+
});
1561+
$issues = array_values($issues); // Re-index array
1562+
}
1563+
13391564
return $issues;
13401565
}
13411566

0 commit comments

Comments
 (0)