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 );
4444if (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" );
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 ' ]);
6670if (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 */
478630function 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