57adf1449c
* feat(review): persist and publish native code-review findings Adds the backend core for reviewing a task's working diff before a PR exists: task-scoped review runs and anchored, advisory findings that the Changes/Review panel can render as inline comments. - task_review_runs / task_review_findings schema, models, and repository, with supersede-on-re-review and a restart sweep that cancels in-flight runs rather than leaving them looking alive. - ReviewService as the single write path, publishing bus events that the WS gateway forwards to the client. - internal/review: reviewer-identity resolution (agent profile > code-review utility agent > user default, failing closed on passthrough-only profiles), change-set collection, prompt batching, response parsing, anchor extraction, and run orchestration. - publish_review_findings_kandev MCP tool plus the task.review.* WS actions. - Go djb2 that matches apps/web/lib/utils/hash.ts, asserted from shared fixtures on both sides so stale detection agrees across the wire. Spec: docs/specs/native-code-review/spec.md Plan: docs/plans/native-code-review/plan.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(review): add run_code_review workflow step and wire the review runner Makes a review pass triggerable both ways and actually runnable end to end. - run_code_review on_enter action across models, engine compile, and an orchestrator callback that logs rather than propagates failures, so a task never gets stuck in a step because no reviewer was configured. - The step's optional agent_profile_id is what lets a step review with a different model than it implemented with; export/import swap it for a portable agent name/model/mode so it survives a cross-workspace round trip instead of carrying a dangling id. - Concrete adapters for reviewer identity, changed-file collection, prompt rendering from the code-review utility agent's editable prompt, and inference (session-bound agentctl, falling back to the host instance so a review works on a task whose agent is not running). - Boot sweep cancels review runs interrupted by a restart; the E2E reset clears review state before deleting the tasks that own it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): add review findings state, helpers, and live updates Frontend plumbing for native code review, ahead of the UI that renders it. - Review types, WS message map, and an API client that preserves the backend's machine-readable failure code so the surface can show an actionable message. - A review store slice that merges findings by id (a re-publish refreshes an issue rather than listing it twice) and keeps a bounded run history. - Findings helpers: composite file keys matching reviewFileKey, staleness via the same per-file diff hash that drives review marks, and best-effort re-anchoring from the stored anchor text so a moved finding follows the code it was written about instead of pointing at whatever now occupies its line. - Hooks for backfill, resolve/dismiss/reopen with optimistic rollback, and sending a finding to the agent as follow-up context without changing its status — handing a finding to an agent is not the human accepting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): render review findings inline in the Changes diff Findings now appear where the code is, in the same annotation surface as anchored review comments, with the run control that produces them. - ReviewFindingCard: severity chip, category, Markdown body, and the three advisory actions. A suggestion is labelled "not applied automatically" because it is display-only in this iteration. Resolved and dismissed findings collapse to one muted line with an Undo, so a reviewed diff does not stay cluttered while the human can still change their mind. - A review-finding annotation type threaded through the diff viewer, anchored from the same per-file diff hash the backend stamped. A finding whose diff moved is re-anchored from its stored text when that text survives, and otherwise reported as unanchored rather than placed against unrelated code. - Review changes control with idle / running / cancel / failed states, and an inline Settings link for the one failure the user can actually fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): make the review control reachable from the Review toolbar Mounts the Review changes control next to the walkthrough action and lets an inline finding wire its own actions. The finding card container now reads the active task and session from the store instead of taking four callbacks threaded through FileDiffViewer and DiffViewer; an inline finding annotation only exists when the active task has findings, so the store is always present and every other diff consumer is spared the plumbing. Staleness likewise hashes the diff the viewer already holds, which is already normalized, so it matches the hash the backend stamped without passing it down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): surface stale review findings above their file's diff A finding whose diff moved and whose anchor text is gone had nowhere to render, so it was silently dropped. It now appears in a collapsed per-file banner above the diff: the finding may still be valid, and placing it at its stored line would point at whatever code now occupies that line instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(review): mark the landed native code-review plan tasks done Tasks 01-09 (backend persistence through inline diff rendering) are implemented, tested, and committed. Tasks 10-13 — findings overview and step editor, mobile parity, E2E, and public docs — remain pending. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(review): cover the on-demand review path end to end Adds the mock-agent review responder and a Playwright spec, so both the happy path and the no-reviewer path are verified against a real backend. The mock agent answers any prompt carrying the review sentinel with fenced JSON, deriving its anchors from the prompt's own diff rather than hardcoding them, so findings land on files and lines the fixture really changed. It includes one deliberately malformed entry to exercise the rejected-entry path. Writing it caught an off-by-one shared by three diff walkers: splitting on "\n" leaves an empty element after a trailing newline, which carries no diff marker and was being counted as a context line, pushing new-side numbering one past the end of a hunk. Interior blank lines still count, since a whitespace-stripped blank context line legitimately appears as "". Also adds a listInferenceAgents helper: default_utility_agent_id needs the registered agent-type id, and an agent row UUID is rejected as not inference-capable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(review): close review-run races and stale client findings Addresses the three blockers and three suggestions from the PR #1957 automated reviews. Two independent reviewers found each blocker separately. - Launch claimed the task slot only after inserting the run row, so two concurrent launches both created a run and the loser returned a pending row with no goroutine behind it — a review that never finished until the next restart. The slot is now claimed before any DB write, and freed on every early return. - Cancelling only marked the row. The goroutine ran on the shared background context, finished inference, and its completion overwrote the cancelled status and published findings the user had declined. Runs now carry a cancel handle, the WS action routes through the runner so the live context is cancelled, CompleteRun/FailRun refuse to resurrect a terminal run, and a cancelled pass neither publishes nor re-reports itself as failed. - The backend deletes a superseded finding and inserts a replacement with a new id, but the publish event carried only the new one, so every open browser showed both at the same anchor until a reload. The event now names the superseded ids and the slice drops them. - Run() waited on the shared WaitGroup, blocking on every other task's review; it now waits on its own pass only. - createReviewSlice is typed over the `set` it actually uses, so call sites no longer pass superfluous arguments (CodeQL js/superfluous-trailing-arguments). - A GetAgentProfile fault no longer collapses into "profile not found", which would have told the user to configure an agent they already have. The cancel bug exposed a hole in the original tests: they asserted cancel could not override a terminal run, but never the reverse. Both directions are covered now, along with the concurrent-launch race, per-run waiting, and the supersede path on both sides of the wire. Test fakes now mirror the service's terminal guard rather than hiding it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
412 lines
15 KiB
Go
412 lines
15 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
// reviewRunColumns is the canonical column list for task_review_runs. Kept as a
|
|
// constant so every read path scans in the same order as writes.
|
|
const reviewRunColumns = `id, task_id, session_id, trigger, workflow_step_id, agent_id, model,
|
|
status, error_code, error_message, summary, finding_count, file_count, repository_count,
|
|
prompt_tokens, response_tokens, duration_ms, created_at, completed_at`
|
|
|
|
const reviewFindingColumns = `id, run_id, task_id, repository_id, repository_name, file_path,
|
|
start_line, end_line, side, severity, category, title, body, suggestion, anchor_text,
|
|
file_diff_hash, status, resolved_at, created_at, updated_at`
|
|
|
|
// restartCancelReason is recorded on runs that were still in flight when the
|
|
// backend stopped. In-flight review passes are never resumed (see the spec's
|
|
// Persistence guarantees), so the boot sweep closes them explicitly instead of
|
|
// leaving a run that looks alive forever.
|
|
const (
|
|
restartCancelReason = "interrupted by restart"
|
|
restartCancelCode = "review_interrupted"
|
|
)
|
|
|
|
// CreateTaskReviewRun inserts a review run, filling in ID, timestamp, status,
|
|
// and trigger defaults when the caller left them blank.
|
|
func (r *Repository) CreateTaskReviewRun(ctx context.Context, run *models.TaskReviewRun) error {
|
|
if run.ID == "" {
|
|
run.ID = uuid.New().String()
|
|
}
|
|
if run.CreatedAt.IsZero() {
|
|
run.CreatedAt = time.Now().UTC()
|
|
}
|
|
if run.Status == "" {
|
|
run.Status = models.ReviewRunPending
|
|
}
|
|
if run.Trigger == "" {
|
|
run.Trigger = models.ReviewTriggerManual
|
|
}
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO task_review_runs (`+reviewRunColumns+`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), run.ID, run.TaskID, run.SessionID, string(run.Trigger), run.WorkflowStepID, run.AgentID,
|
|
run.Model, string(run.Status), run.ErrorCode, run.ErrorMessage, run.Summary,
|
|
run.FindingCount, run.FileCount, run.RepositoryCount, run.PromptTokens,
|
|
run.ResponseTokens, run.DurationMs, run.CreatedAt, run.CompletedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create task review run: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateTaskReviewRun persists every mutable field of a run.
|
|
func (r *Repository) UpdateTaskReviewRun(ctx context.Context, run *models.TaskReviewRun) error {
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE task_review_runs SET
|
|
session_id = ?, agent_id = ?, model = ?, status = ?, error_code = ?,
|
|
error_message = ?, summary = ?, finding_count = ?, file_count = ?,
|
|
repository_count = ?, prompt_tokens = ?, response_tokens = ?,
|
|
duration_ms = ?, completed_at = ?
|
|
WHERE id = ?
|
|
`), run.SessionID, run.AgentID, run.Model, string(run.Status), run.ErrorCode,
|
|
run.ErrorMessage, run.Summary, run.FindingCount, run.FileCount, run.RepositoryCount,
|
|
run.PromptTokens, run.ResponseTokens, run.DurationMs, run.CompletedAt, run.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update task review run: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("%w: %s", models.ErrTaskReviewRunNotFound, run.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetTaskReviewRun retrieves a run by ID.
|
|
func (r *Repository) GetTaskReviewRun(ctx context.Context, runID string) (*models.TaskReviewRun, error) {
|
|
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
|
|
`SELECT `+reviewRunColumns+` FROM task_review_runs WHERE id = ?`), runID)
|
|
run, err := scanReviewRun(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: %s", models.ErrTaskReviewRunNotFound, runID)
|
|
}
|
|
return run, err
|
|
}
|
|
|
|
// ListTaskReviewRuns returns a task's runs, newest first, capped at limit.
|
|
func (r *Repository) ListTaskReviewRuns(ctx context.Context, taskID string, limit int) ([]*models.TaskReviewRun, error) {
|
|
if limit <= 0 {
|
|
limit = defaultReviewRunHistory
|
|
}
|
|
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(
|
|
`SELECT `+reviewRunColumns+` FROM task_review_runs
|
|
WHERE task_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`), taskID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list task review runs: %w", err)
|
|
}
|
|
return collectReviewRuns(rows)
|
|
}
|
|
|
|
// defaultReviewRunHistory bounds how much run history a task read returns.
|
|
const defaultReviewRunHistory = 20
|
|
|
|
// ListActiveTaskReviewRuns returns the task's pending/running runs, newest
|
|
// first. The in-flight guard uses this so a second review request rejoins the
|
|
// existing pass instead of starting a duplicate.
|
|
func (r *Repository) ListActiveTaskReviewRuns(ctx context.Context, taskID string) ([]*models.TaskReviewRun, error) {
|
|
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(
|
|
`SELECT `+reviewRunColumns+` FROM task_review_runs
|
|
WHERE task_id = ? AND status IN ('pending', 'running')
|
|
ORDER BY created_at DESC, id DESC`), taskID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list active task review runs: %w", err)
|
|
}
|
|
return collectReviewRuns(rows)
|
|
}
|
|
|
|
func collectReviewRuns(rows *sql.Rows) ([]*models.TaskReviewRun, error) {
|
|
defer func() { _ = rows.Close() }()
|
|
var runs []*models.TaskReviewRun
|
|
for rows.Next() {
|
|
run, err := scanReviewRun(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
runs = append(runs, run)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("failed to iterate task review runs: %w", err)
|
|
}
|
|
return runs, nil
|
|
}
|
|
|
|
// CancelInFlightTaskReviewRuns closes runs left pending/running by a previous
|
|
// process. Returns the number of runs cancelled.
|
|
func (r *Repository) CancelInFlightTaskReviewRuns(ctx context.Context) (int, error) {
|
|
now := time.Now().UTC()
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE task_review_runs
|
|
SET status = ?, error_code = ?, error_message = ?, completed_at = ?
|
|
WHERE status IN ('pending', 'running')
|
|
`), string(models.ReviewRunCancelled), restartCancelCode, restartCancelReason, now)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to cancel in-flight task review runs: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
return int(rows), nil
|
|
}
|
|
|
|
// CreateTaskReviewFindings inserts findings in a single transaction so a
|
|
// partially-anchored review is never visible to a reader.
|
|
func (r *Repository) CreateTaskReviewFindings(ctx context.Context, findings []*models.TaskReviewFinding) error {
|
|
if len(findings) == 0 {
|
|
return nil
|
|
}
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin task review findings tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
now := time.Now().UTC()
|
|
stmt := tx.Rebind(`INSERT INTO task_review_findings (` + reviewFindingColumns + `)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
for _, f := range findings {
|
|
applyFindingDefaults(f, now)
|
|
if _, execErr := tx.ExecContext(ctx, stmt,
|
|
f.ID, f.RunID, f.TaskID, f.RepositoryID, f.RepositoryName, f.FilePath,
|
|
f.StartLine, f.EndLine, f.Side, string(f.Severity), f.Category, f.Title,
|
|
f.Body, f.Suggestion, f.AnchorText, f.FileDiffHash, string(f.Status),
|
|
f.ResolvedAt, f.CreatedAt, f.UpdatedAt,
|
|
); execErr != nil {
|
|
return fmt.Errorf("failed to insert task review finding: %w", execErr)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("failed to commit task review findings: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyFindingDefaults(f *models.TaskReviewFinding, now time.Time) {
|
|
if f.ID == "" {
|
|
f.ID = uuid.New().String()
|
|
}
|
|
if f.CreatedAt.IsZero() {
|
|
f.CreatedAt = now
|
|
}
|
|
f.UpdatedAt = now
|
|
if f.Status == "" {
|
|
f.Status = models.ReviewFindingOpen
|
|
}
|
|
if f.Side == "" {
|
|
f.Side = models.ReviewSideAdditions
|
|
}
|
|
}
|
|
|
|
// ListTaskReviewFindings returns every finding for a task, ordered for stable
|
|
// rendering: repository, then file, then line.
|
|
func (r *Repository) ListTaskReviewFindings(ctx context.Context, taskID string) ([]*models.TaskReviewFinding, error) {
|
|
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(
|
|
`SELECT `+reviewFindingColumns+` FROM task_review_findings
|
|
WHERE task_id = ?
|
|
ORDER BY repository_name, file_path, start_line, id`), taskID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list task review findings: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var findings []*models.TaskReviewFinding
|
|
for rows.Next() {
|
|
f, scanErr := scanReviewFinding(rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
findings = append(findings, f)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("failed to iterate task review findings: %w", err)
|
|
}
|
|
return findings, nil
|
|
}
|
|
|
|
// GetTaskReviewFinding retrieves a single finding by ID.
|
|
func (r *Repository) GetTaskReviewFinding(ctx context.Context, findingID string) (*models.TaskReviewFinding, error) {
|
|
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
|
|
`SELECT `+reviewFindingColumns+` FROM task_review_findings WHERE id = ?`), findingID)
|
|
f, err := scanReviewFinding(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: %s", models.ErrTaskReviewFindingNotFound, findingID)
|
|
}
|
|
return f, err
|
|
}
|
|
|
|
// UpdateTaskReviewFindingStatus sets a finding's status and resolved_at.
|
|
func (r *Repository) UpdateTaskReviewFindingStatus(ctx context.Context, findingID string, status models.ReviewFindingStatus, resolvedAt *time.Time) error {
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE task_review_findings SET status = ?, resolved_at = ?, updated_at = ? WHERE id = ?
|
|
`), string(status), resolvedAt, time.Now().UTC(), findingID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update task review finding status: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("%w: %s", models.ErrTaskReviewFindingNotFound, findingID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteSupersededTaskReviewFindings removes still-open findings from earlier
|
|
// runs that anchor to the same place with the same title as one of keys. A
|
|
// re-review therefore refreshes an issue instead of listing it twice, while
|
|
// findings the human already resolved or dismissed stay untouched.
|
|
func (r *Repository) DeleteSupersededTaskReviewFindings(ctx context.Context, taskID, runID string, keys []models.ReviewFindingKey) ([]string, error) {
|
|
if len(keys) == 0 {
|
|
return nil, nil
|
|
}
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to begin supersede tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
// RETURNING id rather than a bare row count: a connected client holds the
|
|
// old findings in memory and needs the exact ids to drop, otherwise a
|
|
// re-review shows the superseded finding alongside its replacement.
|
|
// One statement per key keeps the SQL simple and stays well inside SQLite's
|
|
// bound-parameter limit even for a large review.
|
|
stmt := tx.Rebind(`DELETE FROM task_review_findings
|
|
WHERE task_id = ? AND run_id != ? AND status = ?
|
|
AND repository_name = ? AND file_path = ?
|
|
AND start_line = ? AND end_line = ? AND title = ?
|
|
RETURNING id`)
|
|
var deleted []string
|
|
for _, k := range keys {
|
|
rows, execErr := tx.QueryContext(ctx, stmt, taskID, runID,
|
|
string(models.ReviewFindingOpen), k.RepositoryName, k.FilePath,
|
|
k.StartLine, k.EndLine, k.Title)
|
|
if execErr != nil {
|
|
return nil, fmt.Errorf("failed to delete superseded task review finding: %w", execErr)
|
|
}
|
|
for rows.Next() {
|
|
var id string
|
|
if scanErr := rows.Scan(&id); scanErr != nil {
|
|
_ = rows.Close()
|
|
return nil, fmt.Errorf("failed to scan superseded finding id: %w", scanErr)
|
|
}
|
|
deleted = append(deleted, id)
|
|
}
|
|
if rowsErr := rows.Err(); rowsErr != nil {
|
|
_ = rows.Close()
|
|
return nil, fmt.Errorf("failed to iterate superseded finding ids: %w", rowsErr)
|
|
}
|
|
_ = rows.Close()
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("failed to commit supersede tx: %w", err)
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// DeleteTaskReviewByTask removes every run and finding for a task. Findings go
|
|
// first so the run FK never dangles on a connection without enforced FKs.
|
|
func (r *Repository) DeleteTaskReviewByTask(ctx context.Context, taskID string) error {
|
|
statements := []string{
|
|
`DELETE FROM task_review_findings WHERE task_id = ?`,
|
|
`DELETE FROM task_review_runs WHERE task_id = ?`,
|
|
}
|
|
for _, stmt := range statements {
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(stmt), taskID); err != nil {
|
|
return fmt.Errorf("failed to delete task review state: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTaskReviewByWorkspace removes review state for every task in a
|
|
// workspace. The worker-scoped E2E reset needs this: task-scoped entities must
|
|
// be cleared before the tasks themselves are deleted.
|
|
func (r *Repository) DeleteTaskReviewByWorkspace(ctx context.Context, workspaceID string) error {
|
|
statements := []string{
|
|
`DELETE FROM task_review_findings WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = ?)`,
|
|
`DELETE FROM task_review_runs WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = ?)`,
|
|
}
|
|
for _, stmt := range statements {
|
|
if _, err := r.db.ExecContext(ctx, r.db.Rebind(stmt), workspaceID); err != nil {
|
|
return fmt.Errorf("failed to delete workspace task review state: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reviewRowScanner is satisfied by both *sql.Row and *sql.Rows.
|
|
type reviewRowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanReviewRun(s reviewRowScanner) (*models.TaskReviewRun, error) {
|
|
run := &models.TaskReviewRun{}
|
|
var trigger, status string
|
|
var completedAt sql.NullTime
|
|
err := s.Scan(&run.ID, &run.TaskID, &run.SessionID, &trigger, &run.WorkflowStepID,
|
|
&run.AgentID, &run.Model, &status, &run.ErrorCode, &run.ErrorMessage, &run.Summary,
|
|
&run.FindingCount, &run.FileCount, &run.RepositoryCount, &run.PromptTokens,
|
|
&run.ResponseTokens, &run.DurationMs, &run.CreatedAt, &completedAt)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("failed to scan task review run: %w", err)
|
|
}
|
|
run.Trigger = models.ReviewRunTrigger(trigger)
|
|
run.Status = models.ReviewRunStatus(status)
|
|
if completedAt.Valid {
|
|
t := completedAt.Time.UTC()
|
|
run.CompletedAt = &t
|
|
}
|
|
return run, nil
|
|
}
|
|
|
|
func scanReviewFinding(s reviewRowScanner) (*models.TaskReviewFinding, error) {
|
|
f := &models.TaskReviewFinding{}
|
|
var severity, status string
|
|
var resolvedAt sql.NullTime
|
|
err := s.Scan(&f.ID, &f.RunID, &f.TaskID, &f.RepositoryID, &f.RepositoryName, &f.FilePath,
|
|
&f.StartLine, &f.EndLine, &f.Side, &severity, &f.Category, &f.Title, &f.Body,
|
|
&f.Suggestion, &f.AnchorText, &f.FileDiffHash, &status, &resolvedAt,
|
|
&f.CreatedAt, &f.UpdatedAt)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("failed to scan task review finding: %w", err)
|
|
}
|
|
f.Severity = models.ReviewSeverity(severity)
|
|
f.Status = models.ReviewFindingStatus(status)
|
|
if resolvedAt.Valid {
|
|
t := resolvedAt.Time.UTC()
|
|
f.ResolvedAt = &t
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// SupersedeKeysFor builds the deduplicated supersede key set for a batch of
|
|
// findings, so the delete pass runs one statement per distinct anchor.
|
|
func SupersedeKeysFor(findings []*models.TaskReviewFinding) []models.ReviewFindingKey {
|
|
seen := make(map[string]struct{}, len(findings))
|
|
keys := make([]models.ReviewFindingKey, 0, len(findings))
|
|
for _, f := range findings {
|
|
k := f.SupersedeKey()
|
|
id := strings.Join([]string{
|
|
k.RepositoryName, k.FilePath,
|
|
fmt.Sprint(k.StartLine), fmt.Sprint(k.EndLine), k.Title,
|
|
}, "\x00")
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|