Files
ratatoskr-go/internal/worker/review.go
ki.sagidullin 94521488b6
Some checks failed
CI / test (push) Failing after 1m55s
CI / build-and-package (amd64, linux) (push) Failing after 1m7s
CI / build-and-package (amd64, windows) (push) Successful in 29s
feat(agents): человекочитаемые вердикты аналитика, ревьюера, dev и постмортема в панель «Логи»
2026-08-24 10:33:45 +05:00

159 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package worker
import (
"context"
"encoding/json"
"fmt"
"log"
"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 {
log.Printf("reviewer: вердикт: %s", formatReviewVerdict(v))
}
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
}
// formatReviewVerdict собирает человекочитаемое однострочное описание вердикта
// ревьюера (без JSON-разметки) для панели «Логи».
func formatReviewVerdict(v *reviewVerdict) string {
var b strings.Builder
b.WriteString("passed=")
b.WriteString(boolElse(v.Passed, "yes", "no"))
if len(v.CriticalIssues) > 0 {
b.WriteString(", critical=[")
b.WriteString(strings.Join(v.CriticalIssues, " | "))
b.WriteString("]")
}
if len(v.SolidViolations) > 0 {
b.WriteString(", solid=[")
b.WriteString(strings.Join(v.SolidViolations, " | "))
b.WriteString("]")
}
if len(v.Comments) > 0 {
b.WriteString(", comments=[")
b.WriteString(strings.Join(v.Comments, " | "))
b.WriteString("]")
}
return b.String()
}
// boolElse возвращает строку для логического значения (без вызова тернарника).
func boolElse(cond bool, yes, no string) string {
if cond {
return yes
}
return no
}