diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index c5391fe197..c1af4a293c 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -137,6 +137,10 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit Launcher: reviewcore.NewLauncher(reviewers, runtime), }) reviewSvc := reviewsvc.New(reviewEngine, store, reviewsvc.WithLifecycleReducer(lcm)) + lcm.SetReviewTrigger(func(ctx context.Context, id domain.SessionID) error { + _, err := reviewSvc.Trigger(ctx, id) + return err + }) return sessionSvc, reviewSvc, mgr, nil } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 738a9dcef6..5b03bfbb62 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1863,6 +1863,8 @@ components: type: integer prUrl: type: string + previousRun: + $ref: '#/components/schemas/ReviewRun' status: enum: - needs_review diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 6212d8eb79..05af289a22 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -23,6 +23,8 @@ type sessionStore interface { // when no open PR remains and at least one merged) and to suppress // merge-conflict nudges on PRs stacked behind an open parent. ListPRsBySession(ctx context.Context, id domain.SessionID) ([]domain.PullRequest, error) + GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error) + ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) // GetPRLastNudgeSignature / UpdatePRLastNudgeSignature persist the // reaction-dedup map so nudges survive a daemon restart. GetPRLastNudgeSignature(ctx context.Context, prURL string) (string, error) @@ -34,6 +36,8 @@ type notificationSink interface { Notify(ctx context.Context, intent ports.NotificationIntent) error } +type reviewTriggerFunc func(context.Context, domain.SessionID) error + // Option customizes a Manager. type Option func(*Manager) @@ -47,6 +51,12 @@ func WithTelemetry(sink ports.EventSink) Option { return func(m *Manager) { m.telemetry = sink } } +// SetReviewTrigger wires the review service after daemon construction has built +// both lifecycle and review components. +func (m *Manager) SetReviewTrigger(trigger reviewTriggerFunc) { + m.reviewTrigger = trigger +} + // Manager reduces runtime, activity, spawn, and termination observations into durable session facts. // It also owns agent nudges caused by PR observations, including merge-conflict, CI-failure, and review-feedback prompts. type Manager struct { @@ -54,11 +64,12 @@ type Manager struct { messenger ports.AgentMessenger notifications notificationSink - mu sync.Mutex - window time.Duration - clock func() time.Time - react reactionState - telemetry ports.EventSink + mu sync.Mutex + window time.Duration + clock func() time.Time + react reactionState + telemetry ports.EventSink + reviewTrigger reviewTriggerFunc } // New builds a Lifecycle Manager over the session store it writes and the messenger it uses for agent nudges. diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index c8526c4661..72c3347ee0 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -16,6 +16,8 @@ var ctx = context.Background() type fakeStore struct { sessions map[domain.SessionID]domain.SessionRecord prs map[domain.SessionID][]domain.PullRequest + review *domain.Review + reviewRuns []domain.ReviewRun signatures map[string]string signatureWriteErr error @@ -35,6 +37,17 @@ func (f *fakeStore) ListPRsBySession(_ context.Context, id domain.SessionID) ([] return f.prs[id], nil } +func (f *fakeStore) GetReviewBySession(_ context.Context, _ domain.SessionID) (domain.Review, bool, error) { + if f.review == nil { + return domain.Review{}, false, nil + } + return *f.review, true, nil +} + +func (f *fakeStore) ListReviewRunsBySession(_ context.Context, _ domain.SessionID) ([]domain.ReviewRun, error) { + return append([]domain.ReviewRun(nil), f.reviewRuns...), nil +} + func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error { f.sessions[rec.ID] = rec return nil @@ -271,6 +284,68 @@ func TestPRObservation_ReviewNudgeSanitizesCommentControlChars(t *testing.T) { } } +func TestPRObservation_AutoTriggersReviewWhenHeadAdvances(t *testing.T) { + m, st, _ := newManager() + st.sessions["mer-1"] = working("mer-1") + st.prs["mer-1"] = []domain.PullRequest{{URL: "pr1", HeadSHA: "sha2"}} + st.review = &domain.Review{ID: "review-1", SessionID: "mer-1"} + st.reviewRuns = []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested}} + var triggered []domain.SessionID + m.SetReviewTrigger(func(_ context.Context, id domain.SessionID) error { + triggered = append(triggered, id) + return nil + }) + + if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: true, URL: "pr1"}); err != nil { + t.Fatal(err) + } + if len(triggered) != 1 || triggered[0] != "mer-1" { + t.Fatalf("triggered = %#v, want mer-1", triggered) + } +} + +func TestPRObservation_AutoReviewSkipsCurrentRunningHead(t *testing.T) { + m, st, _ := newManager() + st.sessions["mer-1"] = working("mer-1") + st.prs["mer-1"] = []domain.PullRequest{{URL: "pr1", HeadSHA: "sha2"}} + st.review = &domain.Review{ID: "review-1", SessionID: "mer-1"} + st.reviewRuns = []domain.ReviewRun{ + {ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested}, + {ID: "run-2", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha2", Status: domain.ReviewRunRunning}, + } + calls := 0 + m.SetReviewTrigger(func(context.Context, domain.SessionID) error { + calls++ + return nil + }) + + if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: true, URL: "pr1"}); err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatalf("trigger calls = %d, want 0", calls) + } +} + +func TestPRObservation_AutoReviewRequiresReviewRow(t *testing.T) { + m, st, _ := newManager() + st.sessions["mer-1"] = working("mer-1") + st.prs["mer-1"] = []domain.PullRequest{{URL: "pr1", HeadSHA: "sha2"}} + st.reviewRuns = []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved}} + calls := 0 + m.SetReviewTrigger(func(context.Context, domain.SessionID) error { + calls++ + return nil + }) + + if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: true, URL: "pr1"}); err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatalf("trigger calls = %d, want 0", calls) + } +} + func TestSCMObservationProjectsToExistingPRReactions(t *testing.T) { m, st, msg := newManager() st.sessions["mer-1"] = working("mer-1") diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 3b115afe2f..59a7d30607 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -146,6 +146,9 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput { return nil } + if err := m.autoTriggerReviewForHeadAdvance(ctx, id, o.URL); err != nil { + return err + } if o.CI == domain.CIFailing { for _, ch := range o.Checks { if ch.Status == domain.PRCheckFailed { @@ -189,6 +192,50 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o return nil } +func (m *Manager) autoTriggerReviewForHeadAdvance(ctx context.Context, id domain.SessionID, prURL string) error { + if m.reviewTrigger == nil || prURL == "" { + return nil + } + if _, ok, err := m.store.GetReviewBySession(ctx, id); err != nil || !ok { + return err + } + prs, err := m.store.ListPRsBySession(ctx, id) + if err != nil { + return err + } + var headSHA string + for _, pr := range prs { + if pr.URL == prURL { + headSHA = pr.HeadSHA + break + } + } + if headSHA == "" { + return nil + } + runs, err := m.store.ListReviewRunsBySession(ctx, id) + if err != nil { + return err + } + hasPreviousRun := false + for _, run := range runs { + if run.PRURL != prURL { + continue + } + if run.TargetSHA == headSHA { + if run.Status == domain.ReviewRunRunning { + return nil + } + return nil + } + hasPreviousRun = true + } + if !hasPreviousRun { + return nil + } + return m.reviewTrigger(ctx, id) +} + // ApplyReviewResult reacts to a completed AO-internal review pass after the // review service has persisted the run result. It mirrors ApplyPRObservation: // no change_log reads, no review_run writes, only lifecycle side effects. diff --git a/backend/internal/review/planner.go b/backend/internal/review/planner.go index a2cbdd6fa5..b9c2370fba 100644 --- a/backend/internal/review/planner.go +++ b/backend/internal/review/planner.go @@ -24,12 +24,13 @@ const ( // PRReviewState is one PR-scoped review decision for a worker session. type PRReviewState struct { - PRURL string `json:"prUrl"` - PRNumber int `json:"prNumber"` - Title string `json:"title"` - TargetSHA string `json:"targetSha"` - Status StateStatus `json:"status" enum:"needs_review,running,up_to_date,changes_requested,ineligible"` - LatestRun *domain.ReviewRun `json:"latestRun,omitempty"` + PRURL string `json:"prUrl"` + PRNumber int `json:"prNumber"` + Title string `json:"title"` + TargetSHA string `json:"targetSha"` + Status StateStatus `json:"status" enum:"needs_review,running,up_to_date,changes_requested,ineligible"` + LatestRun *domain.ReviewRun `json:"latestRun,omitempty"` + PreviousRun *domain.ReviewRun `json:"previousRun,omitempty"` } // Plan computes per-PR review work from the currently observed PRs and existing @@ -37,6 +38,7 @@ type PRReviewState struct { // the same eligibility/status rules. func Plan(prs []domain.PullRequest, runs []domain.ReviewRun) []PRReviewState { latest := latestRunsByPRAndSHA(runs) + latestCompletedByPR := latestCompletedRunsByPR(runs) reviews := make([]PRReviewState, 0, len(prs)) for _, pr := range prs { review := PRReviewState{ @@ -46,6 +48,9 @@ func Plan(prs []domain.PullRequest, runs []domain.ReviewRun) []PRReviewState { TargetSHA: pr.HeadSHA, Status: ReviewStateNeedsReview, } + if run, ok := latestCompletedByPR[review.PRURL]; ok && run.TargetSHA != review.TargetSHA { + review.PreviousRun = &run + } if pr.URL == "" || pr.HeadSHA == "" || pr.Draft || pr.Merged || pr.Closed { review.Status = ReviewStateIneligible if run, ok := latest[review.PRURL+"\x00"+review.TargetSHA]; ok { @@ -93,3 +98,19 @@ func latestRunsByPRAndSHA(runs []domain.ReviewRun) map[string]domain.ReviewRun { } return latest } + +func latestCompletedRunsByPR(runs []domain.ReviewRun) map[string]domain.ReviewRun { + latest := make(map[string]domain.ReviewRun) + for _, run := range runs { + if run.PRURL == "" || run.TargetSHA == "" { + continue + } + if run.Verdict != domain.VerdictApproved && run.Verdict != domain.VerdictChangesRequested { + continue + } + if existing, ok := latest[run.PRURL]; !ok || run.CreatedAt.After(existing.CreatedAt) { + latest[run.PRURL] = run + } + } + return latest +} diff --git a/backend/internal/review/planner_test.go b/backend/internal/review/planner_test.go index 9b655f2a30..86b6304e36 100644 --- a/backend/internal/review/planner_test.go +++ b/backend/internal/review/planner_test.go @@ -48,6 +48,20 @@ func TestPlanStatuses(t *testing.T) { } } +func TestPlanIncludesPreviousCompletedRunForAdvancedHead(t *testing.T) { + now := time.Unix(100, 0).UTC() + got := Plan([]domain.PullRequest{planPR("pr1", 1, "sha3")}, []domain.ReviewRun{ + {ID: "run-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested, CreatedAt: now}, + {ID: "run-2", PRURL: "pr1", TargetSHA: "sha2", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved, CreatedAt: now.Add(time.Second)}, + }) + if len(got) != 1 { + t.Fatalf("review states = %d, want 1", len(got)) + } + if got[0].PreviousRun == nil || got[0].PreviousRun.ID != "run-2" { + t.Fatalf("previous run = %+v, want latest completed run-2", got[0].PreviousRun) + } +} + func planPR(url string, n int, sha string) domain.PullRequest { return domain.PullRequest{URL: url, Number: n, HeadSHA: sha} } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8a3b89818b..a43781d583 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -652,6 +652,7 @@ export interface components { latestRun?: components["schemas"]["ReviewRun"]; prNumber: number; prUrl: string; + previousRun?: components["schemas"]["ReviewRun"]; /** @enum {string} */ status: "needs_review" | "running" | "up_to_date" | "changes_requested" | "ineligible"; targetSha: string; diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index e7ae9d64db..df3584548b 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -545,6 +545,7 @@ function ReviewPanel({ function ReviewStateRow({ reviewState }: { reviewState: PRReviewState }) { const verdict = reviewVerdict(reviewState); + const previousVerdict = previousReviewVerdict(reviewState); const title = reviewState.title?.trim() || `PR #${reviewState.prNumber}`; return (
#{reviewState.prNumber}
- {verdict.label} + + {verdict.label} + {previousVerdict ? ( + + Previous: {previousVerdict.label} + + ) : null} + ); } @@ -610,6 +618,21 @@ function reviewVerdict(reviewState: PRReviewState): { return { label: "Not run", tone: "neutral" }; } +function previousReviewVerdict(reviewState: PRReviewState): { + label: string; + tone: "success" | "danger"; +} | null { + if (reviewState.status !== "running") return null; + switch (reviewState.previousRun?.verdict) { + case "approved": + return { label: "Approved", tone: "success" }; + case "changes_requested": + return { label: "Changes requested", tone: "danger" }; + default: + return null; + } +} + function reviewSessionRunAction(reviewStates: PRReviewState[], isTriggering: boolean): string { if (isTriggering || reviewStates.some((reviewState) => reviewState.status === "running")) { return "Reviewing..."; diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css index 10c3b4f516..049c1c120d 100644 --- a/frontend/src/renderer/styles.css +++ b/frontend/src/renderer/styles.css @@ -1283,6 +1283,14 @@ body.is-resizing-x [data-slot="sidebar-container"] { color: var(--fg-muted); } +.reviewer-row__verdict-group { + display: inline-flex; + min-width: max-content; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + .reviewer-row__verdict--running { color: var(--orange); } @@ -1295,6 +1303,21 @@ body.is-resizing-x [data-slot="sidebar-container"] { color: var(--red); } +.reviewer-row__previous { + white-space: nowrap; + font-size: 10px; + font-weight: 600; + color: var(--fg-passive); +} + +.reviewer-row__previous--success { + color: color-mix(in srgb, var(--green) 78%, var(--fg-passive)); +} + +.reviewer-row__previous--danger { + color: color-mix(in srgb, var(--red) 78%, var(--fg-passive)); +} + @media (max-width: 680px) { .reviewer-row { grid-template-columns: minmax(0, 1fr) auto;