// SPDX-License-Identifier: EUPL-1.2 package agentic import ( "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "time" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) // --- agentic_review_queue tool --- // ReviewQueueInput controls the review queue runner. type ReviewQueueInput struct { Limit int `json:"limit,omitempty"` // Max PRs to process this run (default: 4) Reviewer string `json:"reviewer,omitempty"` // "coderabbit" (default), "codex", or "both" DryRun bool `json:"dry_run,omitempty"` // Preview without acting LocalOnly bool `json:"local_only,omitempty"` // Run review locally, don't touch GitHub } // ReviewQueueOutput reports what happened. type ReviewQueueOutput struct { Success bool `json:"success"` Processed []ReviewResult `json:"processed"` Skipped []string `json:"skipped,omitempty"` RateLimit *RateLimitInfo `json:"rate_limit,omitempty"` } // ReviewResult is the outcome of reviewing one repo. type ReviewResult struct { Repo string `json:"repo"` Verdict string `json:"verdict"` // clean, findings, rate_limited, error Findings int `json:"findings"` // Number of findings (0 = clean) Action string `json:"action"` // merged, fix_dispatched, skipped, waiting Detail string `json:"detail,omitempty"` } // RateLimitInfo tracks CodeRabbit rate limit state. type RateLimitInfo struct { Limited bool `json:"limited"` RetryAt time.Time `json:"retry_at,omitempty"` Message string `json:"message,omitempty"` } func (s *PrepSubsystem) registerReviewQueueTool(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "agentic_review_queue", Description: "Process the CodeRabbit review queue. Runs local CodeRabbit review on repos, auto-merges clean ones on GitHub, dispatches fix agents for findings. Respects rate limits.", }, s.reviewQueue) } func (s *PrepSubsystem) reviewQueue(ctx context.Context, _ *mcp.CallToolRequest, input ReviewQueueInput) (*mcp.CallToolResult, ReviewQueueOutput, error) { limit := input.Limit if limit <= 0 { limit = 4 } basePath := filepath.Join(s.codePath, "core") // Find repos with draft PRs (ahead of GitHub) candidates := s.findReviewCandidates(basePath) if len(candidates) == 0 { return nil, ReviewQueueOutput{ Success: true, Processed: nil, }, nil } var processed []ReviewResult var skipped []string var rateInfo *RateLimitInfo for _, repo := range candidates { if len(processed) >= limit { skipped = append(skipped, repo+" (limit reached)") continue } // Check rate limit from previous run if rateInfo != nil && rateInfo.Limited && time.Now().Before(rateInfo.RetryAt) { skipped = append(skipped, repo+" (rate limited)") continue } repoDir := filepath.Join(basePath, repo) result := s.reviewRepo(ctx, repoDir, repo, input.DryRun, input.LocalOnly) // Parse rate limit from result if result.Verdict == "rate_limited" { retryAfter := parseRetryAfter(result.Detail) rateInfo = &RateLimitInfo{ Limited: true, RetryAt: time.Now().Add(retryAfter), Message: result.Detail, } // Don't count rate-limited as processed — save the slot skipped = append(skipped, repo+" (rate limited: "+retryAfter.String()+")") continue } processed = append(processed, result) } // Save rate limit state for next run if rateInfo != nil { s.saveRateLimitState(rateInfo) } return nil, ReviewQueueOutput{ Success: true, Processed: processed, Skipped: skipped, RateLimit: rateInfo, }, nil } // findReviewCandidates returns repos that are ahead of GitHub main. func (s *PrepSubsystem) findReviewCandidates(basePath string) []string { entries, err := os.ReadDir(basePath) if err != nil { return nil } var candidates []string for _, e := range entries { if !e.IsDir() { continue } repoDir := filepath.Join(basePath, e.Name()) if !hasRemote(repoDir, "github") { continue } ahead := commitsAhead(repoDir, "github/main", "HEAD") if ahead > 0 { candidates = append(candidates, e.Name()) } } return candidates } // reviewRepo runs CodeRabbit on a single repo and takes action. func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo string, dryRun, localOnly bool) ReviewResult { result := ReviewResult{Repo: repo} // Check saved rate limit if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) { result.Verdict = "rate_limited" result.Detail = fmt.Sprintf("retry after %s", rl.RetryAt.Format(time.RFC3339)) return result } // Run reviewer CLI locally reviewer := "coderabbit" // default, can be overridden by caller cmd := s.buildReviewCommand(ctx, repoDir, reviewer) out, err := cmd.CombinedOutput() output := string(out) // Parse rate limit (both reviewers use similar patterns) if strings.Contains(output, "Rate limit exceeded") || strings.Contains(output, "rate limit") { result.Verdict = "rate_limited" result.Detail = output return result } // Parse error if err != nil && !strings.Contains(output, "No findings") && !strings.Contains(output, "no issues") { result.Verdict = "error" result.Detail = output return result } // Store raw output for training data s.storeReviewOutput(repoDir, repo, reviewer, output) // Parse verdict if strings.Contains(output, "No findings") || strings.Contains(output, "no issues") || strings.Contains(output, "LGTM") { result.Verdict = "clean" result.Findings = 0 if dryRun { result.Action = "skipped (dry run)" return result } if localOnly { result.Action = "clean (local only)" return result } // Push to GitHub and mark PR ready / merge if err := s.pushAndMerge(ctx, repoDir, repo); err != nil { result.Action = "push failed: " + err.Error() } else { result.Action = "merged" } } else { // Has findings — count them and dispatch fix agent result.Verdict = "findings" result.Findings = countFindings(output) result.Detail = truncate(output, 500) if dryRun { result.Action = "skipped (dry run)" return result } // Save findings for agent dispatch findingsFile := filepath.Join(repoDir, ".core", "coderabbit-findings.txt") coreio.Local.Write(findingsFile, output) // Dispatch fix agent with the findings task := fmt.Sprintf("Fix CodeRabbit findings. The review output is in .core/coderabbit-findings.txt. "+ "Read it, verify each finding against the code, fix what's valid. Run tests. "+ "Commit: fix(coderabbit): address review findings\n\nFindings summary (%d issues):\n%s", result.Findings, truncate(output, 1500)) s.dispatchFixFromQueue(ctx, repo, task) result.Action = "fix_dispatched" } return result } // pushAndMerge pushes to GitHub dev and merges the PR. func (s *PrepSubsystem) pushAndMerge(ctx context.Context, repoDir, repo string) error { // Push to dev pushCmd := exec.CommandContext(ctx, "git", "push", "github", "HEAD:refs/heads/dev", "--force") pushCmd.Dir = repoDir if out, err := pushCmd.CombinedOutput(); err != nil { return coreerr.E("pushAndMerge", "push failed: "+string(out), err) } // Mark PR ready if draft readyCmd := exec.CommandContext(ctx, "gh", "pr", "ready", "1") readyCmd.Dir = repoDir readyCmd.Run() // Ignore error — might already be ready // Try to merge mergeCmd := exec.CommandContext(ctx, "gh", "pr", "merge", "1", "--merge", "--delete-branch") mergeCmd.Dir = repoDir if out, err := mergeCmd.CombinedOutput(); err != nil { return coreerr.E("pushAndMerge", "merge failed: "+string(out), err) } return nil } // dispatchFixFromQueue dispatches an opus agent to fix CodeRabbit findings. func (s *PrepSubsystem) dispatchFixFromQueue(ctx context.Context, repo, task string) { // Use the dispatch system — creates workspace, spawns agent input := DispatchInput{ Repo: repo, Task: task, Agent: "claude:opus", } s.dispatch(ctx, nil, input) } // countFindings estimates the number of findings in CodeRabbit output. func countFindings(output string) int { // Count lines that look like findings count := 0 for _, line := range strings.Split(output, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.Contains(trimmed, "Issue:") || strings.Contains(trimmed, "Finding:") || strings.Contains(trimmed, "⚠") || strings.Contains(trimmed, "❌") { count++ } } if count == 0 && !strings.Contains(output, "No findings") { count = 1 // At least one finding if not clean } return count } // parseRetryAfter extracts the retry duration from a rate limit message. // Example: "please try after 4 minutes and 56 seconds" func parseRetryAfter(message string) time.Duration { re := regexp.MustCompile(`(\d+)\s*minutes?\s*(?:and\s*)?(\d+)?\s*seconds?`) matches := re.FindStringSubmatch(message) if len(matches) >= 2 { mins, _ := strconv.Atoi(matches[1]) secs := 0 if len(matches) >= 3 && matches[2] != "" { secs, _ = strconv.Atoi(matches[2]) } return time.Duration(mins)*time.Minute + time.Duration(secs)*time.Second } // Default: 5 minutes return 5 * time.Minute } // buildReviewCommand creates the CLI command for the chosen reviewer. func (s *PrepSubsystem) buildReviewCommand(ctx context.Context, repoDir, reviewer string) *exec.Cmd { switch reviewer { case "codex": return exec.CommandContext(ctx, "codex", "review", "--base", "github/main") default: // coderabbit return exec.CommandContext(ctx, "coderabbit", "review", "--plain", "--base", "github/main", "--config", "CLAUDE.md", "--cwd", repoDir) } } // storeReviewOutput saves raw review output for training data collection. func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string) { home, _ := os.UserHomeDir() dataDir := filepath.Join(home, ".core", "training", "reviews") coreio.Local.EnsureDir(dataDir) timestamp := time.Now().Format("2006-01-02T15-04-05") filename := fmt.Sprintf("%s_%s_%s.txt", repo, reviewer, timestamp) // Write raw output coreio.Local.Write(filepath.Join(dataDir, filename), output) // Append to JSONL for structured training entry := map[string]string{ "repo": repo, "reviewer": reviewer, "timestamp": time.Now().Format(time.RFC3339), "output": output, "verdict": "clean", } if !strings.Contains(output, "No findings") && !strings.Contains(output, "no issues") { entry["verdict"] = "findings" } jsonLine, _ := json.Marshal(entry) jsonlPath := filepath.Join(dataDir, "reviews.jsonl") existing, _ := coreio.Local.Read(jsonlPath) coreio.Local.Write(jsonlPath, existing+string(jsonLine)+"\n") } // saveRateLimitState persists rate limit info for cross-run awareness. func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) { home, _ := os.UserHomeDir() path := filepath.Join(home, ".core", "coderabbit-ratelimit.json") data, _ := json.Marshal(info) coreio.Local.Write(path, string(data)) } // loadRateLimitState reads persisted rate limit info. func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo { home, _ := os.UserHomeDir() path := filepath.Join(home, ".core", "coderabbit-ratelimit.json") data, err := coreio.Local.Read(path) if err != nil { return nil } var info RateLimitInfo if json.Unmarshal([]byte(data), &info) != nil { return nil } return &info }