package worker import ( "context" "encoding/json" "fmt" "path/filepath" "strings" "github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/storage" ) // maxReviewIterations — максимальное число раундов dev↔review. const maxReviewIterations = 3 // reviewVerdict — результат ревьюера (JSON из stdout). type reviewVerdict struct { Passed bool `json:"passed"` CriticalIssues []string `json:"critical_issues"` SolidViolations []string `json:"solid_violations"` Comments []string `json:"comments"` } // repoDirOf возвращает абсолютный каталог репозитория в общем worktree. func (w *Worker) repoDirOf(repo string) string { return filepath.Join(w.Worktree, repo) } // branchDiffAll собирает diff всей feature-ветки по каждому репо и возвращает // единый блок для ревьюера (с заголовками, чтобы reviewer понимал структуру). func (w *Worker) branchDiffAll(ctx context.Context, repos []string, branch string) (string, error) { var b strings.Builder for _, r := range repos { d, err := w.branchDiff(ctx, w.repoDirOf(r), branch) if err != nil { return "", fmt.Errorf("%w: %v", ErrReviewDiff, err) } b.WriteString("\n===== DIFF repo: ") b.WriteString(r) b.WriteString(" (branch ") b.WriteString(branch) b.WriteString(") =====\n") b.WriteString(d) if strings.TrimSpace(d) == "" { b.WriteString("(нет изменений в этой ветке)\n") } } return b.String(), nil } // pushBranches пушит feature-ветку во все затронутые репозитории. func (w *Worker) pushBranches(ctx context.Context, repos []string, branch string) error { for _, r := range repos { if err := w.pushBranch(ctx, w.repoDirOf(r), branch); err != nil { return fmt.Errorf("%w: repo %s: %v", ErrReviewPush, r, err) } } return nil } // runReviewer разово запускает reviewer-агента и парсит JSON-вердикт. // Возвращает (verdict, output, traceID, err). err — только классы R2/R5; // непарсируемый JSON даёт (nil, output, traceID, nil) — вызывающий решает про retry. func (w *Worker) runReviewer(ctx context.Context, taskID int64, cwd, prompt string) (*reviewVerdict, string, int64, error) { tr := &storage.Trace{TaskID: taskID, Agent: "reviewer", Prompt: prompt} traceID, err := w.Store.AppendTrace(ctx, tr) if err != nil { return nil, "", 0, fmt.Errorf("%w: %v", ErrReviewTrace, err) } res, resErr := w.Runner.Run(w.runCtx(ctx, taskID), prompt, cwd, "reviewer", "") if resErr != nil { w.finalizeTrace(ctx, traceID, storage.TraceFailed, resErr.Error()) return nil, "", traceID, fmt.Errorf("%w: %v", ErrReviewSpawn, resErr) } if res.SessionID != "" { _ = w.Store.UpdateTraceSessionID(ctx, traceID, res.SessionID) } output := res.Stdout var v *reviewVerdict if res.RC == 0 { // Как у аналитика: сначала достаём текст вердикта из NDJSON-потока opencode // (ExtractVerdict), затем парсим уже чистый JSON-объект. verdict := opencode.ExtractVerdict(output) pv, perr := parseReviewVerdict(verdict) if perr == nil { v = pv } } ts := storage.TraceFailed if v != nil && v.Passed { ts = storage.TraceSuccess } w.finalizeTrace(ctx, traceID, ts, output) if v == nil { // Невалидный/пустой вывод или RC!=0: не парсится — сигнал на retry. return nil, output, traceID, nil } return v, output, traceID, nil } // parseReviewVerdict извлекает JSON-объект вердикта из вывода reviewer, // устойчивый к случайному markdown-обрамлению. func parseReviewVerdict(out string) (*reviewVerdict, error) { s := strings.TrimSpace(out) if i := strings.Index(s, "{"); i >= 0 { s = s[i:] } if j := strings.LastIndex(s, "}"); j >= 0 { s = s[:j+1] } var v reviewVerdict if err := json.Unmarshal([]byte(s), &v); err != nil { return nil, err } return &v, nil }