worker: add reviewer stage (branch→review→push) with R1-R6 errors
All checks were successful
CI / test (push) Successful in 47s
CI / build-and-package (amd64, linux) (push) Successful in 43s
CI / build-and-package (amd64, windows) (push) Successful in 40s

This commit is contained in:
Hermes
2026-08-16 21:21:21 +05:00
parent 130dc59840
commit 4a6ed57971
8 changed files with 610 additions and 65 deletions

117
internal/worker/review.go Normal file
View File

@@ -0,0 +1,117 @@
package worker
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"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(ctx, 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 {
pv, perr := parseReviewVerdict(output)
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
}