Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath, errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim, core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(), core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives. Framework boundary exceptions preserved where stdlib types are required by external interfaces (Gin, net/http, CGo, Wails, bubbletea). Co-Authored-By: Virgil <virgil@lethean.io>
443 lines
13 KiB
Go
443 lines
13 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package agentic
|
|
|
|
import (
|
|
"context"
|
|
"regexp"
|
|
"time"
|
|
|
|
core "dappco.re/go/core"
|
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// input := agentic.ReviewQueueInput{Reviewer: "coderabbit", Limit: 4, DryRun: true}
|
|
type ReviewQueueInput struct {
|
|
Limit int `json:"limit,omitempty"`
|
|
Reviewer string `json:"reviewer,omitempty"`
|
|
DryRun bool `json:"dry_run,omitempty"`
|
|
LocalOnly bool `json:"local_only,omitempty"`
|
|
}
|
|
|
|
// out := agentic.ReviewQueueOutput{Success: true, Processed: []agentic.ReviewResult{{Repo: "go-io", Verdict: "clean"}}}
|
|
type ReviewQueueOutput struct {
|
|
Success bool `json:"success"`
|
|
Processed []ReviewResult `json:"processed"`
|
|
Skipped []string `json:"skipped,omitempty"`
|
|
RateLimit *RateLimitInfo `json:"rate_limit,omitempty"`
|
|
}
|
|
|
|
// result := agentic.ReviewResult{Repo: "go-io", Verdict: "findings", Findings: 3, Action: "fix_dispatched"}
|
|
type ReviewResult struct {
|
|
Repo string `json:"repo"`
|
|
Verdict string `json:"verdict"`
|
|
Findings int `json:"findings"`
|
|
Action string `json:"action"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
// limit := agentic.RateLimitInfo{Limited: true, Message: "retry after 2026-03-22T06:00:00Z"}
|
|
type RateLimitInfo struct {
|
|
Limited bool `json:"limited"`
|
|
RetryAt time.Time `json:"retry_at,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
var retryAfterPattern = compileRetryAfterPattern()
|
|
|
|
const prManageScheduleInterval = 5 * time.Minute
|
|
|
|
func compileRetryAfterPattern() *regexp.Regexp {
|
|
pattern, err := regexp.Compile(`(\d+)\s*minutes?\s*(?:and\s*)?(\d+)?\s*seconds?`)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return pattern
|
|
}
|
|
|
|
func (s *PrepSubsystem) registerReviewQueueTool(svc *coremcp.Service) {
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
|
|
Name: "agentic_review_queue",
|
|
Description: "Process the review queue. Supports coderabbit, codex, or both reviewers, auto-merges clean ones on GitHub, dispatches fix agents for findings, and respects rate limits.",
|
|
}, s.reviewQueue)
|
|
}
|
|
|
|
// reviewers := reviewQueueReviewers("both")
|
|
func reviewQueueReviewers(reviewer string) []string {
|
|
switch core.Lower(core.Trim(reviewer)) {
|
|
case "codex":
|
|
return []string{"codex"}
|
|
case "both":
|
|
return []string{"codex", "coderabbit"}
|
|
default:
|
|
return []string{"coderabbit"}
|
|
}
|
|
}
|
|
|
|
// result := c.Command("review-queue").Run(ctx, core.NewOptions(
|
|
//
|
|
// core.Option{Key: "limit", Value: 4},
|
|
//
|
|
// ))
|
|
func (s *PrepSubsystem) cmdReviewQueue(options core.Options) core.Result {
|
|
ctx := s.commandContext()
|
|
input := ReviewQueueInput{
|
|
Limit: optionIntValue(options, "limit"),
|
|
Reviewer: optionStringValue(options, "reviewer"),
|
|
DryRun: optionBoolValue(options, "dry-run"),
|
|
LocalOnly: optionBoolValue(options, "local-only"),
|
|
}
|
|
|
|
_, output, err := s.reviewQueue(ctx, nil, input)
|
|
if err != nil {
|
|
core.Print(nil, "error: %v", err)
|
|
return core.Result{Value: err, OK: false}
|
|
}
|
|
|
|
if output.RateLimit != nil && output.RateLimit.Message != "" {
|
|
core.Print(nil, "rate limit: %s", output.RateLimit.Message)
|
|
}
|
|
for _, item := range output.Processed {
|
|
core.Print(nil, "%s: %s (%s)", item.Repo, item.Verdict, item.Action)
|
|
}
|
|
for _, item := range output.Skipped {
|
|
core.Print(nil, "skipped: %s", item)
|
|
}
|
|
|
|
return core.Result{Value: output, OK: true}
|
|
}
|
|
|
|
// result := c.Command("pr-manage").Run(ctx, core.NewOptions(
|
|
//
|
|
// core.Option{Key: "limit", Value: 4},
|
|
//
|
|
// ))
|
|
func (s *PrepSubsystem) cmdPRManage(options core.Options) core.Result {
|
|
return s.cmdReviewQueue(options)
|
|
}
|
|
|
|
// ctx, cancel := context.WithCancel(context.Background())
|
|
// go s.runPRManageLoop(ctx, 5*time.Minute)
|
|
func (s *PrepSubsystem) runPRManageLoop(ctx context.Context, interval time.Duration) {
|
|
if ctx == nil || interval <= 0 {
|
|
return
|
|
}
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if result := s.cmdPRManage(core.NewOptions()); !result.OK {
|
|
core.Warn("pr-manage scheduled run failed", "error", result.Value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *PrepSubsystem) reviewQueue(ctx context.Context, _ *mcp.CallToolRequest, input ReviewQueueInput) (*mcp.CallToolResult, ReviewQueueOutput, error) {
|
|
limit := input.Limit
|
|
if limit <= 0 {
|
|
limit = 4
|
|
}
|
|
|
|
basePath := s.codePath
|
|
if basePath == "" {
|
|
basePath = core.JoinPath(HomeDir(), "Code")
|
|
}
|
|
basePath = core.JoinPath(basePath, "core")
|
|
|
|
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 {
|
|
repoDir := core.JoinPath(basePath, repo)
|
|
for _, reviewer := range reviewQueueReviewers(input.Reviewer) {
|
|
if len(processed) >= limit {
|
|
skipped = append(skipped, core.Concat(repo, " (limit reached)"))
|
|
break
|
|
}
|
|
|
|
if reviewer == "coderabbit" && rateInfo != nil && rateInfo.Limited && time.Now().Before(rateInfo.RetryAt) {
|
|
skipped = append(skipped, core.Concat(repo, " (rate limited)"))
|
|
continue
|
|
}
|
|
|
|
result := s.reviewRepo(ctx, repoDir, repo, reviewer, input.DryRun, input.LocalOnly)
|
|
|
|
if result.Verdict == "rate_limited" {
|
|
if reviewer == "coderabbit" {
|
|
retryAfter := parseRetryAfter(result.Detail)
|
|
rateInfo = &RateLimitInfo{
|
|
Limited: true,
|
|
RetryAt: time.Now().Add(retryAfter),
|
|
Message: result.Detail,
|
|
}
|
|
skipped = append(skipped, core.Concat(repo, " (rate limited: ", retryAfter.String(), ")"))
|
|
}
|
|
continue
|
|
}
|
|
|
|
processed = append(processed, result)
|
|
}
|
|
}
|
|
|
|
if rateInfo != nil {
|
|
s.saveRateLimitState(rateInfo)
|
|
}
|
|
|
|
return nil, ReviewQueueOutput{
|
|
Success: true,
|
|
Processed: processed,
|
|
Skipped: skipped,
|
|
RateLimit: rateInfo,
|
|
}, nil
|
|
}
|
|
|
|
// repos := s.findReviewCandidates("/srv/Code/core")
|
|
func (s *PrepSubsystem) findReviewCandidates(basePath string) []string {
|
|
paths := core.PathGlob(core.JoinPath(basePath, "*"))
|
|
|
|
var candidates []string
|
|
for _, p := range paths {
|
|
if !fs.IsDir(p) {
|
|
continue
|
|
}
|
|
name := core.PathBase(p)
|
|
if !s.hasRemote(p, "github") {
|
|
continue
|
|
}
|
|
ahead := s.commitsAhead(p, "github/main", "HEAD")
|
|
if ahead > 0 {
|
|
candidates = append(candidates, name)
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
// result := s.reviewRepo(ctx, repoDir, "go-io", "coderabbit", false, false)
|
|
func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer string, dryRun, localOnly bool) ReviewResult {
|
|
result := ReviewResult{Repo: repo}
|
|
process := s.Core().Process()
|
|
|
|
if reviewer != "codex" {
|
|
if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) {
|
|
result.Verdict = "rate_limited"
|
|
result.Detail = core.Sprintf("retry after %s", rl.RetryAt.Format(time.RFC3339))
|
|
return result
|
|
}
|
|
}
|
|
|
|
if reviewer == "" {
|
|
reviewer = "coderabbit"
|
|
}
|
|
command, args := s.buildReviewCommand(repoDir, reviewer)
|
|
r := process.RunIn(ctx, repoDir, command, args...)
|
|
output, _ := r.Value.(string)
|
|
|
|
if core.Contains(output, "Rate limit exceeded") || core.Contains(output, "rate limit") {
|
|
result.Verdict = "rate_limited"
|
|
result.Detail = output
|
|
return result
|
|
}
|
|
|
|
if !r.OK && !core.Contains(output, "No findings") && !core.Contains(output, "no issues") {
|
|
result.Verdict = "error"
|
|
result.Detail = output
|
|
return result
|
|
}
|
|
|
|
s.storeReviewOutput(repoDir, repo, reviewer, output)
|
|
|
|
if core.Contains(output, "No findings") || core.Contains(output, "no issues") || core.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
|
|
}
|
|
|
|
if err := s.pushAndMerge(ctx, repoDir, repo); err != nil {
|
|
result.Action = core.Concat("push failed: ", err.Error())
|
|
} else {
|
|
result.Action = "merged"
|
|
}
|
|
} else {
|
|
result.Verdict = "findings"
|
|
result.Findings = countFindings(output)
|
|
result.Detail = truncate(output, 500)
|
|
|
|
if dryRun {
|
|
result.Action = "skipped (dry run)"
|
|
return result
|
|
}
|
|
|
|
findingsFile := core.JoinPath(repoDir, ".core", "coderabbit-findings.txt")
|
|
fs.Write(findingsFile, output)
|
|
|
|
task := core.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))
|
|
|
|
if err := s.dispatchFixFromQueue(ctx, repo, task); err != nil {
|
|
result.Action = "fix_dispatch_failed"
|
|
result.Detail = err.Error()
|
|
} else {
|
|
result.Action = "fix_dispatched"
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// _ = s.pushAndMerge(ctx, repoDir, "go-io")
|
|
func (s *PrepSubsystem) pushAndMerge(ctx context.Context, repoDir, repo string) error {
|
|
process := s.Core().Process()
|
|
if r := process.RunIn(ctx, repoDir, "git", "push", "github", "HEAD:refs/heads/dev", "--force"); !r.OK {
|
|
return core.E("pushAndMerge", core.Concat("push failed: ", r.Value.(string)), nil)
|
|
}
|
|
|
|
process.RunIn(ctx, repoDir, "gh", "pr", "ready", "--repo", core.Concat(GitHubOrg(), "/", repo))
|
|
|
|
if r := process.RunIn(ctx, repoDir, "gh", "pr", "merge", "--merge", "--delete-branch"); !r.OK {
|
|
return core.E("pushAndMerge", core.Concat("merge failed: ", r.Value.(string)), nil)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// _ = s.dispatchFixFromQueue(ctx, "go-io", task)
|
|
func (s *PrepSubsystem) dispatchFixFromQueue(ctx context.Context, repo, task string) error {
|
|
input := DispatchInput{
|
|
Repo: repo,
|
|
Task: task,
|
|
Agent: "claude:opus",
|
|
}
|
|
_, out, err := s.dispatch(ctx, nil, input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !out.Success {
|
|
return core.E("dispatchFixFromQueue", core.Concat("dispatch failed for ", repo), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findings := countFindings(output)
|
|
func countFindings(output string) int {
|
|
count := 0
|
|
for _, line := range core.Split(output, "\n") {
|
|
trimmed := core.Trim(line)
|
|
if core.HasPrefix(trimmed, "- ") || core.HasPrefix(trimmed, "* ") ||
|
|
core.Contains(trimmed, "Issue:") || core.Contains(trimmed, "Finding:") ||
|
|
core.Contains(trimmed, "⚠") || core.Contains(trimmed, "❌") {
|
|
count++
|
|
}
|
|
}
|
|
if count == 0 && !core.Contains(output, "No findings") {
|
|
count = 1
|
|
}
|
|
return count
|
|
}
|
|
|
|
// delay := parseRetryAfter("please try after 4 minutes and 56 seconds")
|
|
func parseRetryAfter(message string) time.Duration {
|
|
if retryAfterPattern == nil {
|
|
return 5 * time.Minute
|
|
}
|
|
matches := retryAfterPattern.FindStringSubmatch(message)
|
|
if len(matches) >= 2 {
|
|
mins := parseInt(matches[1])
|
|
secs := 0
|
|
if len(matches) >= 3 && matches[2] != "" {
|
|
secs = parseInt(matches[2])
|
|
}
|
|
return time.Duration(mins)*time.Minute + time.Duration(secs)*time.Second
|
|
}
|
|
return 5 * time.Minute
|
|
}
|
|
|
|
// cmd, args := s.buildReviewCommand(repoDir, "coderabbit")
|
|
func (s *PrepSubsystem) buildReviewCommand(repoDir, reviewer string) (string, []string) {
|
|
switch reviewer {
|
|
case "codex":
|
|
return "codex", []string{"review", "--base", "github/main"}
|
|
default:
|
|
return "coderabbit", []string{"review", "--plain", "--base", "github/main", "--config", "CLAUDE.md", "--cwd", repoDir}
|
|
}
|
|
}
|
|
|
|
// s.storeReviewOutput(repoDir, "go-io", "coderabbit", output)
|
|
func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string) {
|
|
dataDir := core.JoinPath(HomeDir(), ".core", "training", "reviews")
|
|
fs.EnsureDir(dataDir)
|
|
|
|
timestamp := time.Now().Format("2006-01-02T15-04-05")
|
|
filename := core.Sprintf("%s_%s_%s.txt", repo, reviewer, timestamp)
|
|
|
|
fs.Write(core.JoinPath(dataDir, filename), output)
|
|
|
|
entry := map[string]string{
|
|
"repo": repo,
|
|
"reviewer": reviewer,
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
"output": output,
|
|
"verdict": "clean",
|
|
}
|
|
if !core.Contains(output, "No findings") && !core.Contains(output, "no issues") {
|
|
entry["verdict"] = "findings"
|
|
}
|
|
jsonLine := core.JSONMarshalString(entry)
|
|
|
|
jsonlPath := core.JoinPath(dataDir, "reviews.jsonl")
|
|
r := fs.Append(jsonlPath)
|
|
if !r.OK {
|
|
return
|
|
}
|
|
core.WriteAll(r.Value, core.Concat(jsonLine, "\n"))
|
|
}
|
|
|
|
// s.saveRateLimitState(&RateLimitInfo{Limited: true, RetryAt: time.Now().Add(30 * time.Minute)})
|
|
func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) {
|
|
path := core.JoinPath(HomeDir(), ".core", "coderabbit-ratelimit.json")
|
|
if r := fs.WriteAtomic(path, core.JSONMarshalString(info)); !r.OK {
|
|
if err, ok := r.Value.(error); ok {
|
|
core.Warn("reviewQueue: failed to persist rate limit state", "path", path, "reason", err)
|
|
return
|
|
}
|
|
core.Warn("reviewQueue: failed to persist rate limit state", "path", path)
|
|
}
|
|
}
|
|
|
|
// info := s.loadRateLimitState()
|
|
func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo {
|
|
path := core.JoinPath(HomeDir(), ".core", "coderabbit-ratelimit.json")
|
|
r := fs.Read(path)
|
|
if !r.OK {
|
|
return nil
|
|
}
|
|
var info RateLimitInfo
|
|
if ur := core.JSONUnmarshalString(r.Value.(string), &info); !ur.OK {
|
|
return nil
|
|
}
|
|
return &info
|
|
}
|