159 lines
5.3 KiB
Go
159 lines
5.3 KiB
Go
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
|
||
}
|