diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index b785b7d..3325442 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -5,20 +5,21 @@ pub mod shell; -use std::path::PathBuf; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tauri::State; use cockpit_core::adapters::agent::SpawnConfig; -use cockpit_core::adapters::github::{self, MirrorResult}; +use cockpit_core::adapters::github::{self, MirrorResult, ReviewEvent, SubmitReviewResult}; use cockpit_core::adapters::linear; use cockpit_core::config::Config; use cockpit_core::gate::Gated; use cockpit_core::kickoff::{self, KickoffResult}; use cockpit_core::model::{ - AgentMode, Anchor, Artifact, Comment, CommentId, CommentOrigin, DiffData, GateState, PrRef, - Project, ProjectId, ProjectPlan, ProjectRef, Review, + AgentMode, Anchor, Artifact, Comment, CommentId, CommentOrigin, DiffData, DiffSide, GateState, + PlanDoc, PrRef, Project, ProjectId, ProjectPlan, ProjectRef, Review, ReviewSource, }; use cockpit_core::plan_parser; use cockpit_core::restack; @@ -26,12 +27,6 @@ use cockpit_core::restack; use crate::error::CommandError; use crate::state::AppState; -/// Return the cockpit-core crate version — trivial command to prove IPC round-trip. -#[tauri::command] -pub fn get_version() -> String { - cockpit_core::VERSION.to_string() -} - /// List all reviews currently in the store. #[tauri::command] pub fn list_reviews(state: State<'_, Arc>) -> Result, CommandError> { @@ -45,7 +40,9 @@ pub fn get_frontier(state: State<'_, Arc>) -> Result, Comm .reviews .list() .into_iter() - .filter(|r| !r.stale && r.gate_state != GateState::Approved) + .filter(|r| { + !r.stale && r.gate_state != GateState::Approved && r.gate_state != GateState::Merged + }) .collect(); Ok(frontier) } @@ -87,10 +84,74 @@ pub fn get_review_diff( Ok(review.diff.clone()) } +/// Return the interdiff for a review: the changes since the last dispatch (D10). +/// +/// Diffs the review's [`DispatchSnapshot::reviewed_sha`] against the current +/// HEAD so a re-reviewer sees only what the rework changed, not the whole PR +/// again. Requires a recorded dispatch snapshot (typed error otherwise). +/// +/// When the review has a cockpit-managed worktree present on disk, the diff is +/// computed locally with +/// [`git::diff_range`](cockpit_core::adapters::git::diff_range) (off the async +/// runtime, since `git2` is blocking). Otherwise it falls back to the GitHub +/// compare API between the snapshot SHA and the review's `head_sha` (requiring a +/// `repo_slug`). Returned in the same [`DiffData`] shape as +/// [`get_review_diff`]. +#[tauri::command] +pub async fn get_interdiff( + state: State<'_, Arc>, + pr: String, +) -> Result { + let pr_ref = PrRef::new(&pr); + let review = state.reviews.get(&pr_ref).ok_or_else(|| CommandError { + message: format!("Review not found: {pr}"), + })?; + + let snapshot = review.dispatch_snapshot.clone().ok_or_else(|| CommandError { + message: format!( + "Review {pr} has no dispatch snapshot; request changes first to establish an interdiff baseline" + ), + })?; + + let config = Config::load()?; + let repo_path = config + .repo_path + .clone() + .unwrap_or_else(|| PathBuf::from(".")); + + // Prefer a truthful local diff when a managed worktree exists on disk. + if is_managed_worktree(&review.worktree, &repo_path) && review.worktree.exists() { + let worktree = review.worktree.clone(); + let from = snapshot.reviewed_sha.clone(); + let raw = tokio::task::spawn_blocking(move || { + cockpit_core::adapters::git::diff_range(&worktree, &from, "HEAD") + }) + .await + .map_err(|e| CommandError { + message: format!("interdiff task panicked: {e}"), + })? + .map_err(|e| CommandError { + message: format!("interdiff failed: {e}"), + })?; + return Ok(DiffData { raw }); + } + + // Fall back to GitHub compare for imported PRs with no local worktree. + let repo_slug = review.repo_slug.clone().ok_or_else(|| CommandError { + message: format!( + "Review {pr} has no local worktree and no repo slug; cannot compute an interdiff" + ), + })?; + let raw = github::compare(&repo_slug, &snapshot.reviewed_sha, &review.head_sha).await?; + Ok(DiffData { raw }) +} + /// Add an anchored comment to a review at the diff gate. /// /// Creates an ephemeral [`Comment`] with a `DiffLine` anchor and -/// appends it to the review's comment list. +/// appends it to the review's comment list. `side` selects which side of the +/// diff the range refers to; `None` defaults to [`DiffSide::New`] so existing +/// callers keep commenting on the post-change side unchanged (D12). #[tauri::command] pub fn add_comment( state: State<'_, Arc>, @@ -99,6 +160,7 @@ pub fn add_comment( line_start: u32, line_end: u32, body: String, + side: Option, ) -> Result { let pr_ref = PrRef::new(&pr); @@ -108,6 +170,7 @@ pub fn add_comment( anchor: Anchor::DiffLine { path: PathBuf::from(&file), range: (line_start, line_end), + side: side.unwrap_or(DiffSide::New), }, body, origin: CommentOrigin::Local, @@ -144,6 +207,13 @@ pub async fn request_changes( let mut transition_err: Option = None; let found = state.reviews.update(&pr_ref, |review| { + // D10: snapshot the pre-rework head + the comments being dispatched + // *before* advancing the gate, so the audit record reflects exactly + // what this cycle asked for. Guarded to the states where the transition + // will succeed so a rejected transition never leaves a bogus snapshot. + if review.gate_state == GateState::InReview && !review.comments.is_empty() { + review.snapshot_dispatch(); + } if let Err(e) = review.request_changes() { transition_err = Some(e); } @@ -159,6 +229,13 @@ pub async fn request_changes( return Err(CommandError::from(e)); } + // D7: dispatching rework makes this review's descendants stale — once its + // HEAD advances they sit on an old base and need a restack. Mark them at + // dispatch time (SPEC §7), immediately after the successful transition. + if let Some(id) = state.reviews.get(&pr_ref).map(|r| r.id) { + state.reviews.mark_descendants_stale(&id); + } + // Spawn the fixer agent via the shared Fix-loop path (no CI logs here). // Spawn failure is non-fatal — the gate transition already succeeded so we // return the Dispatched review regardless and log the spawn error. @@ -169,6 +246,50 @@ pub async fn request_changes( }) } +/// Maximum number of PR-body characters carried into a rework intent. +/// +/// A very long PR description would otherwise dominate the prompt; truncating +/// keeps the fixer focused while still giving it the leading context. +const MAX_INTENT_BODY_CHARS: usize = 8000; + +/// Compose the rework intent from a review's PR title, body, and issue ref. +/// +/// Lays out the parts as `` / `<body>` / `Issue: <ref>` separated by +/// blank lines, skipping any empty part so there are no stray blank lines. The +/// body is truncated to [`MAX_INTENT_BODY_CHARS`] characters on a `char` +/// boundary (D4). +fn compose_fix_intent(title: &str, body: &str, issue: &str) -> String { + let mut parts: Vec<String> = Vec::new(); + + let title = title.trim(); + if !title.is_empty() { + parts.push(title.to_string()); + } + + let body = truncate_on_char_boundary(body.trim(), MAX_INTENT_BODY_CHARS); + if !body.is_empty() { + parts.push(body.to_string()); + } + + let issue = issue.trim(); + if !issue.is_empty() { + parts.push(format!("Issue: {issue}")); + } + + parts.join("\n\n") +} + +/// Return the prefix of `s` holding at most `max_chars` characters. +/// +/// Slices on a `char` boundary (never mid-codepoint), so the result is always +/// valid UTF-8. +fn truncate_on_char_boundary(s: &str, max_chars: usize) -> &str { + match s.char_indices().nth(max_chars) { + Some((idx, _)) => &s[..idx], + None => s, + } +} + /// Dispatch the diff-gate Fix loop for a review already transitioned to /// `Dispatched`, spawning the fixer agent with a rework prompt. /// @@ -189,6 +310,23 @@ async fn dispatch_fix_agent( return; }; + // D12: ensure a usable worktree before spawning. An imported same-repo PR + // points at the user's shared checkout; materialize a dedicated branch + // worktree so the fixer commits on the PR branch and the HEAD-based outcome + // detection is truthful. Failure is non-fatal (Invariant 1): surface it and + // bail rather than spawning against the wrong tree. + let worktree = match ensure_worktree_for_review(state, pr_ref).await { + Ok(w) => w, + Err(e) => { + eprintln!("dispatch_fix_agent: ensure worktree failed: {e}"); + let error_event = cockpit_core::adapters::agent_stream::Event::Error { + message: format!("Preparing worktree failed: {e}"), + }; + crate::streaming::emit_agent_event(app_handle, pr, error_event); + return; + } + }; + // Load config once so the per-mode custom preamble and agent command are // both honored. A load failure is non-fatal — fall back to defaults (no // preamble, builtin command) so rework still dispatches. @@ -200,10 +338,22 @@ async fn dispatch_fix_agent( // (fall back to no skills — never block the loop). let skills = cockpit_core::skills::relevant_for_diff(&review.diff.raw); let artifact = Artifact::Diff(review.diff.clone()); + // Compose the rework intent from the PR title/body/issue so the fixer has + // the full context, not just the bare issue ref (D4). + let intent = compose_fix_intent(&review.title, &review.body, review.issue.as_str()); + // D8/SPEC §9: thread the project's approved plan into the rework prompt as + // the contract, but only for reviews whose project has an approved plan. + let approved_plan = review.project.as_ref().and_then(|pid| { + state + .projects + .plan(pid) + .filter(|p| p.gate_state == GateState::Approved) + .map(|p| p.doc) + }); let rework_input = cockpit_core::prompt::ReworkInput { - intent: review.issue.as_str(), + intent: &intent, custom_preamble: preamble.as_deref(), - approved_plan: None, + approved_plan: approved_plan.as_ref(), artifact: &artifact, comments: &review.comments, ci_failures, @@ -211,7 +361,7 @@ async fn dispatch_fix_agent( }; let assembled = cockpit_core::prompt::assemble_rework(&rework_input); - match try_spawn_agent(state, app_handle, pr, pr_ref, &review.worktree, &assembled).await { + match try_spawn_agent(state, app_handle, pr, pr_ref, &worktree, &assembled).await { Ok(agent_run) => { state.reviews.update(pr_ref, |r| { r.agent = Some(agent_run); @@ -219,11 +369,12 @@ async fn dispatch_fix_agent( } Err(e) => { eprintln!("dispatch_fix_agent: agent spawn failed: {e}"); - use tauri::Emitter; + // Surface the spawn failure to the agent panel, keyed by the + // review's PR ref so the frontend attributes it to the right object. let error_event = cockpit_core::adapters::agent_stream::Event::Error { message: format!("Agent spawn failed: {e}"), }; - let _ = app_handle.emit("agent-event", &error_event); + crate::streaming::emit_agent_event(app_handle, pr, error_event); } } } @@ -266,6 +417,90 @@ async fn try_spawn_agent( )) } +// --------------------------------------------------------------------------- +// Worktree materialization +// --------------------------------------------------------------------------- + +/// Whether `worktree` is a cockpit-managed worktree rather than the user's +/// shared repo checkout at `repo_path`. +/// +/// Managed worktrees live under the cockpit worktrees dir (kickoff reviews) or a +/// per-repo clone (cross-repo fix worktrees); either way they are never the +/// configured `repo_path`, which is where imported same-repo PRs point until a +/// fix materializes a dedicated worktree. +fn is_managed_worktree(worktree: &Path, repo_path: &Path) -> bool { + worktree != repo_path +} + +/// Ensure a review has a usable worktree on disk, materializing one for imported +/// PRs, and return its path (D12). +/// +/// Kickoff reviews (and previously-materialized ones) already live in a +/// cockpit-managed worktree — this returns it unchanged. A GitHub-imported +/// same-repo PR instead points at the user's shared repo checkout; its PR branch +/// is checked out into a dedicated worktree via +/// [`git::ensure_branch_checkout`](cockpit_core::adapters::git::ensure_branch_checkout), +/// recorded on the review, so rework and interdiff operate on the PR branch and +/// HEAD-based outcome detection is truthful. +/// +/// Shared by the [`ensure_review_worktree`] command and [`dispatch_fix_agent`]. +async fn ensure_worktree_for_review( + state: &AppState, + pr_ref: &PrRef, +) -> Result<PathBuf, CommandError> { + let review = state.reviews.get(pr_ref).ok_or_else(|| CommandError { + message: format!("Review not found: {pr_ref}"), + })?; + + let config = Config::load()?; + let repo_path = config + .repo_path + .clone() + .unwrap_or_else(|| PathBuf::from(".")); + + // Already a managed worktree: use it as-is. + if is_managed_worktree(&review.worktree, &repo_path) { + return Ok(review.worktree.clone()); + } + + // Imported same-repo PR pointing at the shared checkout: materialize a + // dedicated branch worktree and record it on the review. + let new_path = cockpit_core::adapters::git::ensure_branch_checkout( + &repo_path, + &review.branch, + review.repo_slug.as_deref(), + ) + .await + .map_err(|e| CommandError { + message: format!( + "failed to check out branch `{}` for {pr_ref}: {e}", + review.branch + ), + })?; + + state.reviews.update(pr_ref, |r| { + r.worktree = new_path.clone(); + }); + + Ok(new_path) +} + +/// Ensure a review has a checked-out worktree on disk, returning its path (D12). +/// +/// A no-op returning the existing path for kickoff reviews; for a GitHub-imported +/// same-repo PR it checks the PR branch out into a dedicated worktree (recorded +/// on the review) so rework and interdiff operate on the PR branch rather than +/// the user's shared checkout. This is an explicit, idempotent user action. +#[tauri::command] +pub async fn ensure_review_worktree( + state: State<'_, Arc<AppState>>, + pr: String, +) -> Result<String, CommandError> { + let pr_ref = PrRef::new(&pr); + let path = ensure_worktree_for_review(&state, &pr_ref).await?; + Ok(path.to_string_lossy().into_owned()) +} + // --------------------------------------------------------------------------- // Comment mirroring // --------------------------------------------------------------------------- @@ -295,6 +530,86 @@ pub async fn mirror_comments( Ok(result) } +/// Submit a GitHub PR review (approve / request changes / comment) carrying the +/// review's inline Local comments (D9). +/// +/// This is a guarded outward side effect (Invariant 5): it publishes to a public +/// GitHub thread and must only ever be invoked from an explicit user action in +/// the UI, never automatically or from agent output. +/// +/// Requires a `repo_slug` (typed error otherwise). The review's Local-origin +/// comments are submitted inline; [`github::submit_review`] pre-validates each +/// against the PR diff, recording any whose anchored line is not part of the +/// diff in [`SubmitReviewResult::skipped`] rather than failing the whole review. +/// +/// On success: +/// - `Approve` on a review-requested PR also advances the local gate to +/// `Approved` (opening from `Pending`/`Reworked` first, like [`approve_review`]). +/// No local agent is ever dispatched here. +/// - `RequestChanges`/`Comment` clear the Local comments that were actually +/// submitted (they now live on GitHub; keeping local copies would +/// double-report), leaving skipped ones in place so the user can fix and retry. +#[tauri::command] +pub async fn submit_github_review( + state: State<'_, Arc<AppState>>, + pr: String, + event: ReviewEvent, + body: Option<String>, +) -> Result<SubmitReviewResult, CommandError> { + let pr_ref = PrRef::new(&pr); + let review = state.reviews.get(&pr_ref).ok_or_else(|| CommandError { + message: format!("Review not found: {pr}"), + })?; + + let repo_slug = review.repo_slug.clone().ok_or_else(|| CommandError { + message: format!("Review {pr} has no repo slug; cannot submit a GitHub review"), + })?; + let pr_number = pr_number_from_ref(&pr).ok_or_else(|| CommandError { + message: format!("Could not parse a PR number from: {pr}"), + })?; + + // `submit_review` filters to Local-origin comments and validates each against + // the diff, so pass the full comment list plus the review's diff. + let result = github::submit_review( + &repo_slug, + pr_number, + event, + &review.comments, + &body.unwrap_or_default(), + &review.diff.raw, + ) + .await?; + + match event { + ReviewEvent::Approve => { + // Mirror the GitHub approval locally only for review-requested PRs + // (cockpit's own authored PRs use approve_review + merge). Best-effort: + // the GitHub review already succeeded, so a local transition error is + // ignored rather than surfaced. + if review.source == ReviewSource::ReviewRequested { + state.reviews.update(&pr_ref, |r| { + if matches!(r.gate_state, GateState::Pending | GateState::Reworked) { + let _ = r.open(); + } + let _ = r.approve(); + }); + } + } + ReviewEvent::RequestChanges | ReviewEvent::Comment => { + // Clear the Local comments that were submitted; keep skipped ones so + // the user can fix and retry. Non-Local (GitHub-mirrored) comments + // are always kept. + let skipped: Vec<CommentId> = result.skipped.iter().map(|(id, _)| id.clone()).collect(); + state.reviews.update(&pr_ref, |r| { + r.comments + .retain(|c| c.origin != CommentOrigin::Local || skipped.contains(&c.id)); + }); + } + } + + Ok(result) +} + // --------------------------------------------------------------------------- // CI visibility + dispatch-to-fix // --------------------------------------------------------------------------- @@ -474,16 +789,24 @@ pub async fn fix_ci( // as a transition error below. let mut transition_err: Option<cockpit_core::gate::Error> = None; state.reviews.update(&pr_ref, |r| { - if r.gate_state == GateState::Reworked + // Auto-open from Pending/Reworked so the gate can dispatch. A failing + // open bails before we touch comments. + if matches!(r.gate_state, GateState::Pending | GateState::Reworked) && let Err(e) = r.open() { transition_err = Some(e); return; } - if r.gate_state == GateState::Pending - && let Err(e) = r.open() - { - transition_err = Some(e); + + // Guard: only mutate the review when it is in a state `request_changes` + // will accept (InReview). Pushing the synthetic comment first and only + // then discovering the transition is illegal would leave a stray CI + // comment behind, so bail here without mutating. + if r.gate_state != GateState::InReview { + transition_err = Some(cockpit_core::gate::Error::IllegalTransition { + from: r.gate_state, + event: "request_changes", + }); return; } @@ -496,17 +819,27 @@ pub async fn fix_ci( job logs; fix the failing checks." .to_string() }; + let comment_id = CommentId::new(format!("ci-{}", uuid::Uuid::new_v4())); r.comments.push(Comment { - id: CommentId::new(format!("ci-{}", uuid::Uuid::new_v4())), + id: comment_id.clone(), anchor: Anchor::DiffLine { path: PathBuf::from("CI"), range: (0, 0), + side: DiffSide::New, }, body: summary, origin: CommentOrigin::Local, }); + // D10: snapshot the pre-rework head + the synthetic CI comment being + // dispatched, before the gate advances to Dispatched. State is InReview + // by the guard above. + r.snapshot_dispatch(); + if let Err(e) = r.request_changes() { + // Roll back the synthetic comment so a rejected transition never + // leaves a stray CI comment on the review. + r.comments.retain(|c| c.id != comment_id); transition_err = Some(e); } }); @@ -515,6 +848,11 @@ pub async fn fix_ci( return Err(CommandError::from(e)); } + // D7: same as request_changes — dispatching rework staled the descendants. + if let Some(id) = state.reviews.get(&pr_ref).map(|r| r.id) { + state.reviews.mark_descendants_stale(&id); + } + // Reuse the shared Fix-loop spawn path, carrying the CI logs into the // rework prompt verbatim. let ci_arg = if ci_logs.trim().is_empty() { @@ -759,11 +1097,12 @@ pub async fn plan_request_changes( } Err(e) => { eprintln!("plan_request_changes: planner spawn failed: {e}"); - use tauri::Emitter; + // Surface the spawn failure to the agent panel, keyed by the + // project id so the frontend attributes it to the right plan. let error_event = cockpit_core::adapters::agent_stream::Event::Error { message: format!("Planner spawn failed: {e}"), }; - let _ = app_handle.emit("agent-event", &error_event); + crate::streaming::emit_agent_event(&app_handle, &project_id, error_event); } } @@ -828,6 +1167,7 @@ async fn spawn_plan_agent( #[tauri::command] pub async fn plan_approve( state: State<'_, Arc<AppState>>, + app_handle: tauri::AppHandle, project_id: String, ) -> Result<ProjectPlan, CommandError> { let id = ProjectId::new(&project_id); @@ -839,88 +1179,268 @@ pub async fn plan_approve( *slot = Some(approved); }); - // Fan out implementers for THIS project's frontier reviews only, scoped by - // the first-class ProjectId. The approval stands regardless; a fan-out - // failure is surfaced to the caller but does not roll back the - // (authoritative) gate transition. - fan_out_implementers(&state, &plan.project, &id).await?; + // D8: prepare worktrees synchronously (git2 is not `Send`), then fan out + // implementers on a background task so this command returns the Approved + // plan immediately instead of blocking until every implementer finishes. + // The approval already stands; a fan-out failure is surfaced only via the + // agent-event streams and never rolls back the (authoritative) gate + // transition (Invariant 1/5). + let state_arc: Arc<AppState> = state.inner().clone(); + spawn_background_fan_out(state_arc, &app_handle, &plan.project, &id, plan.doc.clone()); plan_for(&state, &id) } -/// Spawn implementer agents for a project's frontier reviews after approval. +/// Prepare worktrees and launch the background implementer fan-out for a +/// project's frontier reviews after plan approval (D8). /// -/// Loads the project's reviews from the store, selects the frontier (roots of -/// the stack), and runs [`kickoff::spawn_batch`] with the configured -/// concurrency bound. Updates each spawned review's `base_sha` and `agent` in -/// the store; the reviews stay `Pending`. -async fn fan_out_implementers( - state: &AppState, +/// Worktree creation needs the non-`Send` `git2::Repository`, so it runs +/// synchronously here (the repo is dropped before the task spawn). The bounded +/// agent fan-out then runs on a background task ([`run_fan_out`]) so the calling +/// command does not block. Every failure mode is non-fatal: the plan approval is +/// authoritative and already applied, so a prep failure is logged and surfaced +/// as a per-project agent-event rather than rolled back (Invariant 1/5). +fn spawn_background_fan_out( + state: Arc<AppState>, + app_handle: &tauri::AppHandle, project: &ProjectRef, project_id: &ProjectId, -) -> Result<(), CommandError> { - let config = Config::load()?; + approved_plan: PlanDoc, +) { + let config = match Config::load() { + Ok(c) => c, + Err(e) => { + eprintln!("plan_approve fan-out: config load failed: {e}"); + emit_fan_out_error(app_handle, project_id, &format!("config load failed: {e}")); + return; + } + }; let repo_path = config .repo_path .clone() .unwrap_or_else(|| PathBuf::from(".")); + // Implement-mode custom preamble, injected verbatim into every implementer + // prompt (builtin fallback when unset). + let implement_preamble = config + .agent_prompts + .for_mode(AgentMode::Implement) + .map(str::to_owned); // Collect this project's reviews, then narrow to the frontier (roots). let mut reviews = cockpit_core::store::reviews_by_project(&state.reviews, Some(project_id)); let frontier_ids = kickoff::select_frontier_reviews(&reviews); reviews.retain(|r| frontier_ids.contains(&r.id)); - if reviews.is_empty() { - // Nothing to build (e.g. plan-only project); approval already stands. - return Ok(()); + // Nothing to build (e.g. a plan-only project); approval already stands. + return; } - // Phase 1 (synchronous): prepare worktrees. `git2::Repository` is not - // `Send`, so it must not live across the `.await` below — scope it here so - // it is dropped before spawning. - // Implement-mode custom preamble, injected verbatim into every implementer - // prompt (builtin fallback when unset). - let implement_preamble = config - .agent_prompts - .for_mode(AgentMode::Implement) - .map(str::to_owned); - let prepared = { - let repo = git2::Repository::discover(&repo_path).map_err(|e| CommandError { - message: format!("could not open git repo at {}: {e}", repo_path.display()), - })?; - kickoff::prepare_batch_worktrees( + // Phase 1 (synchronous, non-Send git2): create the worktrees and record each + // review's base_sha. The prompts `prepare_batch_worktrees` builds carry no + // plan, so they are discarded — the per-review prompts are rebuilt below to + // thread the approved plan in. + { + let repo = match git2::Repository::discover(&repo_path) { + Ok(r) => r, + Err(e) => { + eprintln!("plan_approve fan-out: could not open git repo: {e}"); + emit_fan_out_error( + app_handle, + project_id, + &format!("could not open git repo: {e}"), + ); + return; + } + }; + if let Err(e) = kickoff::prepare_batch_worktrees( &mut reviews, &repo, project, implement_preamble.as_deref(), - ) - .map_err(CommandError::from)? - }; - - let spawn_config = SpawnConfig::from_config(&config); - let hook_url = format!("http://127.0.0.1:{}/hook/stop", config.hook_port); - - let kickoff_config = kickoff::KickoffConfig { - session_map: &state.sessions, - hook_url: &hook_url, - spawn_config: &spawn_config, - max_parallel_agents: config.max_parallel_agents, - }; - - // Phase 2 (async): bounded agent fan-out. No repo handle in scope. - kickoff::spawn_batch(&mut reviews, &prepared, &kickoff_config) - .await - .map_err(CommandError::from)?; + ) { + eprintln!("plan_approve fan-out: prepare worktrees failed: {e}"); + emit_fan_out_error( + app_handle, + project_id, + &format!("prepare worktrees failed: {e}"), + ); + return; + } + } - // Persist the spawned agents + base SHAs back into the store. + // Persist each review's base_sha now that its worktree exists. for review in &reviews { state.reviews.update(&review.pr, |r| { r.base_sha = review.base_sha.clone(); - r.agent = review.agent.clone(); }); } - Ok(()) + // Build the per-review implement prompt + spawn job, threading the approved + // plan into each (SPEC §9). Reuses kickoff's exact prompt text. + let jobs: Vec<(Review, cockpit_core::prompt::AssembledPrompt)> = reviews + .iter() + .map(|review| { + let prompt = kickoff::assemble_implement_prompt( + review, + project, + Some(&approved_plan), + implement_preamble.as_deref(), + ); + (review.clone(), prompt) + }) + .collect(); + + let spawn_config = SpawnConfig::from_config(&config); + let hook_url = format!("http://127.0.0.1:{}/hook/stop", config.hook_port); + let max_parallel = config.max_parallel_agents.max(1) as usize; + let app_handle = app_handle.clone(); + + // Phase 2 (async, no repo handle in scope): bounded agent fan-out. + tauri::async_runtime::spawn(async move { + run_fan_out( + state, + app_handle, + jobs, + max_parallel, + spawn_config, + hook_url, + ) + .await; + }); +} + +/// Run the bounded implementer fan-out in waves of at most `max_parallel` agents. +/// +/// Each review is spawned via the same streaming path the diff-gate Fix loop +/// uses (`spawn_agent` + `start_stream_forwarding`), keyed by its PR ref, with +/// its running agent recorded in the store as it spawns. A wave is not started +/// until the previous wave's agents have completed — completions arrive on the +/// broadcast channel that `start_stream_forwarding` fires when each process +/// exits — which is the concurrency bound. A spawn failure is surfaced as a +/// per-review agent-event and simply not awaited (Invariant 1). The reviews stay +/// `Pending`; nothing auto-advances (Invariant 5). +async fn run_fan_out( + state: Arc<AppState>, + app_handle: tauri::AppHandle, + jobs: Vec<(Review, cockpit_core::prompt::AssembledPrompt)>, + max_parallel: usize, + spawn_config: SpawnConfig, + hook_url: String, +) { + use tokio::sync::broadcast::error::RecvError; + + // Subscribe before spawning so no completion can be missed. + let mut completions = state.completion_tx.subscribe(); + + for wave in jobs.chunks(max_parallel) { + // PR refs we must see complete before starting the next wave. + let mut pending: HashSet<String> = HashSet::new(); + + for (review, prompt) in wave { + let spawn_result = cockpit_core::adapters::agent::spawn_agent( + &review.worktree, + prompt, + AgentMode::Implement, + review.pr.as_str(), + &state.sessions, + &hook_url, + &spawn_config, + ) + .await; + + match spawn_result { + Ok(spawn_result) => { + let stream_ctx = crate::streaming::StreamContext { + object_id: review.pr.as_str().to_string(), + mode: AgentMode::Implement, + completion_tx: state.completion_tx.clone(), + }; + let run = crate::streaming::start_stream_forwarding( + spawn_result, + app_handle.clone(), + stream_ctx, + ); + state.reviews.update(&review.pr, |r| { + r.agent = Some(run); + }); + pending.insert(review.pr.as_str().to_string()); + } + Err(e) => { + eprintln!("plan_approve fan-out: spawn failed for {}: {e}", review.pr); + let error_event = cockpit_core::adapters::agent_stream::Event::Error { + message: format!("Implementer spawn failed: {e}"), + }; + crate::streaming::emit_agent_event( + &app_handle, + review.pr.as_str(), + error_event, + ); + } + } + } + + // Wait for this wave's implementers to finish before starting the next. + // + // Completions arrive on a shared, bounded broadcast that can drop + // messages: a `Lagged` error means we already missed at least one, and a + // completion can even be missed with no detected lag (another subscriber + // drains its slot first). Either way a PR could sit in `pending` forever + // and stall every later wave. The store — not the channel — is the + // durable source of truth for "still building": the global completion + // consumer clears a review's `agent` handle once it applies the + // completion. So on detected lag reconcile `pending` against the store, + // and add a periodic safety tick so even a silently-missed completion + // eventually unblocks the wave. + let mut safety_tick = tokio::time::interval(std::time::Duration::from_secs(15)); + // The first tick fires immediately; skip it so we do not reconcile before + // any agent has had a chance to run. + safety_tick.tick().await; + + while !pending.is_empty() { + tokio::select! { + recv = completions.recv() => match recv { + Ok(event) => { + if event.mode == AgentMode::Implement { + pending.remove(&event.object_id); + } + } + // Detected lag: at least one completion was dropped. + // Reconcile so a lost completion cannot pin a PR in `pending`. + Err(RecvError::Lagged(_)) => reconcile_pending(&state, &mut pending), + // Channel closed (app shutting down): stop the fan-out. + Err(RecvError::Closed) => return, + }, + // Safety tick: catch completions missed without a detected lag. + _ = safety_tick.tick() => reconcile_pending(&state, &mut pending), + } + } + } +} + +/// Drop from `pending` any PR whose review no longer has a running agent (or no +/// longer exists), reconciling the wave gate against the store. +/// +/// [`run_fan_out`] waits on a shared, bounded completion broadcast that can drop +/// messages, so it cannot rely on seeing every completion. The store's `agent` +/// handle — cleared by the global completion consumer once a completion is +/// applied — is the durable signal for whether an implementer is still running, +/// so it is what the wave gate reconciles against. +fn reconcile_pending(state: &AppState, pending: &mut HashSet<String>) { + pending.retain(|pr| { + state + .reviews + .get(&PrRef::new(pr.as_str())) + .is_some_and(|review| review.agent.is_some()) + }); +} + +/// Surface an implementer fan-out preparation failure to the project's agent +/// panel, keyed by the project id so the frontend attributes it correctly. +fn emit_fan_out_error(app_handle: &tauri::AppHandle, project_id: &ProjectId, message: &str) { + let event = cockpit_core::adapters::agent_stream::Event::Error { + message: format!("Implementer fan-out failed: {message}"), + }; + crate::streaming::emit_agent_event(app_handle, project_id.as_str(), event); } /// Return the [`BatchStatus`] for a project's reviews. @@ -993,6 +1513,188 @@ pub fn approve_review(state: State<'_, Arc<AppState>>, pr: String) -> Result<Rev }) } +/// Merge an approved review's PR (`Approved` -> `Merged`) and GC its worktree. +/// +/// This is a guarded side effect (Invariant 5 / `SPEC.md` §9): it only ever +/// runs from this explicit user command, never automatically or from agent +/// output. It requires the review to be [`GateState::Approved`] and refuses +/// [`ReviewSource::ReviewRequested`] PRs — cockpit merges the user's own work, +/// not teammates' review requests. +/// +/// On a successful `gh pr merge` (squash) it advances the gate to `Merged`, +/// marks the review's descendants stale (they now sit on an old base and need a +/// restack), then GCs the worktree — but only when it lives under the +/// cockpit-managed worktrees directory, never the user's main checkout. The +/// prune is best-effort: a failure is logged, not surfaced as an error +/// (Invariant 1). +#[tauri::command] +pub async fn merge_review( + state: State<'_, Arc<AppState>>, + pr: String, +) -> Result<Review, CommandError> { + let pr_ref = PrRef::new(&pr); + let review = state.reviews.get(&pr_ref).ok_or_else(|| CommandError { + message: format!("Review not found: {pr}"), + })?; + + // Guard: only an approved review may merge. + if review.gate_state != GateState::Approved { + return Err(CommandError { + message: format!( + "Review {pr} is not approved (state: {:?}); approve it before merging", + review.gate_state + ), + }); + } + + // Guard: never merge a teammate's review-requested PR. + if review.source == ReviewSource::ReviewRequested { + return Err(CommandError { + message: format!( + "Review {pr} is a review-requested PR; cockpit does not merge teammates' PRs" + ), + }); + } + + let Some(pr_number) = pr_number_from_ref(&pr) else { + return Err(CommandError { + message: format!("Could not parse a PR number from: {pr}"), + }); + }; + + // Guarded side effect: merge via `gh` (squash). A failure here leaves the + // review Approved and is surfaced to the caller. + github::merge_pr( + review.repo_slug.as_deref(), + pr_number, + github::MergeMethod::Squash, + ) + .await?; + + // Advance the gate to Merged (Approved -> Merged is the only legal edge). + let mut transition_err: Option<cockpit_core::gate::Error> = None; + state.reviews.update(&pr_ref, |r| { + if let Err(e) = r.mark_merged() { + transition_err = Some(e); + } + }); + if let Some(e) = transition_err { + return Err(CommandError::from(e)); + } + + // Descendants now sit on an old base — mark them stale so the UI prompts a + // restack. + state.reviews.mark_descendants_stale(&review.id); + + // Worktree GC: prune the merged review's worktree, but ONLY when it lives + // under the cockpit-managed worktrees dir (never the user's main checkout). + // Best-effort (Invariant 1): any failure is logged, never surfaced. + prune_merged_worktree(&pr, &review.worktree, &review.branch); + + state.reviews.get(&pr_ref).ok_or_else(|| CommandError { + message: format!("Review not found: {pr}"), + }) +} + +/// Best-effort GC of a merged review's git worktree. +/// +/// Only prunes when `worktree` is under [`cockpit_core::config::worktrees_dir`] +/// so the user's main checkout is never touched. `git2::Repository` is not +/// `Send`; this helper is synchronous and holds no handle across an `.await`, +/// so callers must invoke it outside any await span. Every failure mode (no +/// worktrees dir, worktree outside it, repo won't open, prune errors) is logged +/// and swallowed — merge already succeeded, so GC must never fail the command. +fn prune_merged_worktree(pr: &str, worktree: &std::path::Path, branch: &str) { + let Ok(worktrees_dir) = cockpit_core::config::worktrees_dir() else { + eprintln!("merge_review: could not resolve worktrees dir; skipping prune for {pr}"); + return; + }; + if !worktree.starts_with(&worktrees_dir) { + // Not a cockpit-managed worktree (e.g. an imported PR pointing at the + // main checkout) — leave it alone. + return; + } + + let repo_path = Config::load() + .ok() + .and_then(|c| c.repo_path) + .unwrap_or_else(|| PathBuf::from(".")); + match git2::Repository::discover(&repo_path) { + Ok(repo) => { + if let Err(e) = cockpit_core::adapters::git::prune_worktree(&repo, branch) { + eprintln!("merge_review: prune_worktree failed for {pr}: {e}"); + } + } + Err(e) => { + eprintln!( + "merge_review: could not open repo at {} for prune: {e}", + repo_path.display() + ); + } + } +} + +// --------------------------------------------------------------------------- +// Agent control +// --------------------------------------------------------------------------- + +/// Kill the running agent attached to a review (D11). +/// +/// Sends SIGTERM to the agent process, removes its session from the session map +/// so a straggling Stop-hook / stream-end completion cannot double-fire, then +/// applies a no-progress completion: the review returns to `InReview` with its +/// comments preserved and the agent handle cleared (git HEAD, not the killed +/// agent, is authoritative — Invariant 4). This is an explicit user action. +/// +/// The killed process still emits a stream-end completion when it exits; the +/// completion handler tolerates the review already being non-`Dispatched` and +/// settles it without an illegal transition (see `reconcile_fix_completion`). +#[tauri::command] +pub async fn kill_agent( + state: State<'_, Arc<AppState>>, + pr: String, +) -> Result<Review, CommandError> { + let pr_ref = PrRef::new(&pr); + let review = state.reviews.get(&pr_ref).ok_or_else(|| CommandError { + message: format!("Review not found: {pr}"), + })?; + + let pid = review + .agent + .as_ref() + .map(|a| a.pid) + .ok_or_else(|| CommandError { + message: format!("Review {pr} has no running agent to kill"), + })?; + + // Send SIGTERM to the agent process. + cockpit_core::adapters::agent::kill_agent(pid).await?; + + // Remove the session so a straggling completion cannot double-fire against a + // review we are about to return to InReview. The fix path keys sessions by + // PR ref; fall back to the review id for defensiveness. + let session_id = state + .sessions + .find_by_object(pr_ref.as_str()) + .or_else(|| state.sessions.find_by_object(review.id.as_str())); + if let Some(sid) = session_id { + state.sessions.remove(&sid); + } + + // Apply a no-progress completion. On a Dispatched review this returns it to + // InReview (comments preserved, agent cleared); for a non-Dispatched agent + // (e.g. a Pending implementer) the transition is a no-op but the agent handle + // is still cleared, which is what the UI needs — so the transition error is + // ignored deliberately. + state.reviews.update(&pr_ref, |r| { + let _ = r.apply_agent_completion(None); + }); + + state.reviews.get(&pr_ref).ok_or_else(|| CommandError { + message: format!("Review not found: {pr}"), + }) +} + // --------------------------------------------------------------------------- // Settings / Config commands // --------------------------------------------------------------------------- @@ -1326,11 +2028,15 @@ pub async fn restack_pr( .map(str::to_owned); let prompt = restack::assemble_conflict_prompt(&review, &parent_branch, preamble.as_deref()); + // Key the session + stream by the PR ref (not the ReviewId): the + // Restack completion handler resolves reviews by PrRef, so keying by + // ReviewId here would leave restack completions unmatched. Mirrors the + // Fix path (see `try_spawn_agent`). let spawn_result = cockpit_core::adapters::agent::spawn_agent( &worktree_path, &prompt, cockpit_core::model::AgentMode::Restack, - review.id.as_str(), + review.pr.as_str(), &state.sessions, &hook_url, &spawn_config, @@ -1342,7 +2048,7 @@ pub async fn restack_pr( // Start streaming agent stdout to the frontend. let stream_ctx = crate::streaming::StreamContext { - object_id: review.id.as_str().to_string(), + object_id: review.pr.as_str().to_string(), mode: cockpit_core::model::AgentMode::Restack, completion_tx: state.completion_tx.clone(), }; diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 4b887b9..85b2936 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -12,11 +12,31 @@ use std::sync::Arc; use tauri::{Emitter, Manager}; -use cockpit_core::gate::Gated; -use cockpit_core::model::{AgentMode, PrRef, ProjectId}; +use cockpit_core::gate::{AgentOutcome, Gated}; +use cockpit_core::model::{AgentMode, GateState, PrRef, Project, ProjectId, Review}; use state::AppState; +/// Payload emitted on the `"agent-completed"` Tauri event after a completion is +/// reconciled against git HEAD. +/// +/// Extends the raw [`CompletionEvent`](cockpit_core::hook_server::CompletionEvent) +/// fields the frontend already listens for with a git-HEAD-authoritative +/// `outcome` label, so the UI can tell rework that actually landed a commit +/// (`"reworked"`) from a failed/no-op run (`"failed"`) or a non-gate-advancing +/// artifact fill (`"completed"`). Hand-typed on the frontend (no `ts-rs`). +#[derive(Debug, Clone, serde::Serialize)] +struct AgentCompletedPayload { + /// The session id that completed. + session_id: String, + /// UI key of the reviewed object (PR ref for reviews, project id for plans). + object_id: String, + /// Which agent mode ran. + mode: AgentMode, + /// Outcome label: `"reworked"`, `"failed"`, or `"completed"`. + outcome: &'static str, +} + /// Set the macOS dock icon from an embedded PNG. /// /// During `cargo tauri dev` there is no `.app` bundle, so macOS shows a @@ -62,6 +82,26 @@ pub fn run() { let hook_sessions = app_state.sessions.clone(); let hook_completion_tx = app_state.completion_tx.clone(); + // Restore persisted session state (D5) before any observer runs, so the + // flush task's baseline revision reflects the loaded data. A missing or + // corrupt file yields `None` (persist::load never panics — Invariant 1), so + // a fresh start is the normal first-launch path. + if let Ok(home) = cockpit_core::config::cockpit_home() + && let Some(persisted) = cockpit_core::persist::load(&home) + { + app_state + .reviews + .hydrate(sanitize_loaded_reviews(persisted.reviews)); + app_state + .projects + .hydrate(sanitize_loaded_projects(persisted.projects)); + } + + // Clone the (Arc-backed) store handles for the background flush task before + // app_state is moved into `.manage()`. + let flush_reviews = app_state.reviews.clone(); + let flush_projects = app_state.projects.clone(); + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) @@ -82,38 +122,29 @@ pub fn run() { while let Ok(event) = rx.recv().await { let app_state_ref: tauri::State<'_, Arc<AppState>> = app_handle.state(); - match event.mode { + let outcome: &'static str = match event.mode { AgentMode::Fix | AgentMode::Restack => { - // Look up the review by PrRef (stored as - // object_id), clear the agent run, transition - // to Reworked, and re-fetch the diff. - let pr_ref = PrRef::new(&event.object_id); - app_state_ref.reviews.update(&pr_ref, |review| { - review.agent = None; - let _ = review.mark_reworked(); - }); - - // Best-effort: re-fetch the diff so users - // review fresh code, not stale diffs. - refresh_review_diff(&app_state_ref, &pr_ref).await; + // Reconcile the review against git HEAD, which — not + // agent stdout — decides whether rework landed. + reconcile_fix_completion(&app_state_ref, &event.object_id).await } AgentMode::Plan => { // Two planner spawns land here (both AgentMode::Plan): // * initial generation — the plan is still // `Pending`; leave it `Pending` (artifact-fill) - // so the user opens it when ready. - // * rework — the plan is `Dispatched`; transition - // to `Reworked` (clears ephemeral comments). - // In both cases, if the planner wrote its output to - // the recorded `plan_path`, ingest it: read + parse - // the markdown into `doc`. Read/parse failure is - // non-fatal (Invariant 1) — we log and keep the - // prior doc rather than block the loop. + // so the user opens it when ready ("completed"). + // * rework — the plan is `Dispatched`; ingest the + // planner's output and settle the gate: parsed + // output → `Reworked` (clears comments), missing + // or unparseable output → `InReview` (comments + // preserved, "failed"). + // Read/parse failure is non-fatal (Invariant 1) — we + // log and keep the prior doc rather than block. // // The session object_id is the project id (set at // spawn), so this routes to the right project's plan. let project_id = cockpit_core::model::ProjectId::new(&event.object_id); - ingest_plan_output(&app_state_ref, &project_id); + ingest_plan_output(&app_state_ref, &project_id) } AgentMode::Implement => { // An implementer finished building a review's @@ -134,12 +165,19 @@ pub fn run() { }); refresh_review_diff(&app_state_ref, &pr_ref).await; } + "completed" } - } + }; // Best-effort: if no frontend window is listening, the // event is simply dropped. - let _ = app_handle.emit("agent-completed", &event); + let payload = AgentCompletedPayload { + session_id: event.session_id.clone(), + object_id: event.object_id.clone(), + mode: event.mode, + outcome, + }; + let _ = app_handle.emit("agent-completed", &payload); } }); @@ -159,18 +197,68 @@ pub fn run() { }); } + // Background persistence flush (D5). Once per ~second, snapshot the + // store revisions; when either changed since the last save, persist + // the whole session to disk. `save_atomic` is blocking file IO, so + // it runs on the blocking pool — the async loop never blocks + // (Invariant 1). Save failures are logged and retried on the next + // tick, never fatal. + tauri::async_runtime::spawn(async move { + let mut last = flush_reviews + .revision() + .wrapping_add(flush_projects.revision()); + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let current = flush_reviews + .revision() + .wrapping_add(flush_projects.revision()); + if current == last { + continue; + } + + let snapshot = cockpit_core::persist::PersistedState { + version: cockpit_core::persist::STATE_VERSION, + reviews: flush_reviews.list(), + projects: flush_projects.list(), + }; + + // Persist off the async runtime — save_atomic is sync IO. + let saved = + tokio::task::spawn_blocking( + move || match cockpit_core::config::cockpit_home() { + Ok(home) => cockpit_core::persist::save_atomic(&home, &snapshot) + .map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + }, + ) + .await; + + match saved { + // Only advance the baseline on a durable save, so a + // failed flush is retried rather than silently skipped. + Ok(Ok(())) => last = current, + Ok(Err(msg)) => eprintln!("persist flush: save failed: {msg}"), + Err(e) => eprintln!("persist flush: save task panicked: {e}"), + } + } + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ - commands::get_version, commands::list_reviews, commands::get_frontier, commands::get_review, commands::open_review, commands::get_review_diff, + commands::get_interdiff, commands::add_comment, commands::request_changes, commands::mirror_comments, + commands::submit_github_review, + commands::kill_agent, + commands::ensure_review_worktree, commands::fetch_ci_checks, commands::list_ci_checks, commands::ci_run_logs_by_link, @@ -183,6 +271,7 @@ pub fn run() { commands::plan_open, commands::batch_status, commands::approve_review, + commands::merge_review, commands::get_config, commands::save_config, commands::get_agent_prompt, @@ -212,19 +301,149 @@ pub fn run() { .expect("error running tauri application"); } -/// Settle a project's plan after a planner (`AgentMode::Plan`) completion. +/// Sanitize reviews loaded from disk before hydrating the store (D5). +/// +/// Two fix-ups make persisted state safe to resume: +/// * The process that owned each agent handle is dead after a restart, so +/// every `agent` handle is dropped. +/// * A review left `Dispatched` at shutdown had an in-flight agent that will +/// never report back, so it is returned to `InReview` via +/// `mark_agent_failed`, which preserves its comments (Invariant 4) so the +/// pending rework can be re-dispatched. +/// +/// The `mark_agent_failed` call is guarded by the `Dispatched` check, so its +/// only failure mode is unreachable here; the `Result` is ignored deliberately. +fn sanitize_loaded_reviews(reviews: Vec<Review>) -> Vec<Review> { + reviews + .into_iter() + .map(|mut review| { + review.agent = None; + if review.gate_state == GateState::Dispatched { + let _ = review.mark_agent_failed(); + } + review + }) + .collect() +} + +/// Sanitize projects loaded from disk before hydrating the store (D5). +/// +/// Mirrors [`sanitize_loaded_reviews`] for each project's optional plan: drop +/// the dead planner `agent` handle, and return a `Dispatched` plan to +/// `InReview` (comments preserved) so a pending plan rework can be re-dispatched. +fn sanitize_loaded_projects(projects: Vec<Project>) -> Vec<Project> { + projects + .into_iter() + .map(|mut project| { + if let Some(plan) = project.plan.as_mut() { + plan.agent = None; + if plan.gate_state == GateState::Dispatched { + let _ = plan.mark_agent_failed(); + } + } + project + }) + .collect() +} + +/// Reconcile a Fix/Restack agent completion against git HEAD and return the +/// outcome label for the `"agent-completed"` payload. +/// +/// git HEAD — not agent stdout — is authoritative: an agent can report success +/// while committing nothing. The only trusted signal is whether the worktree +/// branch HEAD actually advanced. +/// +/// The lock-across-await rule (CLAUDE.md §2) is honored by construction: the +/// worktree path is snapshotted out of the store (releasing the lock) before any +/// blocking git work; the blocking `git2` read runs on the blocking pool via +/// [`tokio::task::spawn_blocking`] (only the owned worktree path — all `Send` — +/// crosses the boundary); and the store is only re-locked afterwards to write +/// the result. [`Review::apply_agent_completion`] preserves comments on a Failed +/// outcome (Invariant 4). +async fn reconcile_fix_completion(state: &AppState, object_id: &str) -> &'static str { + let pr_ref = PrRef::new(object_id); + + // A completion can arrive for a review that is no longer `Dispatched`: the + // agent was killed (kill_agent already reconciled it to InReview) or a + // duplicate completion (Stop hook + stream-end) already settled it. Applying + // a transition now would be illegal and only log noise, so report the + // review's already-settled outcome without touching it. + let Some(review) = state.reviews.get(&pr_ref) else { + // No stored review resolved for this object id — no rework can have + // landed. + return "failed"; + }; + match review.gate_state { + GateState::Dispatched => {} + GateState::Reworked => return "reworked", + _ => return "failed", + } + + // Snapshot the worktree path, dropping the store lock before the blocking + // git read and the diff refresh below. + let worktree = review.worktree; + + // `git2` reconcile is blocking; run it off the async runtime. + let head = + tokio::task::spawn_blocking(move || cockpit_core::adapters::git::reconcile(&worktree)) + .await; + + // Resolve the new HEAD SHA, or `None` (treated as "no progress") when the + // reconcile failed or its task panicked. `None` routes to Failed, preserving + // comments for re-dispatch. + let new_head: Option<String> = match head { + Ok(Ok(oid)) => Some(oid.to_string()), + Ok(Err(e)) => { + eprintln!("reconcile_fix_completion: reconcile failed for {object_id}: {e}"); + None + } + Err(e) => { + eprintln!("reconcile_fix_completion: reconcile task panicked for {object_id}: {e}"); + None + } + }; + + // Re-lock only to apply the git-authoritative outcome. + let mut applied: Option<Result<AgentOutcome, cockpit_core::gate::Error>> = None; + state.reviews.update(&pr_ref, |review| { + applied = Some(review.apply_agent_completion(new_head)); + }); + + match applied { + Some(Ok(AgentOutcome::Reworked)) => { + // Best-effort: re-fetch the diff so users review fresh code. + refresh_review_diff(state, &pr_ref).await; + "reworked" + } + Some(Ok(AgentOutcome::Failed)) => "failed", + Some(Err(e)) => { + eprintln!( + "reconcile_fix_completion: apply_agent_completion failed for {object_id}: {e}" + ); + "failed" + } + // Review vanished between the snapshot and the write-back. + None => "failed", + } +} + +/// Settle a project's plan after a planner (`AgentMode::Plan`) completion and +/// return the outcome label for the `"agent-completed"` payload. /// /// Clears the running agent, ingests the planner's written markdown (when a /// `plan_path` is recorded and the file is present and non-empty) by parsing it /// into the plan's `doc`, and settles the gate: -/// * `Dispatched` (rework) -> `Reworked` (also clears ephemeral comments). -/// * `Pending` (initial artifact-fill) stays `Pending`. +/// * `Dispatched` (rework) with parsed output -> `Reworked` (clears ephemeral +/// comments); returns `"reworked"`. +/// * `Dispatched` (rework) with missing/unparseable output -> `InReview` via +/// `mark_agent_failed` (comments preserved for re-dispatch); returns +/// `"failed"`. +/// * `Pending` (initial artifact-fill) stays `Pending`; returns `"completed"`. /// /// Keyed by [`ProjectId`] (the completion event's `object_id`) so the correct /// project's plan is updated. Read/parse failures are non-fatal (Invariant 1): /// the prior doc is kept and the failure is logged rather than blocking the loop. -fn ingest_plan_output(state: &AppState, project_id: &ProjectId) { - use cockpit_core::gate::Gated; +fn ingest_plan_output(state: &AppState, project_id: &ProjectId) -> &'static str { use cockpit_core::model::GateState; // Read + parse outside the store lock; only touch on-disk state here. @@ -252,6 +471,8 @@ fn ingest_plan_output(state: &AppState, project_id: &ProjectId) { } }); + let parsed_ok = parsed.is_some(); + let mut outcome = "completed"; state.projects.update_plan(project_id, |slot| { let Some(plan) = slot.as_mut() else { return; @@ -260,13 +481,25 @@ fn ingest_plan_output(state: &AppState, project_id: &ProjectId) { if let Some(doc) = parsed { plan.doc = doc; } + // Only a rework spawn (Dispatched) settles the gate; initial generation + // (Pending) stays Pending as an artifact fill. if plan.gate_state == GateState::Dispatched { - // `mark_reworked` clears ephemeral comments (Invariant 4). A wrong - // starting state cannot occur here (guarded above), so the error is - // ignored deliberately. - let _ = plan.mark_reworked(); + if parsed_ok { + // `mark_reworked` clears ephemeral comments (Invariant 4). A + // wrong starting state cannot occur here (guarded above), so the + // error is ignored deliberately. + let _ = plan.mark_reworked(); + outcome = "reworked"; + } else { + // The planner produced no usable output — return the plan to + // InReview with its comments preserved (failure-aware rework) + // rather than falsely reporting rework. Guarded state as above. + let _ = plan.mark_agent_failed(); + outcome = "failed"; + } } }); + outcome } /// Resolve a review's [`PrRef`] from a completion event's `object_id`. diff --git a/app/src-tauri/src/streaming.rs b/app/src-tauri/src/streaming.rs index 3daa651..a61df52 100644 --- a/app/src-tauri/src/streaming.rs +++ b/app/src-tauri/src/streaming.rs @@ -4,11 +4,17 @@ //! When an agent is spawned with `--output-format stream-json`, its stdout //! is piped. This module reads that pipe line by line, writes each raw line //! to the log file, parses it into a [`cockpit_core::adapters::agent_stream::Event`], -//! and emits it as a `"agent-event"` Tauri event for the frontend to render. +//! and emits it wrapped in an [`AgentEventEnvelope`] on the `"agent-event"` +//! Tauri channel — the envelope carries the object's UI key so the frontend +//! can tell which review/plan a given event belongs to. //! //! When the stream ends (agent process exits), a [`CompletionEvent`] is //! emitted on the broadcast channel so the completion handler in `lib.rs` -//! can reconcile the review state. +//! can reconcile the review state. The completion *outcome* is decided by git +//! HEAD in `lib.rs`, not by the stream — an agent can claim success on stdout +//! while committing nothing — so the child's exit status and any observed +//! [`Event::Error`](agent_stream::Event::Error) are captured here only as +//! advisory diagnostics, never threaded into the (authoritative) completion. use std::path::PathBuf; @@ -19,6 +25,37 @@ use cockpit_core::adapters::agent_stream; use cockpit_core::hook_server::CompletionEvent; use cockpit_core::model::AgentMode; +/// Envelope wrapping a parsed agent [`Event`](agent_stream::Event) with the UI +/// key of the object it belongs to, emitted on the `"agent-event"` channel. +/// +/// Hand-typed on the frontend (no `ts-rs` binding) to mirror how other Tauri +/// payloads (e.g. the shell output payload) are threaded across the boundary. +#[derive(Debug, Clone, serde::Serialize)] +pub struct AgentEventEnvelope { + /// UI key of the object this event belongs to: a review's PR ref for review + /// agents, or the project id for plan agents. + pub object_id: String, + /// The parsed agent stream event. + pub event: agent_stream::Event, +} + +/// Emit a parsed agent [`Event`](agent_stream::Event) wrapped in an +/// [`AgentEventEnvelope`] on the `"agent-event"` channel, keyed by `object_id`. +/// +/// Best-effort: if no frontend window is listening, the event is dropped. +pub fn emit_agent_event( + app_handle: &tauri::AppHandle, + object_id: &str, + event: agent_stream::Event, +) { + use tauri::Emitter; + let envelope = AgentEventEnvelope { + object_id: object_id.to_string(), + event, + }; + let _ = app_handle.emit("agent-event", &envelope); +} + /// Context needed by the streaming task to emit a completion event /// when the agent stream ends. pub struct StreamContext { @@ -46,13 +83,31 @@ pub fn start_stream_forwarding( let log_path = spawn_result.log_path.clone(); tauri::async_runtime::spawn(async move { - stream_agent_output(&mut spawn_result.child, &log_path, &app_handle).await; + let saw_error = stream_agent_output( + &mut spawn_result.child, + &log_path, + &app_handle, + &ctx.object_id, + ) + .await; // Wait for the child process to fully exit. - let _ = spawn_result.child.wait().await; + let exit_status = spawn_result.child.wait().await; + + // Advisory only: the completion outcome is decided by git HEAD in + // lib.rs (an agent can claim success while committing nothing), so we + // log an abnormal exit / observed stream error for diagnostics but do + // NOT thread it into CompletionEvent — the HEAD check is authoritative. + let clean_exit = matches!(&exit_status, Ok(status) if status.success()); + if saw_error || !clean_exit { + eprintln!( + "agent stream for {} ended abnormally (exit: {exit_status:?}, stream_error: {saw_error})", + ctx.object_id + ); + } - // Emit a completion event so the handler in lib.rs reconciles - // the review state (Dispatched → Reworked). + // Emit a completion event so the handler in lib.rs reconciles the + // reviewed object's state against git HEAD. let event = CompletionEvent { session_id: String::new(), object_id: ctx.object_id, @@ -65,19 +120,23 @@ pub fn start_stream_forwarding( } /// Read the child's stdout line by line, write each line to the log file, -/// and emit parsed events via the Tauri event system. +/// and emit parsed events (wrapped in an [`AgentEventEnvelope`] keyed by +/// `object_id`) via the Tauri event system. +/// +/// Returns `true` if an [`Event::Error`](agent_stream::Event::Error) was seen +/// in the stream — advisory diagnostic only; the completion outcome is decided +/// by git HEAD in `lib.rs`. async fn stream_agent_output( child: &mut tokio::process::Child, log_path: &PathBuf, app_handle: &tauri::AppHandle, -) { - use tauri::Emitter; - + object_id: &str, +) -> bool { // Take stdout from the child. If piped stdout is unavailable, nothing // to stream. let stdout = match child.stdout.take() { Some(stdout) => stdout, - None => return, + None => return false, }; // Open the log file for appending raw lines. @@ -95,6 +154,7 @@ async fn stream_agent_output( let reader = BufReader::new(stdout); let mut lines = reader.lines(); + let mut saw_error = false; loop { let line = match lines.next_line().await { Ok(Some(line)) => line, @@ -110,11 +170,12 @@ async fn stream_agent_output( let _ = writer.flush().await; } - // Parse and emit. + // Parse and emit, wrapped in the object-keyed envelope. if let Some(event) = agent_stream::parse_stream_line(&line) { - // Best-effort: if no frontend window is listening, the event is - // simply dropped. - let _ = app_handle.emit("agent-event", &event); + if matches!(event, agent_stream::Event::Error { .. }) { + saw_error = true; + } + emit_agent_event(app_handle, object_id, event); } } @@ -122,4 +183,6 @@ async fn stream_agent_output( if let Some(ref mut writer) = log_writer { let _ = writer.flush().await; } + + saw_error } diff --git a/app/src/App.tsx b/app/src/App.tsx index 0b62418..da54b7f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,7 @@ import { SHORTCUTS } from "./lib/shortcuts"; import type { ShortcutId } from "./lib/shortcuts"; import { Sidebar } from "./components/Sidebar"; import { ReviewCard } from "./components/ReviewCard"; +import { StackContainer } from "./components/StackContainer"; import { ProjectCard } from "./components/ProjectCard"; import { ReviewWorkspace } from "./components/ReviewWorkspace"; import { PlanView } from "./components/PlanView"; @@ -36,7 +37,7 @@ import { FolderOpen, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { sortByAttention } from "./lib/attention"; +import { buildBoardItems } from "./lib/stack-tree"; import type { CardDensity } from "./components/ReviewCard"; import type { GateState } from "./bindings/GateState"; import type { Review } from "./bindings/Review"; @@ -48,6 +49,13 @@ interface CompletionEventPayload { readonly session_id: string; readonly object_id: string; readonly mode: AgentMode; + /** + * git-HEAD-authoritative outcome of the completed run: `"reworked"` when a + * commit landed, `"failed"` for a no-op/failed run, `"completed"` for a + * non-gate-advancing artifact fill. Optional — hand-typed, tolerant of older + * payloads. + */ + readonly outcome?: "reworked" | "failed" | "completed"; } type ReviewTab = "my-prs" | "review-requests" | "all"; @@ -66,35 +74,70 @@ function assertNever(x: never): never { throw new Error(`unreachable: ${String(x)}`); } -/** Build a desktop notification title from the agent mode. */ -function notificationTitleForMode(mode: AgentMode): string { +/** The git-HEAD-authoritative outcome label carried on a completion event. */ +type CompletionOutcome = CompletionEventPayload["outcome"]; + +/** + * Build an outcome-aware desktop notification for a completed agent run. + * + * The diff-gate rework loop (Fix/Restack) distinguishes a run that actually + * landed a commit (`"reworked"` — ready to re-review) from a no-op/failed run + * (`"failed"` — comments are preserved for another cycle), since those demand + * very different follow-up from the reviewer. + */ +function completionNotification( + mode: AgentMode, + outcome: CompletionOutcome, + branch: string, +): { readonly title: string; readonly body: string } { switch (mode) { case "Fix": case "Restack": - return "Rework Complete"; - case "Plan": - return "Plan Rework Complete"; + return outcome === "failed" + ? { + title: "Agent Failed", + body: `Agent failed on ${branch} — comments preserved`, + } + : { + title: "Rework Complete", + body: `PR reworked on ${branch} — ready to re-review`, + }; case "Implement": - return "Implementation Complete"; + return { + title: "Implementation Complete", + body: `Implementation agent finished on ${branch}`, + }; + case "Plan": + return { + title: "Plan Rework Complete", + body: "Plan agent finished reworking", + }; default: return assertNever(mode); } } -/** Build a desktop notification body from the agent mode and branch name. */ -function notificationBodyForMode(mode: AgentMode, branch: string): string { - switch (mode) { - case "Fix": - return `Fix agent finished on ${branch}`; - case "Restack": - return `Restack agent finished on ${branch}`; - case "Plan": - return `Plan agent finished reworking`; - case "Implement": - return `Implementation agent finished on ${branch}`; - default: - return assertNever(mode); - } +/** Whether a mode's completion is keyed by a review's PR ref (not a project). */ +function isReviewMode(mode: AgentMode): boolean { + return mode === "Fix" || mode === "Restack" || mode === "Implement"; +} + +/** + * Whether a review-mode completion was already reconciled locally before the + * event arrived — i.e. the user stopped the agent (`killAgent` cleared the + * review's `agent` handle) or a duplicate stream-end already settled it. + * + * The frontend store is only refreshed asynchronously, so a *genuine* + * completion still shows the agent attached at event time; a stopped one shows + * it already cleared. Used to suppress a double-toast after a user Stop. + */ +function alreadySettledLocally(objectId: string): boolean { + const s = useAppStore.getState(); + const review = + [...s.reviews, ...s.authoredPrs, ...s.reviewRequests].find( + (r) => r.pr === objectId, + ) ?? (s.activeReview?.pr === objectId ? s.activeReview : null); + return review !== null && review !== undefined && review.agent === null; } /** A named group of reviews for the grouped-by-project PRs list. */ @@ -142,7 +185,6 @@ function App() { const fetchAuthoredPrs = useAppStore((s) => s.fetchAuthoredPrs); const fetchReviewRequests = useAppStore((s) => s.fetchReviewRequests); const fetchReviews = useAppStore((s) => s.fetchReviews); - const fetchFrontier = useAppStore((s) => s.fetchFrontier); const fetchPlan = useAppStore((s) => s.fetchPlan); const fetchConfig = useAppStore((s) => s.fetchConfig); const listProjects = useAppStore((s) => s.listProjects); @@ -217,7 +259,6 @@ function App() { }, refresh: () => { void fetchReviews(); - void fetchFrontier(); void fetchAuthoredPrs(); }, "toggle-sidebar": () => { @@ -246,7 +287,6 @@ function App() { navigateToAgents, navigateToSettings, fetchReviews, - fetchFrontier, fetchAuthoredPrs, toggleSidebar, view.kind, @@ -266,14 +306,20 @@ function App() { useEffect(() => { void fetchReviews(); - void fetchFrontier(); void fetchConfig(); void fetchAuthoredPrs(); void listProjects(); const unlisten = listen<CompletionEventPayload>("agent-completed", (event) => { + const { object_id, mode, outcome } = event.payload; + + // Snapshot BEFORE the async refreshes so a genuine completion (agent still + // attached) is distinguished from one the user already stopped (agent + // cleared by killAgent). The refreshes below then reconcile local state. + const settledLocally = + isReviewMode(mode) && alreadySettledLocally(object_id); + void fetchReviews(); - void fetchFrontier(); void listProjects(); // Refresh the open plan (if any) so a completed Plan agent updates it. const currentView = useAppStore.getState().view; @@ -282,15 +328,16 @@ function App() { } void refreshActiveReview(); - // Best-effort desktop notification. Use the event payload's mode - // to differentiate the notification title and body. - const { mode } = event.payload; + // Suppress the notification for a run the user already stopped — otherwise + // the killed process's straggler completion would double-toast. + if (settledLocally) return; + + // Best-effort desktop notification, outcome-aware so a failed rework reads + // differently from one that landed a commit. const current = useAppStore.getState().activeReview; const branch = current !== null ? current.branch : "a review"; - void sendNotification({ - title: notificationTitleForMode(mode), - body: notificationBodyForMode(mode, branch), - }); + const { title, body } = completionNotification(mode, outcome, branch); + void sendNotification({ title, body }); }); return () => { @@ -300,7 +347,6 @@ function App() { }; }, [ fetchReviews, - fetchFrontier, fetchPlan, fetchConfig, fetchAuthoredPrs, @@ -359,6 +405,7 @@ function App() { case "InReview": case "Dispatched": case "Approved": + case "Merged": void navigateToDiff(pr); break; default: @@ -472,15 +519,26 @@ function App() { </span> </h2> <div className={prsDensity === "compact" ? "space-y-1.5" : "space-y-3"}> - {sortByAttention(group.reviews).map((review) => ( - <ReviewCard - key={review.id} - review={review} - density={prsDensity} - onAction={handleReviewAction} - onRestack={handleRestack} - /> - ))} + {buildBoardItems(group.reviews).map((item) => + item.kind === "single" ? ( + <ReviewCard + key={item.review.id} + review={item.review} + density={prsDensity} + onAction={handleReviewAction} + onRestack={handleRestack} + /> + ) : ( + <StackContainer + key={item.root.review.id} + root={item.root} + nodes={item.nodes} + density={prsDensity} + onAction={handleReviewAction} + onRestack={handleRestack} + /> + ), + )} </div> </section> )); diff --git a/app/src/bindings/Anchor.ts b/app/src/bindings/Anchor.ts index 8a095b0..87bf8cf 100644 --- a/app/src/bindings/Anchor.ts +++ b/app/src/bindings/Anchor.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DiffSide } from "./DiffSide"; /** * A location inside the current artifact that a [`Comment`] points to. @@ -14,4 +15,9 @@ path: string, /** * Inclusive start and end line in the current head. */ -range: [number, number], } }; +range: [number, number], +/** + * Which side of the diff the range refers to. Defaults to + * [`DiffSide::New`] for legacy data that predates this field. + */ +side: DiffSide, } }; diff --git a/app/src/bindings/DiffSide.ts b/app/src/bindings/DiffSide.ts new file mode 100644 index 0000000..e278b94 --- /dev/null +++ b/app/src/bindings/DiffSide.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Which side of a diff an [`Anchor::DiffLine`] range refers to. + * + * A comment can anchor to the old (pre-change) side or the new (post-change) + * side of the diff. Defaults to [`DiffSide::New`], matching the common case of + * commenting on added or changed lines. + */ +export type DiffSide = "Old" | "New"; diff --git a/app/src/bindings/DispatchSnapshot.ts b/app/src/bindings/DispatchSnapshot.ts new file mode 100644 index 0000000..4ebc57c --- /dev/null +++ b/app/src/bindings/DispatchSnapshot.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Comment } from "./Comment"; + +/** + * A read-only, single-cycle audit record of what the reviewer asked for at the + * moment comments were dispatched to an agent. + * + * Captured by [`crate::model::Review::snapshot_dispatch`] and overwritten on + * the next dispatch. This is **not** a durable comment store: comments remain + * ephemeral and are cleared on `Reworked` (Invariant §0.4). The snapshot exists + * only so the UI can show "what was asked for" during the in-flight cycle. + */ +export type DispatchSnapshot = { +/** + * The review's HEAD SHA at the moment of dispatch. + */ +reviewed_sha: string, +/** + * The comments dispatched to the agent this cycle. + */ +comments: Array<Comment>, }; diff --git a/app/src/bindings/GateState.ts b/app/src/bindings/GateState.ts index 9cd53b2..53b5a04 100644 --- a/app/src/bindings/GateState.ts +++ b/app/src/bindings/GateState.ts @@ -4,4 +4,4 @@ * The shared gate state that drives the review loop for both [`ProjectPlan`] * and [`Review`]. See `SPEC.md` §7 for the transition table. */ -export type GateState = "Pending" | "InReview" | "Dispatched" | "Reworked" | "Approved"; +export type GateState = "Pending" | "InReview" | "Dispatched" | "Reworked" | "Approved" | "Merged"; diff --git a/app/src/bindings/MergeMethod.ts b/app/src/bindings/MergeMethod.ts new file mode 100644 index 0000000..28e7f6f --- /dev/null +++ b/app/src/bindings/MergeMethod.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How GitHub should combine a PR's commits when merging. + * + * Mirrors `gh pr merge`'s mutually-exclusive strategy flags. Defaults to + * [`MergeMethod::Squash`], the common single-commit-per-PR workflow. + */ +export type MergeMethod = "Squash" | "Merge" | "Rebase"; diff --git a/app/src/bindings/Review.ts b/app/src/bindings/Review.ts index 88c5608..0847e4d 100644 --- a/app/src/bindings/Review.ts +++ b/app/src/bindings/Review.ts @@ -2,6 +2,7 @@ import type { AgentRun } from "./AgentRun"; import type { Comment } from "./Comment"; import type { DiffData } from "./DiffData"; +import type { DispatchSnapshot } from "./DispatchSnapshot"; import type { GateState } from "./GateState"; import type { IssueRef } from "./IssueRef"; import type { PrRef } from "./PrRef"; @@ -28,6 +29,14 @@ issue: IssueRef, * The GitHub PR opened for this review. */ pr: PrRef, +/** + * PR title. Empty for legacy data that predates this field. + */ +title: string, +/** + * PR description / body. Empty for legacy data that predates this field. + */ +body: string, /** * Git branch name (e.g. `alejandro/nex-123-do-thing`). */ @@ -93,4 +102,11 @@ repo_slug: string | null, * The first-class [`Project`] this review belongs to, if any. `None` for * ungrouped reviews (e.g. GitHub-imported PRs with no project attached). */ -project: ProjectId | null, }; +project: ProjectId | null, +/** + * Read-only audit record of the most recent dispatch cycle, if any. + * + * Overwritten on each dispatch (see [`DispatchSnapshot`]). `None` before + * the first dispatch and for legacy data. + */ +dispatch_snapshot?: DispatchSnapshot, }; diff --git a/app/src/bindings/ReviewEvent.ts b/app/src/bindings/ReviewEvent.ts new file mode 100644 index 0000000..b68f987 --- /dev/null +++ b/app/src/bindings/ReviewEvent.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The kind of review to submit to GitHub, matching the API's `event` values. + */ +export type ReviewEvent = "Approve" | "RequestChanges" | "Comment"; diff --git a/app/src/bindings/SubmitReviewResult.ts b/app/src/bindings/SubmitReviewResult.ts new file mode 100644 index 0000000..100d380 --- /dev/null +++ b/app/src/bindings/SubmitReviewResult.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CommentId } from "./CommentId"; + +/** + * Result of submitting a review to a GitHub PR. + * + * `submitted` counts the inline comments actually included in the review; + * `skipped` lists the comments that were left out with a human-readable reason + * (e.g. their anchored line is not part of the PR's diff), so the caller can + * report partial success. + */ +export type SubmitReviewResult = { +/** + * Number of inline comments included in the submitted review. + */ +submitted: number, +/** + * Comments left out of the review: `(comment_id, reason)`. + */ +skipped: Array<[CommentId, string]>, }; diff --git a/app/src/components/AddressedRequests.tsx b/app/src/components/AddressedRequests.tsx new file mode 100644 index 0000000..c93e151 --- /dev/null +++ b/app/src/components/AddressedRequests.tsx @@ -0,0 +1,112 @@ +/** + * Read-only "Addressed requests" panel for interdiff re-review (D10). + * + * When a review is `Reworked`, this lists the comments that were dispatched to + * the agent in the most recent cycle (from the review's [`DispatchSnapshot`]), + * so the reviewer can see exactly what was asked for while inspecting the + * changes since. It is history, not live state: there are deliberately NO + * edit / delete / resolve affordances (Invariant §0.4 — comments are ephemeral + * and single-cycle). + */ + +import { ChevronDown, ChevronRight, History } from "lucide-react"; +import type { Comment } from "../bindings/Comment"; +import type { Anchor } from "../bindings/Anchor"; +import { cn } from "@/lib/utils"; + +interface AddressedRequestsProps { + /** The comments dispatched in the last review cycle. */ + readonly comments: readonly Comment[]; + /** Whether the panel is expanded. */ + readonly open: boolean; + /** Toggle the expanded state. */ + readonly onToggle: () => void; +} + +/** The file path an anchor points at, or null for non-diff anchors. */ +function anchorPath(anchor: Anchor): string | null { + if ("DiffLine" in anchor) { + return anchor.DiffLine.path; + } + return null; +} + +/** The inclusive line range an anchor points at, or null for non-diff anchors. */ +function anchorRange(anchor: Anchor): readonly [number, number] | null { + if ("DiffLine" in anchor) { + return anchor.DiffLine.range; + } + return null; +} + +/** Format an anchor as a compact `path:Lstart–Lend` location label. */ +function locationLabel(anchor: Anchor): string { + const path = anchorPath(anchor); + const range = anchorRange(anchor); + if (path === null) return "—"; + if (range === null) return path; + const [start, end] = range; + const lines = start === end ? `L${String(start)}` : `L${String(start)}–${String(end)}`; + return `${path}:${lines}`; +} + +/** + * A collapsible band listing the previous cycle's dispatched review comments. + */ +export function AddressedRequests({ + comments, + open, + onToggle, +}: AddressedRequestsProps) { + return ( + <div className="shrink-0 border-b border-border bg-card/50"> + <button + type="button" + onClick={onToggle} + aria-expanded={open} + className={cn( + "flex w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-4 py-1.5", + "text-[10px] font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground", + )} + title="Toggle addressed requests" + > + {open ? ( + <ChevronDown className="h-3 w-3" /> + ) : ( + <ChevronRight className="h-3 w-3" /> + )} + <History className="h-3 w-3" /> + Addressed requests + <span className="font-mono tabular-nums text-muted-foreground/70"> + {String(comments.length)} + </span> + <span className="ml-1 font-sans normal-case tracking-normal text-muted-foreground/60"> + from your last review + </span> + </button> + + {open && ( + <ul className="space-y-1.5 px-4 pb-2.5 pl-9 text-xs"> + {comments.map((comment) => ( + <li + key={comment.id} + className="rounded-md border border-border bg-background/40 px-2.5 py-1.5" + > + <div className="mb-0.5 font-mono text-[10px] text-muted-foreground"> + {locationLabel(comment.anchor)} + </div> + <div className="whitespace-pre-wrap break-words leading-relaxed text-foreground"> + {comment.body} + </div> + </li> + ))} + {comments.length === 0 && ( + <li className="italic text-muted-foreground"> + No requests were dispatched last cycle. + </li> + )} + </ul> + )} + </div> + ); +} diff --git a/app/src/components/AgentPanel.tsx b/app/src/components/AgentPanel.tsx index c64618a..ebf055b 100644 --- a/app/src/components/AgentPanel.tsx +++ b/app/src/components/AgentPanel.tsx @@ -2,11 +2,16 @@ * Agent activity timeline — the product's signature surface for watching an * agent rework a PR in real time. * - * Listens for Tauri `"agent-event"` events, maintains a local list, and renders - * each event as a row on a vertical timeline rail: an SVG icon tile (no emoji) - * colored by event tone, a bold title, a faint mono detail line, and a - * right-aligned tabular-nums elapsed timestamp. A header LED pulses while the - * agent runs and stops on a terminal Complete / Error event. + * Listens for Tauri `"agent-event"` envelopes, keeps a per-object buffer so + * switching between reviews never interleaves or wipes timelines, and renders + * the current object's events as rows on a vertical timeline rail: an SVG icon + * tile (no emoji) colored by event tone, a bold title, a faint mono detail + * line, and a right-aligned tabular-nums elapsed timestamp. A header LED pulses + * while the agent runs and stops on a terminal Complete / Error event. + * + * The panel also owns two explicit agent controls for the current object: a + * Stop button (visible while an agent is attached) that kills the run, and an + * Open-log affordance that opens the run's log file in the configured editor. * * Presentation (icon + tone + copy per event variant) lives in * `@/lib/agent-event`; this component owns streaming, layout, and lifecycle. @@ -14,16 +19,30 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; import type { Event } from "../bindings/Event"; +import { useAppStore } from "../store"; import { presentEvent, toneTileClass, AgentMark, type EventPresentation, } from "@/lib/agent-event"; -import { X, Trash2 } from "lucide-react"; +import { X, Trash2, Square, FileText } from "lucide-react"; import { cn } from "@/lib/utils"; +/** + * Envelope wrapping an agent {@link Event} with the UI key of the object it + * belongs to, as emitted on the backend `"agent-event"` channel. Hand-typed to + * mirror the Rust `AgentEventEnvelope` (no ts-rs binding). + */ +interface AgentEventEnvelope { + /** UI key of the object this event belongs to (PR ref or project id). */ + readonly object_id: string; + /** The parsed agent stream event. */ + readonly event: Event; +} + /** Internal event entry with a local sequence number for keying. */ interface TimelineEntry { readonly seq: number; @@ -32,9 +51,97 @@ interface TimelineEntry { readonly timestamp: number; } +/** Per-object timeline snapshot: its entries plus whether it was user-stopped. */ +interface ObjectTimeline { + readonly entries: readonly TimelineEntry[]; + /** True once the user stopped this object's run (terminal, danger banner). */ + readonly stopped: boolean; +} + +/** + * Module-level per-object event buffers, keyed by `object_id`. + * + * The panel stays mounted per workspace and its `objectId` prop changes as the + * user navigates between reviews; buffering here (rather than in component + * state) means each object keeps its full timeline across those switches and + * across panel remounts. Buffers are append-only for the app's lifetime, which + * is acceptable for the number of agent runs in a session. + */ +const timelines = new Map<string, ObjectTimeline>(); + +/** Monotonic sequence source for stable React keys across all objects. */ +let globalSeq = 0; + +/** Read (or lazily create) the timeline buffer for an object. */ +function timelineFor(objectId: string): ObjectTimeline { + const existing = timelines.get(objectId); + if (existing !== undefined) return existing; + const created: ObjectTimeline = { entries: [], stopped: false }; + timelines.set(objectId, created); + return created; +} + +/** + * Append an incoming stream event to an object's buffer, returning a fresh + * snapshot. Consecutive `Thinking` events collapse onto a single row (latest + * token count wins) instead of spamming the rail. A real stream event means the + * agent is active again, so any prior user-stop marker is cleared. + */ +function appendEvent( + objectId: string, + event: Event, + now: number, +): ObjectTimeline { + const cur = timelineFor(objectId); + let entries: TimelineEntry[]; + + if (event.kind === "Thinking") { + const lastIdx = cur.entries.length - 1; + const last = lastIdx >= 0 ? cur.entries[lastIdx] : undefined; + if (last !== undefined && last.event.kind === "Thinking") { + entries = [...cur.entries]; + entries[lastIdx] = { ...last, event, timestamp: now }; + } else { + globalSeq += 1; + entries = [...cur.entries, { seq: globalSeq, event, timestamp: now }]; + } + } else { + globalSeq += 1; + entries = [...cur.entries, { seq: globalSeq, event, timestamp: now }]; + } + + const next: ObjectTimeline = { entries, stopped: false }; + timelines.set(objectId, next); + return next; +} + +/** + * Push a synthetic terminal "stopped" entry after a user Stop, returning a + * fresh snapshot. The entry is a plain `Error` event so it reuses the existing + * error-row presentation; the `stopped` flag drives the terminal banner copy. + */ +function appendStopped(objectId: string, now: number): ObjectTimeline { + const cur = timelineFor(objectId); + globalSeq += 1; + const event: Event = { kind: "Error", message: "Agent stopped by you" }; + const next: ObjectTimeline = { + entries: [...cur.entries, { seq: globalSeq, event, timestamp: now }], + stopped: true, + }; + timelines.set(objectId, next); + return next; +} + +/** Clear an object's buffer (Clear button). */ +function clearTimeline(objectId: string): void { + timelines.set(objectId, { entries: [], stopped: false }); +} + interface AgentPanelProps { /** Whether the panel is visible. */ readonly visible: boolean; + /** UI key of the object whose timeline this panel shows (PR ref / project id). */ + readonly objectId: string; /** Callback to hide the panel / return to the diff. */ readonly onClose: () => void; } @@ -167,8 +274,19 @@ function EmptyState() { } /** Terminal banner shown after the run finishes. */ -function EndBanner({ phase }: { readonly phase: "complete" | "failed" }) { - const complete = phase === "complete"; +function EndBanner({ + phase, + stopped, +}: { + readonly phase: "complete" | "failed"; + readonly stopped: boolean; +}) { + const complete = phase === "complete" && !stopped; + const label = complete + ? "Reworked — ready to re-review" + : stopped + ? "Stopped by you" + : "Failed"; return ( <div className={cn( @@ -184,22 +302,33 @@ function EndBanner({ phase }: { readonly phase: "complete" | "failed" }) { complete ? "bg-success" : "bg-danger", )} /> - <span className="font-medium"> - {complete ? "Reworked — ready to re-review" : "Failed"} - </span> + <span className="font-medium">{label}</span> </div> ); } -export function AgentPanel({ visible, onClose }: AgentPanelProps) { - const [entries, setEntries] = useState<readonly TimelineEntry[]>([]); - const [startTime, setStartTime] = useState<number | null>(null); +export function AgentPanel({ visible, objectId, onClose }: AgentPanelProps) { + const initial = timelineFor(objectId); + const [entries, setEntries] = useState<readonly TimelineEntry[]>( + initial.entries, + ); + const [stopped, setStopped] = useState<boolean>(initial.stopped); const [elapsed, setElapsed] = useState(0); const scrollRef = useRef<HTMLDivElement | null>(null); - const seqRef = useRef(0); + + // The review whose agent this panel controls, when it is the current object. + // AgentPanel is mounted per workspace, so `activeReview.pr === objectId`. + const activeReview = useAppStore((s) => s.activeReview); + const killAgent = useAppStore((s) => s.killAgent); + const reviewForObject = activeReview?.pr === objectId ? activeReview : null; + const agentAttached = reviewForObject?.agent != null; + const logPath = reviewForObject?.agent?.log_path ?? null; const phase = runPhase(entries); const isRunning = phase === "running"; + // The first event's timestamp is the run anchor for elapsed stamps; null + // until any event has arrived. + const anchor = entries[0]?.timestamp ?? null; // Count tool uses and total thinking tokens for the header telemetry. let toolCount = 0; @@ -211,46 +340,41 @@ export function AgentPanel({ visible, onClose }: AgentPanelProps) { } } + // Keep the current object id reachable from the (mount-once) event listener. + const objectIdRef = useRef(objectId); + useEffect(() => { + objectIdRef.current = objectId; + }, [objectId]); + + // Load the buffered timeline whenever the shown object changes. + useEffect(() => { + const t = timelineFor(objectId); + setEntries(t.entries); + setStopped(t.stopped); + setElapsed(0); + }, [objectId]); + // Elapsed timer while running. useEffect(() => { - if (!isRunning || startTime === null) return; + if (!isRunning || anchor === null) return; const interval = setInterval(() => { - setElapsed(Date.now() - startTime); + setElapsed(Date.now() - anchor); }, 200); return () => { clearInterval(interval); }; - }, [isRunning, startTime]); + }, [isRunning, anchor]); - // Listen for agent events from Tauri. + // Listen for agent events from Tauri. Registered once; every event is + // buffered under its own object_id and only the shown object updates state. useEffect(() => { - const unlisten = listen<Event>("agent-event", (tauriEvent) => { - const event = tauriEvent.payload; - const now = Date.now(); - setStartTime((prev) => prev ?? now); - - // Thinking events collapse in place: keep the latest token count on a - // single row instead of spamming the rail. - if (event.kind === "Thinking") { - setEntries((prev) => { - const lastIdx = prev.length - 1; - const last = lastIdx >= 0 ? prev[lastIdx] : undefined; - if (last !== undefined && last.event.kind === "Thinking") { - const updated = [...prev]; - updated[lastIdx] = { ...last, event, timestamp: now }; - return updated; - } - seqRef.current += 1; - return [...prev, { seq: seqRef.current, event, timestamp: now }]; - }); - return; + const unlisten = listen<AgentEventEnvelope>("agent-event", (tauriEvent) => { + const { object_id, event } = tauriEvent.payload; + const next = appendEvent(object_id, event, Date.now()); + if (object_id === objectIdRef.current) { + setEntries(next.entries); + setStopped(next.stopped); } - - seqRef.current += 1; - setEntries((prev) => [ - ...prev, - { seq: seqRef.current, event, timestamp: now }, - ]); }); return () => { @@ -269,18 +393,49 @@ export function AgentPanel({ visible, onClose }: AgentPanelProps) { }, [entries]); const handleClear = useCallback(() => { + clearTimeline(objectId); setEntries([]); - seqRef.current = 0; - setStartTime(null); + setStopped(false); setElapsed(0); - }, []); + }, [objectId]); + + const handleStop = useCallback(async () => { + const confirmed = window.confirm( + `Stop the agent working on ${objectId}?`, + ); + if (!confirmed) return; + await killAgent(objectId); + // Only mark the timeline stopped when the kill actually settled the review + // (its agent handle was cleared). A failed kill leaves the agent running + // and surfaces via the store error, so the timeline is left untouched. + const settled = useAppStore.getState().activeReview; + if (settled?.pr === objectId && settled.agent != null) return; + const next = appendStopped(objectId, Date.now()); + setEntries(next.entries); + setStopped(next.stopped); + }, [objectId, killAgent]); + + const handleOpenLog = useCallback(() => { + if (logPath === null) return; + // Open the local log file in the configured editor. `open_in_editor` + // resolves an absolute path as-is and uses existing capabilities (no + // opener-plugin URL scheme needed for a local file). + void invoke("open_in_editor", { + filePath: logPath, + repoSlug: null, + branch: null, + }); + }, [logPath]); if (!visible) return null; - const modeLabel = phase === "failed" ? "failed" : isRunning ? "running" : "idle"; - // Once any event has arrived, startTime is set; captured here so the render - // path never needs a non-null assertion. - const anchor = startTime; + const modeLabel = stopped + ? "stopped" + : phase === "failed" + ? "failed" + : isRunning + ? "running" + : "idle"; return ( <div className="flex h-full min-h-0 flex-col bg-card"> @@ -310,6 +465,28 @@ export function AgentPanel({ visible, onClose }: AgentPanelProps) { )} </span> )} + {logPath !== null && ( + <button + type="button" + onClick={handleOpenLog} + title="Open the agent log in your editor" + className="flex cursor-pointer items-center gap-1 border-none bg-transparent text-xs text-muted-foreground hover:text-foreground" + > + <FileText className="h-3.5 w-3.5" /> + Open log + </button> + )} + {agentAttached && ( + <button + type="button" + onClick={() => void handleStop()} + title="Stop the running agent" + className="flex cursor-pointer items-center gap-1 border-none bg-transparent text-xs text-danger hover:text-danger/80" + > + <Square className="h-3.5 w-3.5" /> + Stop + </button> + )} <button type="button" onClick={handleClear} @@ -350,7 +527,7 @@ export function AgentPanel({ visible, onClose }: AgentPanelProps) { ))} </ol> {(phase === "complete" || phase === "failed") && ( - <EndBanner phase={phase} /> + <EndBanner phase={phase} stopped={stopped} /> )} </> )} diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx index 76a8417..26bea8e 100644 --- a/app/src/components/CommandPalette.tsx +++ b/app/src/components/CommandPalette.tsx @@ -71,7 +71,6 @@ export function CommandPalette({ const navigateToSettings = useAppStore((s) => s.navigateToSettings); const navigateToDiff = useAppStore((s) => s.navigateToDiff); const fetchReviews = useAppStore((s) => s.fetchReviews); - const fetchFrontier = useAppStore((s) => s.fetchFrontier); const fetchAuthoredPrs = useAppStore((s) => s.fetchAuthoredPrs); const close = useCallback(() => { @@ -181,7 +180,6 @@ export function CommandPalette({ onSelect={() => { handleSelect(() => { void fetchReviews(); - void fetchFrontier(); void fetchAuthoredPrs(); }); }} diff --git a/app/src/components/DiffView.tsx b/app/src/components/DiffView.tsx index 6d152c6..bf57ba6 100644 --- a/app/src/components/DiffView.tsx +++ b/app/src/components/DiffView.tsx @@ -29,9 +29,17 @@ import type { Comment } from "../bindings/Comment"; import type { CommentOrigin } from "../bindings/CommentOrigin"; import type { MirrorResult } from "../bindings/MirrorResult"; import type { Anchor } from "../bindings/Anchor"; +import type { DiffSide } from "../bindings/DiffSide"; import type { CiSummary } from "../bindings/CiSummary"; import { summarizeChecks, ciState, parseCiUpdate } from "@/lib/ci"; -import { parseDiff, extractFilePaths } from "../diff-parser"; +import type { ReviewEvent } from "../bindings/ReviewEvent"; +import type { SubmitReviewResult } from "../bindings/SubmitReviewResult"; +import { + parseDiff, + extractFilePaths, + fragmentToReal, + realToFragment, +} from "../diff-parser"; import type { FileDiff } from "../diff-parser"; import { elapsedSince } from "@/lib/relative-time"; import { useAppStore } from "../store"; @@ -40,6 +48,9 @@ import { attachLspClient, type LspAttachment } from "@/lib/lsp-client"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { GatePill } from "./GatePill"; +import { IntentPanel } from "./IntentPanel"; +import { AddressedRequests } from "./AddressedRequests"; +import { SubmitReviewControl } from "./SubmitReviewControl"; import { cn } from "@/lib/utils"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; @@ -59,6 +70,8 @@ import { Loader2, Wrench, Layers, + Check, + GitMerge, } from "lucide-react"; // --------------------------------------------------------------------------- @@ -74,6 +87,7 @@ interface DiffViewProps { lineStart: number, lineEnd: number, body: string, + side: DiffSide, ) => Promise<void>; readonly onRequestChanges: () => Promise<void>; readonly onMirrorComments: () => Promise<MirrorResult | null>; @@ -88,7 +102,13 @@ interface DiffViewProps { interface PortalEntry { readonly key: string; readonly domNode: HTMLDivElement; + /** + * The REAL file line, used for display and as the comment anchor when + * submitting (D1). Zone placement uses the fragment line instead. + */ readonly lineNumber: number; + /** Which diff side this zone lives on: `New` (modified) or `Old` (original). */ + readonly side: DiffSide; readonly comments: readonly Comment[]; readonly hasInput: boolean; } @@ -114,6 +134,8 @@ function gateLedColorClass(state: GateState): string { return "bg-state-reworked"; case "Approved": return "bg-state-approved"; + case "Merged": + return "bg-state-approved"; default: return assertNever(state); } @@ -188,7 +210,9 @@ function repoUrl(slug: string): string | null { function isDiffLineAnchor( anchor: Anchor, -): anchor is { readonly DiffLine: { path: string; range: [number, number] } } { +): anchor is { + readonly DiffLine: { path: string; range: [number, number]; side: DiffSide }; +} { return "DiffLine" in anchor; } @@ -208,6 +232,17 @@ function anchorRange( return null; } +/** + * Which diff side a comment anchors to. Defaults to `New` for non-diff anchors + * and legacy data (matching the server-side `serde` default). + */ +function anchorSide(anchor: Anchor): DiffSide { + if (isDiffLineAnchor(anchor)) { + return anchor.DiffLine.side; + } + return "New"; +} + function getFileDiff( fileDiffs: readonly FileDiff[], path: string, @@ -312,12 +347,14 @@ function detectLanguage(filePath: string): string { function InlineCommentThread({ comments, lineNumber, + side, hasInput, onSubmit, onCancel, }: { readonly comments: readonly Comment[]; readonly lineNumber: number; + readonly side: DiffSide; readonly hasInput: boolean; readonly onSubmit: (body: string) => void; readonly onCancel: () => void; @@ -405,7 +442,8 @@ function InlineCommentThread({ /> <div className="flex items-center justify-between mt-1.5"> <span className="text-[10px] text-muted-foreground"> - Line {String(lineNumber)} + {side === "Old" ? "old line " : "Line "} + {String(lineNumber)} </span> <div className="flex gap-1.5"> <Button @@ -453,9 +491,30 @@ export function DiffView({ const lspRootPath = useAppStore((s) => s.config?.repo_path ?? null); const lspEnabled = useAppStore((s) => s.config?.lsp_servers.enabled ?? true); + // -- Close-the-loop store actions (D2 / D9 / D10) -- + const approveReview = useAppStore((s) => s.approveReview); + const mergeReview = useAppStore((s) => s.mergeReview); + const submitGithubReview = useAppStore((s) => s.submitGithubReview); + const fetchInterdiff = useAppStore((s) => s.fetchInterdiff); + + // -- D10: interdiff (changes since the last review dispatch) -- + // Only a reworked review with a dispatch snapshot has a meaningful interdiff. + const hasInterdiff = + review.gate_state === "Reworked" && review.dispatch_snapshot != null; + const [interdiff, setInterdiff] = useState<DiffData | null>(null); + const [diffSource, setDiffSource] = useState<"interdiff" | "full">("full"); + + // The raw diff currently shown: the interdiff when selected and available, + // otherwise the full review diff. + const activeDiffRaw = + diffSource === "interdiff" && interdiff !== null ? interdiff.raw : diff.raw; + // -- Diff parsing -- - const fileDiffs = useMemo(() => parseDiff(diff.raw), [diff.raw]); - const filePaths = useMemo(() => extractFilePaths(diff.raw), [diff.raw]); + const fileDiffs = useMemo(() => parseDiff(activeDiffRaw), [activeDiffRaw]); + const filePaths = useMemo( + () => extractFilePaths(activeDiffRaw), + [activeDiffRaw], + ); // -- Navigation / display state -- const [selectedFile, setSelectedFile] = useState<string>( @@ -467,9 +526,12 @@ export function DiffView({ const [submitting, setSubmitting] = useState(false); // -- Inline comment state -- - const [activeCommentLine, setActiveCommentLine] = useState<number | null>( - null, - ); + // Holds the FRAGMENT (Monaco) line and which diff side its editor belongs to, + // so the New (modified) and Old (original) gutters each open their own form. + const [activeComment, setActiveComment] = useState<{ + readonly side: DiffSide; + readonly line: number; + } | null>(null); const [editorReady, setEditorReady] = useState(false); const [portals, setPortals] = useState<readonly PortalEntry[]>([]); @@ -488,16 +550,38 @@ export function DiffView({ const restackPr = useAppStore((s) => s.restackPr); const [restacking, setRestacking] = useState(false); + // -- D2: approve / merge state -- + const [approving, setApproving] = useState(false); + const [merging, setMerging] = useState(false); + + // -- D9: GitHub review submission state -- + const [githubSubmitting, setGithubSubmitting] = useState(false); + const [githubSubmitResult, setGithubSubmitResult] = + useState<SubmitReviewResult | null>(null); + + // -- D4: per-review Intent disclosure open state (component-only memory) -- + const [intentOpenByPr, setIntentOpenByPr] = useState< + Readonly<Record<string, boolean>> + >({}); + + // -- D10: Addressed-requests panel open state (open by default on entry) -- + const [addressedOpen, setAddressedOpen] = useState(true); + // -- Refs -- const activeFileRef = useRef<HTMLButtonElement | null>(null); const diffEditorRef = useRef<MonacoDiffEditor | null>(null); const monacoRef = useRef<Monaco | null>(null); const zoneIdsRef = useRef<string[]>([]); + const originalZoneIdsRef = useRef<string[]>([]); const domNodeCacheRef = useRef<Map<string, HTMLDivElement>>(new Map()); const glyphDecorRef = useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null); const commentDecorRef = useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null); + const originalGlyphDecorRef = + useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null); + const originalCommentDecorRef = + useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null); const lspAttachmentRef = useRef<LspAttachment | null>(null); // -- Derived -- @@ -506,6 +590,14 @@ export function DiffView({ [fileDiffs, selectedFile], ); + // Keep the current file diff reachable from the (mount-time) Monaco mouse + // handlers, which capture nothing else from render scope. Used to map a + // clicked fragment line to its real file line for the comment anchor (D1). + const currentFileDiffRef = useRef(currentFileDiff); + useEffect(() => { + currentFileDiffRef.current = currentFileDiff; + }, [currentFileDiff]); + const commentsForFile = useMemo( () => fileComments(review.comments, selectedFile), [review.comments, selectedFile], @@ -522,7 +614,7 @@ export function DiffView({ const canAddComments = review.gate_state === "InReview"; // Only open the input form when the review is InReview - const effectiveInputLine = canAddComments ? activeCommentLine : null; + const effectiveActiveComment = canAddComments ? activeComment : null; // -- Relocated PR-info: external reference links -- const prHref = useMemo(() => prUrl(review.pr), [review.pr]); @@ -538,11 +630,60 @@ export function DiffView({ // -- Agent reason line (replaces the raw PID readout) -- const agentReason = useMemo(() => agentReasonLine(review), [review]); + // -- D4: PR intent header + collapsible disclosure -- + const headerTitle = review.title.trim() !== "" ? review.title : review.branch; + const showIntent = review.title.trim() !== "" || review.body.trim() !== ""; + const intentOpen = intentOpenByPr[review.pr] ?? false; + const toggleIntent = useCallback(() => { + setIntentOpenByPr((prev) => ({ + ...prev, + [review.pr]: !(prev[review.pr] ?? false), + })); + }, [review.pr]); + + // -- D10: read-only history of what was asked for in the last cycle -- + const addressedComments = review.dispatch_snapshot?.comments ?? []; + const showAddressed = hasInterdiff && addressedComments.length > 0; + // -- Close inline form on file change -- useEffect(() => { - setActiveCommentLine(null); + setActiveComment(null); }, [selectedFile]); + // -- D10: default a reworked review to its interdiff and fetch it. A fetch + // failure falls back to the full diff (the store surfaces the error). -- + const reviewPr = review.pr; + useEffect(() => { + if (!hasInterdiff) { + setInterdiff(null); + setDiffSource("full"); + return; + } + setDiffSource("interdiff"); + let cancelled = false; + void fetchInterdiff(reviewPr).then((data) => { + if (cancelled) return; + if (data === null) { + // Fetch failed; keep the full diff visible. + setDiffSource("full"); + return; + } + setInterdiff(data); + }); + return () => { + cancelled = true; + }; + }, [reviewPr, hasInterdiff, fetchInterdiff]); + + // -- Keep the selected file valid when the file set changes (e.g. toggling + // between the interdiff and the full diff). -- + useEffect(() => { + if (filePaths.length === 0) return; + if (!filePaths.includes(selectedFile)) { + setSelectedFile(filePaths[0] ?? ""); + } + }, [filePaths, selectedFile]); + // -- Keyboard shortcut: `m` toggles the file tree sidebar -- useEffect(() => { function handleKeyDown(e: KeyboardEvent): void { @@ -615,7 +756,9 @@ export function DiffView({ } }); - // Click on gutter: toggle inline comment form + // Click on gutter: toggle inline comment form. `activeComment` holds the + // FRAGMENT (Monaco) line + side for zone placement; the real file line is + // resolved when the comment is submitted (D1). modified.onMouseDown((e) => { const target = e.target; const isGutterClick = @@ -624,7 +767,71 @@ export function DiffView({ if (isGutterClick && target.position != null) { const line = target.position.lineNumber; - setActiveCommentLine((prev) => (prev === line ? null : line)); + // D1: only lines that map to a real file line are commentable. This + // should always hold for a clickable gutter line; guard anyway. + if ( + fragmentToReal(currentFileDiffRef.current, "New", line) === undefined + ) { + return; + } + setActiveComment((prev) => + prev !== null && prev.side === "New" && prev.line === line + ? null + : { side: "New", line }, + ); + } + }); + + // -- Original (left/old-side) editor: mirror the hover + click gutter so + // reviewers can comment on removed / pre-change lines (D12). Old-side + // comments render in this editor; New-side stay in the modified editor. -- + const original = editor.getOriginalEditor(); + original.updateOptions({ glyphMargin: true }); + + originalGlyphDecorRef.current = original.createDecorationsCollection([]); + originalCommentDecorRef.current = original.createDecorationsCollection([]); + + original.onMouseMove((e) => { + if (originalGlyphDecorRef.current == null) return; + const target = e.target; + const isHoverArea = + target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN || + target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS || + target.type === + monaco.editor.MouseTargetType.GUTTER_LINE_DECORATIONS || + target.type === monaco.editor.MouseTargetType.CONTENT_TEXT; + + if (isHoverArea && target.position != null) { + const ln = target.position.lineNumber; + originalGlyphDecorRef.current.set([ + { + range: new monaco.Range(ln, 1, ln, 1), + options: { glyphMarginClassName: "inline-comment-glyph" }, + }, + ]); + } else { + originalGlyphDecorRef.current.clear(); + } + }); + + original.onMouseDown((e) => { + const target = e.target; + const isGutterClick = + target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN || + target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS; + + if (isGutterClick && target.position != null) { + const line = target.position.lineNumber; + if ( + fragmentToReal(currentFileDiffRef.current, "Old", line) === undefined + ) { + return; + } + setActiveComment((prev) => + prev !== null && prev.side === "Old" && prev.line === line + ? null + : { side: "Old", line }, + ); } }); @@ -635,7 +842,9 @@ export function DiffView({ // -- Sync view zones with comments + active input line -- // useEffect (not useLayoutEffect) so zones are created AFTER Monaco's - // internal useEffect updates the editor models on file switch. + // internal useEffect updates the editor models on file switch. Both sides are + // synced: New-side comments live in the modified editor, Old-side comments in + // the original editor (D12). useEffect(() => { if ( !editorReady || @@ -645,99 +854,153 @@ export function DiffView({ return; } - const modified = diffEditorRef.current.getModifiedEditor(); const monaco = monacoRef.current; - // Re-apply glyph margin after mode changes - modified.updateOptions({ glyphMargin: true }); - - // Group comments by their end line - const commentsByLine = new Map<number, Comment[]>(); - for (const c of commentsForFile) { - const range = anchorRange(c.anchor); - if (range != null) { - const line = range[1]; - const arr = commentsByLine.get(line) ?? []; + // Build (and place) the view zones for one diff side in its editor, + // returning the portal entries and the DOM-node cache keys it used. Anchors + // store REAL file lines (D1), so each is translated back to the current + // fragment; comments whose real line is not present in the fragment (e.g. + // after a diff refresh, or an interdiff that no longer touches that line) + // are skipped gracefully. + const syncSide = ( + ed: MonacoEditorNs.IStandaloneCodeEditor, + side: DiffSide, + zoneIds: { current: string[] }, + commentDecor: MonacoEditorNs.IEditorDecorationsCollection | null, + ): { readonly portals: PortalEntry[]; readonly usedKeys: Set<string> } => { + // Re-apply glyph margin after mode changes. + ed.updateOptions({ glyphMargin: true }); + + const commentsByLine = new Map<number, Comment[]>(); + for (const c of commentsForFile) { + if (anchorSide(c.anchor) !== side) continue; + const range = anchorRange(c.anchor); + if (range === null) continue; + const fragLine = realToFragment(currentFileDiff, side, range[1]); + if (fragLine === undefined) continue; + const arr = commentsByLine.get(fragLine) ?? []; arr.push(c); - commentsByLine.set(line, arr); + commentsByLine.set(fragLine, arr); } - } - // All lines that need a view zone - const zoneLines = new Set<number>(commentsByLine.keys()); - if (effectiveInputLine != null) { - zoneLines.add(effectiveInputLine); - } + const inputLine = + effectiveActiveComment?.side === side + ? effectiveActiveComment.line + : null; - const newPortals: PortalEntry[] = []; - const usedKeys = new Set<string>(); - - modified.changeViewZones((accessor) => { - // Remove all previous zones - for (const id of zoneIdsRef.current) { - accessor.removeZone(id); + const zoneLines = new Set<number>(commentsByLine.keys()); + if (inputLine != null) { + zoneLines.add(inputLine); } - zoneIdsRef.current = []; - - // Create zones for each line - for (const line of Array.from(zoneLines).sort((a, b) => a - b)) { - const comments = commentsByLine.get(line) ?? []; - const hasInput = line === effectiveInputLine; - - const commentHeight = comments.length * 52; - const inputHeight = hasInput ? 130 : 0; - const padding = - comments.length > 0 || hasInput ? 8 : 0; - const totalHeight = commentHeight + inputHeight + padding; - - if (totalHeight === 0) continue; - - const key = `zone-${String(line)}`; - usedKeys.add(key); - - // Reuse DOM nodes so React portals keep component state - let domNode = domNodeCacheRef.current.get(key); - if (domNode == null) { - domNode = document.createElement("div"); - domNode.style.zIndex = "10"; - domNodeCacheRef.current.set(key, domNode); - } - const zoneId = accessor.addZone({ - afterLineNumber: line, - heightInPx: totalHeight, - domNode, - suppressMouseDown: false, - }); + const portals: PortalEntry[] = []; + const usedKeys = new Set<string>(); - zoneIdsRef.current.push(zoneId); - newPortals.push({ key, domNode, lineNumber: line, comments, hasInput }); + ed.changeViewZones((accessor) => { + for (const id of zoneIds.current) { + accessor.removeZone(id); + } + zoneIds.current = []; + + for (const line of Array.from(zoneLines).sort((a, b) => a - b)) { + const comments = commentsByLine.get(line) ?? []; + const hasInput = line === inputLine; + + const commentHeight = comments.length * 52; + const inputHeight = hasInput ? 130 : 0; + const padding = comments.length > 0 || hasInput ? 8 : 0; + const totalHeight = commentHeight + inputHeight + padding; + + if (totalHeight === 0) continue; + + // Key by side so the two editors never collide in the DOM cache. + const key = `zone-${side}-${String(line)}`; + usedKeys.add(key); + + // Reuse DOM nodes so React portals keep component state. + let domNode = domNodeCacheRef.current.get(key); + if (domNode == null) { + domNode = document.createElement("div"); + domNode.style.zIndex = "10"; + domNodeCacheRef.current.set(key, domNode); + } + + const zoneId = accessor.addZone({ + afterLineNumber: line, + heightInPx: totalHeight, + domNode, + suppressMouseDown: false, + }); + + // Display + anchor use the real file line; placement used the fragment. + const realLine = fragmentToReal(currentFileDiff, side, line) ?? line; + zoneIds.current.push(zoneId); + portals.push({ + key, + domNode, + lineNumber: realLine, + side, + comments, + hasInput, + }); + } + }); + + // Highlight lines that have comments on this side. + if (commentDecor != null) { + commentDecor.set( + Array.from(commentsByLine.keys()).map((line) => ({ + range: new monaco.Range(line, 1, line, 1), + options: { + isWholeLine: true, + className: "inline-comment-line-bg", + glyphMarginClassName: "inline-comment-line-glyph", + }, + })), + ); } - }); - // Purge stale cached DOM nodes + return { portals, usedKeys }; + }; + + const modified = diffEditorRef.current.getModifiedEditor(); + const original = diffEditorRef.current.getOriginalEditor(); + + const newResult = syncSide( + modified, + "New", + zoneIdsRef, + commentDecorRef.current, + ); + const oldResult = syncSide( + original, + "Old", + originalZoneIdsRef, + originalCommentDecorRef.current, + ); + + const newPortals = [...newResult.portals, ...oldResult.portals]; + + // Purge stale cached DOM nodes across both sides. + const usedKeys = new Set<string>([ + ...newResult.usedKeys, + ...oldResult.usedKeys, + ]); for (const cachedKey of domNodeCacheRef.current.keys()) { if (!usedKeys.has(cachedKey)) { domNodeCacheRef.current.delete(cachedKey); } } - // Highlight lines that have comments - if (commentDecorRef.current != null) { - commentDecorRef.current.set( - Array.from(commentsByLine.keys()).map((line) => ({ - range: new monaco.Range(line, 1, line, 1), - options: { - isWholeLine: true, - className: "inline-comment-line-bg", - glyphMarginClassName: "inline-comment-line-glyph", - }, - })), - ); - } - setPortals(newPortals); - }, [editorReady, commentsForFile, effectiveInputLine, selectedFile, diffMode]); + }, [ + editorReady, + commentsForFile, + currentFileDiff, + effectiveActiveComment, + selectedFile, + diffMode, + ]); // -- LSP: attach a language client to the modified (right-hand) model -- // Runs once the editor is ready and whenever the selected file (and thus its @@ -791,12 +1054,12 @@ export function DiffView({ // -- Handlers -- const handleInlineSubmit = useCallback( - async (lineNumber: number, body: string) => { + async (lineNumber: number, body: string, side: DiffSide) => { setSubmitting(true); setCommentError(null); try { - await onAddComment(selectedFile, lineNumber, lineNumber, body); - setActiveCommentLine(null); + await onAddComment(selectedFile, lineNumber, lineNumber, body, side); + setActiveComment(null); } catch (e: unknown) { setCommentError(String(e)); } finally { @@ -885,24 +1148,76 @@ export function DiffView({ } }, [restackPr, prRef]); - const handleSubmitReview = useCallback(async () => { - const commentCount = review.comments.filter((c) => isLocalOrigin(c.origin)).length; - if (commentCount === 0) return; + // -- D2: approve is a local gate transition (InReview -> Approved); no + // confirm, since it publishes nothing. -- + const handleApprove = useCallback(async () => { + setApproving(true); + try { + await approveReview(review.pr); + } finally { + setApproving(false); + } + }, [approveReview, review.pr]); + // -- D2: merge is a guarded, confirmed side effect (Invariant 5 / §9). -- + const handleMerge = useCallback(async () => { const confirmed = window.confirm( - `Post ${String(commentCount)} comment${commentCount !== 1 ? "s" : ""} to GitHub?`, + `Squash-merge ${review.pr} on GitHub and delete the branch?`, ); if (!confirmed) return; - - setSubmitting(true); - setMirrorResult(null); + setMerging(true); try { - const result = await onMirrorComments(); - setMirrorResult(result); + await mergeReview(review.pr); } finally { - setSubmitting(false); + setMerging(false); } - }, [review.comments, onMirrorComments]); + }, [mergeReview, review.pr]); + + // -- D9: the inline Local comments carried by a submitted GitHub review. -- + const localCommentCount = useMemo( + () => review.comments.filter((c) => isLocalOrigin(c.origin)).length, + [review.comments], + ); + + const verdictLabel = useCallback((event: ReviewEvent): string => { + switch (event) { + case "Approve": + return "Approve"; + case "RequestChanges": + return "Request changes"; + case "Comment": + return "Comment"; + default: + return assertNever(event); + } + }, []); + + // -- D9: submit a real GitHub PR review (guarded, confirmed side effect; + // Invariant 5 / §9). Returns whether the review was posted so the control can + // close its popover. -- + const handleGithubSubmit = useCallback( + async (event: ReviewEvent, body: string): Promise<boolean> => { + const confirmed = window.confirm( + `Post review (${verdictLabel(event)}, ${String(localCommentCount)} line comment${ + localCommentCount === 1 ? "" : "s" + }) to GitHub?`, + ); + if (!confirmed) return false; + setGithubSubmitting(true); + setGithubSubmitResult(null); + try { + const result = await submitGithubReview(review.pr, event, body); + if (result !== null) { + setGithubSubmitResult(result); + return true; + } + return false; + } finally { + setGithubSubmitting(false); + } + }, + [submitGithubReview, review.pr, localCommentCount, verdictLabel], + ); // ========================================================================= // Render @@ -933,8 +1248,11 @@ export function DiffView({ <div className="flex min-w-0 flex-col"> {/* Title row: PR subject + gate-state pill + inline flags. */} <div className="flex min-w-0 items-center gap-2"> - <span className="truncate font-display text-sm font-semibold text-foreground"> - {review.branch} + <span + className="truncate font-display text-sm font-semibold text-foreground" + title={headerTitle} + > + {headerTitle} </span> <GatePill state={review.gate_state} /> {review.stale && ( @@ -944,8 +1262,16 @@ export function DiffView({ )} </div> - {/* Refs line: mono, faint; PR / issue / repo links via opener. */} + {/* Refs line: mono, faint; branch + PR / issue / repo links. */} <div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-0.5 font-mono text-xs text-muted-foreground"> + <span + className="inline-flex min-w-0 shrink items-center gap-1" + title={review.branch} + > + <GitBranch className="h-3 w-3 shrink-0" /> + <span className="truncate">{review.branch}</span> + </span> + {prHref !== null ? ( <a href={prHref} @@ -1050,6 +1376,45 @@ export function DiffView({ </button> </div> + {/* Diff-source toggle: interdiff vs full diff (D10). */} + {hasInterdiff && ( + <div className="flex overflow-hidden rounded-md border border-border"> + <button + type="button" + onClick={() => { + setDiffSource("interdiff"); + }} + aria-pressed={diffSource === "interdiff"} + disabled={interdiff === null} + className={cn( + "cursor-pointer border-none px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50", + diffSource === "interdiff" + ? "bg-accent text-accent-foreground" + : "bg-transparent text-muted-foreground hover:bg-accent/50", + )} + title="Show only what changed since your last review" + > + Since your review + </button> + <button + type="button" + onClick={() => { + setDiffSource("full"); + }} + aria-pressed={diffSource === "full"} + className={cn( + "cursor-pointer border-none px-3 py-1 text-xs font-medium transition-colors", + diffSource === "full" + ? "bg-accent text-accent-foreground" + : "bg-transparent text-muted-foreground hover:bg-accent/50", + )} + title="Show the full PR diff" + > + Full diff + </button> + </div> + )} + {/* CI checks badge. */} {ciSummary !== null && ciSummary.total > 0 && ( <span @@ -1149,7 +1514,18 @@ export function DiffView({ <Button variant="outline" size="sm" - onClick={() => void handleMirrorComments()} + onClick={() => { + // Outward publish — explicit confirmation per SPEC §12. + if ( + window.confirm( + `Post ${String(localCommentCount)} comment${ + localCommentCount === 1 ? "" : "s" + } to the GitHub PR thread?`, + ) + ) { + void handleMirrorComments(); + } + }} disabled={mirroring} title="Mirror local comments to the GitHub PR thread" > @@ -1160,33 +1536,95 @@ export function DiffView({ </div> {/* Primary action — one clear call to action by state. */} - {review.source === "ReviewRequested" - ? review.gate_state === "InReview" && - review.comments.length > 0 && ( + {review.source === "ReviewRequested" ? ( + // D9: review-requested PRs publish a real GitHub review. + review.gate_state !== "Merged" && ( + <SubmitReviewControl + commentCount={localCommentCount} + pending={githubSubmitting} + onSubmit={handleGithubSubmit} + /> + ) + ) : ( + // D2: authored reviews close the loop locally — request changes / + // approve while InReview, merge once Approved, read-only when Merged. + <> + {review.gate_state === "InReview" && ( + <> + {canRequestChanges && ( + <Button + variant="destructive" + size="sm" + onClick={() => void handleRequestChanges()} + disabled={submitting} + > + <Send className="h-3.5 w-3.5" /> + Request Changes ({String(review.comments.length)}) + </Button> + )} + <Button + size="sm" + onClick={() => void handleApprove()} + disabled={approving} + > + <Check className="h-3.5 w-3.5" /> + {approving ? "Approving…" : "Approve"} + </Button> + </> + )} + + {review.gate_state === "Approved" && ( <Button size="sm" className="bg-success text-white hover:bg-success/90" - onClick={() => void handleSubmitReview()} - disabled={submitting} + onClick={() => void handleMerge()} + disabled={merging} > - <Upload className="h-3.5 w-3.5" /> - Submit Review ({String(review.comments.length)}) - </Button> - ) - : canRequestChanges && ( - <Button - variant="destructive" - size="sm" - onClick={() => void handleRequestChanges()} - disabled={submitting} - > - <Send className="h-3.5 w-3.5" /> - Request Changes ({String(review.comments.length)}) + <GitMerge className="h-3.5 w-3.5" /> + {merging ? "Merging…" : "Merge PR"} </Button> )} + + {review.gate_state === "Merged" && ( + <span className="inline-flex items-center gap-1.5 rounded-md border border-success/30 bg-success/15 px-2.5 py-1 text-xs font-medium text-success"> + <CheckCircle2 className="h-3.5 w-3.5" /> + Merged + </span> + )} + </> + )} </div> </header> + {/* ----------------------------------------------------------------- */} + {/* Intent disclosure (D4) */} + {/* ----------------------------------------------------------------- */} + {showIntent && ( + <IntentPanel + body={review.body} + issue={review.issue} + issueHref={issueHref} + open={intentOpen} + onToggle={toggleIntent} + onOpenIssue={(href) => { + void openExternal(href); + }} + /> + )} + + {/* ----------------------------------------------------------------- */} + {/* Addressed requests — read-only interdiff history (D10) */} + {/* ----------------------------------------------------------------- */} + {showAddressed && ( + <AddressedRequests + comments={addressedComments} + open={addressedOpen} + onToggle={() => { + setAddressedOpen((prev) => !prev); + }} + /> + )} + {/* ----------------------------------------------------------------- */} {/* Stack strip (collapsible) — relocated from PR Info tab */} {/* ----------------------------------------------------------------- */} @@ -1281,6 +1719,29 @@ export function DiffView({ </div> )} + {/* ----------------------------------------------------------------- */} + {/* GitHub review submission result (D9) */} + {/* ----------------------------------------------------------------- */} + {githubSubmitResult !== null && ( + <div className="flex items-center justify-between border-b border-success/40 bg-success/15 px-4 py-2 text-xs text-success"> + <span> + Submitted review with {String(githubSubmitResult.submitted)} line + comment{githubSubmitResult.submitted === 1 ? "" : "s"} to GitHub + {githubSubmitResult.skipped.length > 0 && + ` · ${String(githubSubmitResult.skipped.length)} skipped`} + </span> + <button + type="button" + onClick={() => { + setGithubSubmitResult(null); + }} + className="cursor-pointer border-none bg-transparent text-success underline hover:no-underline" + > + Dismiss + </button> + </div> + )} + {/* ----------------------------------------------------------------- */} {/* File tree sidebar + Monaco Diff Editor */} {/* ----------------------------------------------------------------- */} @@ -1448,12 +1909,13 @@ export function DiffView({ <InlineCommentThread comments={entry.comments} lineNumber={entry.lineNumber} + side={entry.side} hasInput={entry.hasInput} onSubmit={(body) => { - void handleInlineSubmit(entry.lineNumber, body); + void handleInlineSubmit(entry.lineNumber, body, entry.side); }} onCancel={() => { - setActiveCommentLine(null); + setActiveComment(null); }} />, entry.domNode, diff --git a/app/src/components/GatePill.tsx b/app/src/components/GatePill.tsx index 842caec..8812eab 100644 --- a/app/src/components/GatePill.tsx +++ b/app/src/components/GatePill.tsx @@ -21,6 +21,8 @@ export function gateStateLabel(state: GateState): string { return "Reworked"; case "Approved": return "Approved"; + case "Merged": + return "Merged"; default: return assertNever(state); } @@ -43,6 +45,10 @@ export function gateToneClass(state: GateState): string { return "bg-state-reworked/20 text-state-reworked border-state-reworked/30"; case "Approved": return "bg-state-approved/20 text-state-approved border-state-approved/30"; + // Merged is terminal like Approved; reuse the approved tone until it earns + // its own design token in a later FE wave. + case "Merged": + return "bg-state-approved/20 text-state-approved border-state-approved/30"; default: return assertNever(state); } diff --git a/app/src/components/IntentPanel.tsx b/app/src/components/IntentPanel.tsx new file mode 100644 index 0000000..a01a934 --- /dev/null +++ b/app/src/components/IntentPanel.tsx @@ -0,0 +1,95 @@ +/** + * Collapsible "Intent" disclosure for the diff gate header (D4). + * + * Surfaces the PR description (`review.body`) as plain preformatted text — the + * body is untrusted, so it is rendered as text, never as markdown-derived HTML — + * together with the Linear issue reference. Collapsed by default; the open state + * is owned by the parent so it can be remembered per review. + */ + +import { ChevronDown, ChevronRight, FileText, Hash } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface IntentPanelProps { + /** The PR description body. May be empty. */ + readonly body: string; + /** The Linear issue reference (e.g. `NEX-123`). */ + readonly issue: string; + /** External URL for the issue, or null if it does not look like a Linear ref. */ + readonly issueHref: string | null; + /** Whether the disclosure is expanded. */ + readonly open: boolean; + /** Toggle the expanded state. */ + readonly onToggle: () => void; + /** Open the issue in the external browser. */ + readonly onOpenIssue: (href: string) => void; +} + +/** + * A single-band disclosure showing the PR intent (body + issue) beneath the + * diff-gate header. + */ +export function IntentPanel({ + body, + issue, + issueHref, + open, + onToggle, + onOpenIssue, +}: IntentPanelProps) { + const trimmedBody = body.trim(); + + return ( + <div className="shrink-0 border-b border-border bg-card/50"> + <button + type="button" + onClick={onToggle} + aria-expanded={open} + className={cn( + "flex w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-4 py-1.5", + "text-[10px] font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground", + )} + title="Toggle PR intent" + > + {open ? ( + <ChevronDown className="h-3 w-3" /> + ) : ( + <ChevronRight className="h-3 w-3" /> + )} + <FileText className="h-3 w-3" /> + Intent + </button> + + {open && ( + <div className="space-y-2 px-4 pb-2.5 pl-9 text-xs"> + {trimmedBody !== "" ? ( + <pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words font-sans leading-relaxed text-foreground"> + {trimmedBody} + </pre> + ) : ( + <p className="italic text-muted-foreground">No description.</p> + )} + {issueHref !== null ? ( + <a + href={issueHref} + onClick={(e) => { + e.preventDefault(); + onOpenIssue(issueHref); + }} + className="inline-flex items-center gap-1 font-mono text-muted-foreground hover:text-foreground hover:underline" + title="Open issue on Linear" + > + <Hash className="h-3 w-3" /> + {issue} + </a> + ) : ( + <span className="inline-flex items-center gap-1 font-mono text-muted-foreground"> + <Hash className="h-3 w-3" /> + {issue} + </span> + )} + </div> + )} + </div> + ); +} diff --git a/app/src/components/PlanView.tsx b/app/src/components/PlanView.tsx index b02ef26..d7934af 100644 --- a/app/src/components/PlanView.tsx +++ b/app/src/components/PlanView.tsx @@ -393,6 +393,16 @@ function GateActions({ </div> ); + // Plans never merge (Merged is a Review-only terminal state); this case + // exists only to keep the GateState switch exhaustive. + case "Merged": + return ( + <div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground"> + <CheckCheck className="h-4 w-4" /> + Merged. + </div> + ); + default: return assertNever(gateState); } diff --git a/app/src/components/ReviewCard.test.tsx b/app/src/components/ReviewCard.test.tsx index 42e6f79..d9cac86 100644 --- a/app/src/components/ReviewCard.test.tsx +++ b/app/src/components/ReviewCard.test.tsx @@ -56,6 +56,7 @@ describe("ReviewCard", () => { Dispatched: "Watch", Reworked: "Re-review", Approved: "View", + Merged: "View", }; for (const state of ALL_GATE_STATES) { const { unmount } = render( diff --git a/app/src/components/ReviewCard.tsx b/app/src/components/ReviewCard.tsx index 3214d9f..da828b8 100644 --- a/app/src/components/ReviewCard.tsx +++ b/app/src/components/ReviewCard.tsx @@ -20,6 +20,12 @@ interface ReviewCardProps { readonly onRestack: (pr: string) => void; /** Presentation density; defaults to the roomy card layout. */ readonly density?: CardDensity; + /** + * Whether the card is rendered inside a stack container. When true the text + * stack-relation hint is suppressed, since the container already shows the + * relationship visually via its rail and indentation. + */ + readonly inStack?: boolean; } function assertNever(x: never): never { @@ -39,6 +45,8 @@ function ledColorClass(state: GateState): string { return "bg-state-reworked"; case "Approved": return "bg-state-approved"; + case "Merged": + return "bg-state-approved"; default: return assertNever(state); } @@ -84,6 +92,8 @@ function actionConfigForState(state: GateState): ActionConfig { return { label: "Re-review", variant: "default", muted: false }; case "Approved": return { label: "View", variant: "ghost", muted: false }; + case "Merged": + return { label: "View", variant: "ghost", muted: false }; default: return assertNever(state); } @@ -216,11 +226,14 @@ export function ReviewCard({ onAction, onRestack, density = "cards", + inStack = false, }: ReviewCardProps) { const { repo, number: prNumber } = parsePrDisplay(review.pr); const action = actionConfigForState(review.gate_state); const signal = cardSignal(review); - const relation = stackRelation(review); + // Inside a stack container the relationship is shown by the rail/indent, so + // the redundant text hint is suppressed there but kept in flat lists. + const relation = inStack ? null : stackRelation(review); const dimmed = isDimmed(review); if (density === "compact") { diff --git a/app/src/components/ReviewWorkspace.tsx b/app/src/components/ReviewWorkspace.tsx index 0507b74..57b1fbc 100644 --- a/app/src/components/ReviewWorkspace.tsx +++ b/app/src/components/ReviewWorkspace.tsx @@ -15,11 +15,13 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import type { ShortcutMap } from "../hooks/useKeyboardShortcuts"; import type { Review } from "../bindings/Review"; import type { DiffData } from "../bindings/DiffData"; +import type { DiffSide } from "../bindings/DiffSide"; import type { MirrorResult } from "../bindings/MirrorResult"; import { DiffView } from "./DiffView"; import { CiPanel } from "./CiPanel"; import { AgentPanel } from "./AgentPanel"; import { Terminal } from "./Terminal"; +import { Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- @@ -38,6 +40,7 @@ interface ReviewWorkspaceProps { lineStart: number, lineEnd: number, body: string, + side: DiffSide, ) => Promise<void>; readonly onRequestChanges: () => Promise<void>; readonly onMirrorComments: () => Promise<MirrorResult | null>; @@ -108,6 +111,7 @@ export function ReviewWorkspace({ const [activeTab, setActiveTab] = useState<WorkspaceTab>("diff"); const error = useAppStore((s) => s.error); const clearError = useAppStore((s) => s.clearError); + const ensureReviewWorktree = useAppStore((s) => s.ensureReviewWorktree); const toggleAgentTab = useCallback(() => { setActiveTab((prev) => (prev === "agent" ? "diff" : "agent")); @@ -165,6 +169,30 @@ export function ReviewWorkspace({ [review.pr], ); + // -- Shell worktree materialization -- + // The Shell tab must root the terminal at a real checked-out worktree. For an + // imported same-repo PR that means checking the branch out on first use, which + // can take a moment (and may clone), so we resolve the cwd lazily when the tab + // is first opened for a review and show a preparing state until it lands. A + // failure falls back to the review's recorded worktree (store surfaces error). + const [shellCwd, setShellCwd] = useState<string | null>(null); + const preparedShellPrRef = useRef<string | null>(null); + useEffect(() => { + if (activeTab !== "shell") return; + if (preparedShellPrRef.current === review.pr) return; + preparedShellPrRef.current = review.pr; + setShellCwd(null); + let cancelled = false; + void (async () => { + const path = await ensureReviewWorktree(review.pr); + if (cancelled) return; + setShellCwd(path ?? review.worktree); + })(); + return () => { + cancelled = true; + }; + }, [activeTab, review.pr, review.worktree, ensureReviewWorktree]); + return ( <div className="flex h-full flex-col"> <WorkspaceTabBar active={activeTab} onSelect={setActiveTab} /> @@ -201,13 +229,24 @@ export function ReviewWorkspace({ )} {mountedTabs.has("agent") && ( <div className={cn("flex min-h-0 flex-1 flex-col", activeTab !== "agent" && "hidden")}> - <AgentPanel visible onClose={() => { setActiveTab("diff"); }} /> + <AgentPanel + visible + objectId={review.pr} + onClose={() => { setActiveTab("diff"); }} + /> </div> )} {mountedTabs.has("shell") && ( <div className={cn("flex min-h-0 flex-1 flex-col", activeTab !== "shell" && "hidden")}> <div className="flex-1 min-h-0"> - <Terminal id={shellSessionId} cwd={review.worktree} /> + {shellCwd !== null ? ( + <Terminal id={shellSessionId} cwd={shellCwd} /> + ) : ( + <div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground"> + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + Preparing worktree… + </div> + )} </div> </div> )} diff --git a/app/src/components/StackContainer.test.tsx b/app/src/components/StackContainer.test.tsx new file mode 100644 index 0000000..970e631 --- /dev/null +++ b/app/src/components/StackContainer.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { StackContainer } from "./StackContainer"; +import { buildStackTrees, flattenStack } from "../lib/stack-tree"; +import type { StackTreeNode } from "../lib/stack-tree"; +import { makeReview } from "../test/fixtures"; + +/** Build the (single) stack root from a set of reviews for the render tests. */ +function stackRoot(): StackTreeNode { + const r1 = makeReview({ + id: "r1", + branch: "parent-branch", + gate_state: "Approved", + children: ["r2"], + }); + const r2 = makeReview({ + id: "r2", + branch: "child-branch", + gate_state: "InReview", + parents: ["r1"], + }); + const root = buildStackTrees([r1, r2])[0]; + if (root === undefined) throw new Error("expected a stack root"); + return root; +} + +describe("StackContainer", () => { + it("renders a header with the member count and a health summary", () => { + const root = stackRoot(); + render( + <StackContainer + root={root} + nodes={flattenStack(root)} + density="cards" + onAction={vi.fn()} + onRestack={vi.fn()} + />, + ); + + expect( + screen.getByText( + (_, el) => el?.textContent === "STACK · 2 PRs", + ), + ).toBeInTheDocument(); + // One approved, one in review -> progress summary. + expect(screen.getByText("1/2 approved")).toBeInTheDocument(); + }); + + it("renders every member, parent before child", () => { + const root = stackRoot(); + render( + <StackContainer + root={root} + nodes={flattenStack(root)} + density="cards" + onAction={vi.fn()} + onRestack={vi.fn()} + />, + ); + + const parent = screen.getByText("parent-branch"); + const child = screen.getByText("child-branch"); + expect(parent).toBeInTheDocument(); + expect(child).toBeInTheDocument(); + // Parent-first: the parent node precedes the child in document order. + expect( + parent.compareDocumentPosition(child) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); +}); diff --git a/app/src/components/StackContainer.tsx b/app/src/components/StackContainer.tsx new file mode 100644 index 0000000..35d013d --- /dev/null +++ b/app/src/components/StackContainer.tsx @@ -0,0 +1,123 @@ +import { cn } from "@/lib/utils"; +import { ReviewCard } from "./ReviewCard"; +import type { CardDensity } from "./ReviewCard"; +import { + computeHealth, + frontierReviewId, + stackStatus, +} from "../lib/stack-tree"; +import type { FlatStackNode, StackStatusTone, StackTreeNode } from "../lib/stack-tree"; + +interface StackContainerProps { + /** The stack's root, used to derive health and the frontier highlight. */ + readonly root: StackTreeNode; + /** The stack's members in dependency order (parent first) with depth. */ + readonly nodes: readonly FlatStackNode[]; + /** Board density; forwarded to each member card. */ + readonly density: CardDensity; + readonly onAction: (pr: string) => void; + readonly onRestack: (pr: string) => void; +} + +function assertNever(x: never): never { + throw new Error(`unreachable: ${String(x)}`); +} + +/** Status-dot background class for a stack health tone. */ +function dotClass(tone: StackStatusTone): string { + switch (tone) { + case "warning": + return "bg-warning"; + case "approved": + return "bg-state-approved"; + case "progress": + return "bg-state-in-review"; + default: + return assertNever(tone); + } +} + +/** Text class paired with the status dot (so the label never reads by color alone). */ +function statusTextClass(tone: StackStatusTone): string { + switch (tone) { + case "warning": + return "text-warning"; + case "approved": + return "text-state-approved"; + case "progress": + return "text-state-in-review"; + default: + return assertNever(tone); + } +} + +/** Depth indent in pixels, capped at three visual levels. */ +function indentFor(depth: number): number { + return Math.min(depth, 3) * 16; +} + +/** + * A stack of two-or-more connected reviews rendered as one unit: a header with + * a mono "STACK · N PRs" label and a health summary (status dot + short label), + * then the members in dependency order down a left rail. The rail segment beside + * the frontier-eligible member is tinted with the brand teal. + */ +export function StackContainer({ + root, + nodes, + density, + onAction, + onRestack, +}: StackContainerProps) { + const health = computeHealth(root); + const status = stackStatus(health); + const frontierId = frontierReviewId(root); + + return ( + <div className="rounded-xl border border-border bg-card/30"> + <div className="flex items-center gap-2 px-3 pb-1.5 pt-2.5"> + <span className="font-mono text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> + STACK · {health.total} PRs + </span> + <span className="ml-auto inline-flex items-center gap-1.5"> + <span + className={cn("h-2 w-2 rounded-full", dotClass(status.tone))} + aria-hidden="true" + /> + <span className={cn("text-xs font-medium", statusTextClass(status.tone))}> + {status.label} + </span> + </span> + </div> + + <div className={cn("px-2 pb-2", density === "compact" ? "space-y-1" : "space-y-2")}> + {nodes.map(({ review, depth }) => { + const isFrontier = review.id === frontierId; + return ( + <div key={review.id} className="flex items-stretch"> + <div + className={cn( + "w-0.5 shrink-0 rounded-full", + isFrontier ? "bg-brand" : "bg-border", + )} + aria-hidden="true" + /> + <div + className="min-w-0 flex-1 pl-2.5" + style={{ marginLeft: `${String(indentFor(depth))}px` }} + > + <ReviewCard + review={review} + density={density} + onAction={onAction} + onRestack={onRestack} + inStack + /> + </div> + </div> + ); + })} + </div> + </div> + ); +} diff --git a/app/src/components/StateFilter.test.tsx b/app/src/components/StateFilter.test.tsx index 015d9dd..05ae105 100644 --- a/app/src/components/StateFilter.test.tsx +++ b/app/src/components/StateFilter.test.tsx @@ -76,6 +76,26 @@ describe("StateFilter", () => { expect(onToggleStale).toHaveBeenCalledOnce(); }); + it("exposes Merged as a filterable chip so it is never hidden silently", async () => { + const user = userEvent.setup(); + const onFilterChange = vi.fn<(s: GateState | null) => void>(); + const withMerged = [...reviews, makeReview({ pr: "e", gate_state: "Merged" })]; + render( + <StateFilter + reviews={withMerged} + activeFilter={null} + showStale={false} + onFilterChange={onFilterChange} + onToggleStale={vi.fn()} + />, + ); + + const chip = screen.getByRole("button", { name: "Merged (1)" }); + expect(chip).toBeInTheDocument(); + await user.click(chip); + expect(onFilterChange).toHaveBeenCalledWith("Merged"); + }); + it("renders zero counts for an empty review list", () => { render( <StateFilter diff --git a/app/src/components/StateFilter.tsx b/app/src/components/StateFilter.tsx index a669ba5..507e139 100644 --- a/app/src/components/StateFilter.tsx +++ b/app/src/components/StateFilter.tsx @@ -22,6 +22,7 @@ const GATE_STATES = [ "Dispatched", "Reworked", "Approved", + "Merged", ] as const satisfies readonly GateState[]; /** Tailwind classes for an active (selected) chip of the given state. */ @@ -37,6 +38,9 @@ function activeChipClass(state: GateState): string { return "bg-state-reworked text-white"; case "Approved": return "bg-state-approved text-white"; + // Merged shares the approved tone until it earns its own token. + case "Merged": + return "bg-state-approved text-white"; default: return assertNever(state); } @@ -55,6 +59,8 @@ function inactiveChipClass(state: GateState): string { return "bg-state-reworked/20 text-state-reworked"; case "Approved": return "bg-state-approved/20 text-state-approved"; + case "Merged": + return "bg-state-approved/20 text-state-approved"; default: return assertNever(state); } diff --git a/app/src/components/SubmitReviewControl.tsx b/app/src/components/SubmitReviewControl.tsx new file mode 100644 index 0000000..9a9cac2 --- /dev/null +++ b/app/src/components/SubmitReviewControl.tsx @@ -0,0 +1,157 @@ +/** + * Compact GitHub review submission control for review-requested PRs (D9). + * + * Lets the reviewer pick a verdict (Approve / Request changes / Comment) and an + * optional summary body, then submit a single real GitHub PR review carrying the + * inline Local comments. Submission is a guarded outward side effect + * (Invariant §0.5 / §9): the parent confirms with the user before publishing. + */ + +import { useState, useCallback } from "react"; +import { Upload, Check, X, MessageSquare } from "lucide-react"; +import type { ReviewEvent } from "../bindings/ReviewEvent"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +/** The selectable verdicts, in display order, mapped to `ReviewEvent`. */ +const VERDICTS = [ + { event: "Comment", label: "Comment" }, + { event: "Approve", label: "Approve" }, + { event: "RequestChanges", label: "Request changes" }, +] as const satisfies readonly { readonly event: ReviewEvent; readonly label: string }[]; + +interface SubmitReviewControlProps { + /** Number of inline Local comments that will accompany the review. */ + readonly commentCount: number; + /** Whether a submission is currently in flight. */ + readonly pending: boolean; + /** + * Submit the review. Returns `true` when the review was posted (so the popover + * closes and the body resets), `false` when the user cancelled or it failed. + */ + readonly onSubmit: (event: ReviewEvent, body: string) => Promise<boolean>; +} + +/** + * A popover-triggering "Submit Review" button that collects a verdict + summary + * and posts one GitHub PR review. + */ +export function SubmitReviewControl({ + commentCount, + pending, + onSubmit, +}: SubmitReviewControlProps) { + const [open, setOpen] = useState(false); + const [verdict, setVerdict] = useState<ReviewEvent>("Comment"); + const [body, setBody] = useState(""); + + const handleSubmit = useCallback(async () => { + const posted = await onSubmit(verdict, body); + if (posted) { + setOpen(false); + setBody(""); + } + }, [onSubmit, verdict, body]); + + return ( + <div className="relative"> + <Button + size="sm" + className="bg-success text-white hover:bg-success/90" + onClick={() => { + setOpen((prev) => !prev); + }} + aria-expanded={open} + disabled={pending} + > + <Upload className="h-3.5 w-3.5" /> + Submit Review + {commentCount > 0 && ( + <span className="ml-1 inline-flex items-center gap-0.5 tabular-nums"> + <MessageSquare className="h-3 w-3" /> + {String(commentCount)} + </span> + )} + </Button> + + {open && ( + <> + {/* Backdrop closes the popover on an outside click. */} + <button + type="button" + aria-label="Close" + onClick={() => { + setOpen(false); + }} + className="fixed inset-0 z-40 cursor-default border-none bg-transparent" + /> + <div className="absolute right-0 top-full z-50 mt-1.5 w-72 rounded-md border border-border bg-card p-2.5 shadow-lg"> + <div className="mb-2 flex items-center justify-between"> + <span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> + Verdict + </span> + <button + type="button" + onClick={() => { + setOpen(false); + }} + className="cursor-pointer border-none bg-transparent text-muted-foreground hover:text-foreground" + title="Close" + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + + <div className="mb-2 flex overflow-hidden rounded-md border border-border"> + {VERDICTS.map((option) => ( + <button + key={option.event} + type="button" + onClick={() => { + setVerdict(option.event); + }} + aria-pressed={verdict === option.event} + className={cn( + "flex-1 cursor-pointer border-none px-2 py-1 text-[11px] font-medium transition-colors", + verdict === option.event + ? "bg-accent text-accent-foreground" + : "bg-transparent text-muted-foreground hover:bg-accent/50", + )} + > + {option.label} + </button> + ))} + </div> + + <textarea + value={body} + onChange={(e) => { + setBody(e.target.value); + }} + placeholder="Optional summary comment…" + className="mb-2 min-h-[64px] w-full resize-none rounded-md border border-border bg-background p-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + rows={3} + /> + + <div className="flex items-center justify-between"> + <span className="text-[10px] text-muted-foreground"> + {commentCount > 0 + ? `${String(commentCount)} line comment${commentCount === 1 ? "" : "s"}` + : "No line comments"} + </span> + <Button + size="sm" + className="h-7 text-xs" + onClick={() => void handleSubmit()} + disabled={pending} + > + <Check className="h-3.5 w-3.5" /> + {pending ? "Posting…" : "Post to GitHub"} + </Button> + </div> + </div> + </> + )} + </div> + ); +} diff --git a/app/src/diff-parser.test.ts b/app/src/diff-parser.test.ts new file mode 100644 index 0000000..659e3a1 --- /dev/null +++ b/app/src/diff-parser.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect } from "vitest"; +import type { FileDiff } from "./diff-parser"; +import { + parseDiff, + fragmentToReal, + realToFragment, + extractFilePaths, + diffStats, +} from "./diff-parser"; + +/** Fetch the single parsed file, failing loudly if the diff produced none. */ +function only(files: readonly FileDiff[]): FileDiff { + const file = files[0]; + if (file === undefined) { + throw new Error("expected at least one parsed file"); + } + return file; +} + +describe("parseDiff line mapping — single hunk deep in a file", () => { + // Hunk starts at old line 100 / new line 100. + const raw = [ + "diff --git a/src/deep.ts b/src/deep.ts", + "index 111..222 100644", + "--- a/src/deep.ts", + "+++ b/src/deep.ts", + "@@ -100,4 +100,5 @@", + " ctx-a", + " ctx-b", + "-old-line", + "+new-line-1", + "+new-line-2", + " ctx-c", + ].join("\n"); + const file = only(parseDiff(raw)); + + it("maps new-side fragment line k to real line c+k-1", () => { + // Fragment new lines: 1 ctx-a(100), 2 ctx-b(101), 3 new-line-1(102), + // 4 new-line-2(103), 5 ctx-c(104). + expect(fragmentToReal(file, "New", 1)).toBe(100); + expect(fragmentToReal(file, "New", 3)).toBe(102); + expect(fragmentToReal(file, "New", 5)).toBe(104); + }); + + it("maps old-side fragment lines to real old lines", () => { + // Fragment old lines: 1 ctx-a(100), 2 ctx-b(101), 3 old-line(102), + // 4 ctx-c(103). + expect(fragmentToReal(file, "Old", 1)).toBe(100); + expect(fragmentToReal(file, "Old", 3)).toBe(102); + expect(fragmentToReal(file, "Old", 4)).toBe(103); + }); + + it("returns undefined past the fragment end and below line 1", () => { + expect(fragmentToReal(file, "New", 6)).toBeUndefined(); + expect(fragmentToReal(file, "New", 0)).toBeUndefined(); + expect(fragmentToReal(file, "Old", 5)).toBeUndefined(); + }); +}); + +describe("parseDiff line mapping — multiple non-contiguous hunks", () => { + // Two hunks separated by a large gap; this is the case the fragment + // reconstruction would otherwise mis-number. + const raw = [ + "diff --git a/src/multi.ts b/src/multi.ts", + "--- a/src/multi.ts", + "+++ b/src/multi.ts", + "@@ -1,3 +1,3 @@", + " a", + "-b", + "+B", + " c", + "@@ -50,3 +50,4 @@", + " x", + "+Y", + " y", + " z", + ].join("\n"); + const file = only(parseDiff(raw)); + + it("keeps first-hunk lines near the top of the file", () => { + expect(fragmentToReal(file, "New", 1)).toBe(1); // a + expect(fragmentToReal(file, "New", 2)).toBe(2); // B + expect(fragmentToReal(file, "New", 3)).toBe(3); // c + }); + + it("maps lines after the second header to real lines ~50, not ~4", () => { + // Fragment new lines continue: 4 x(50), 5 Y(51), 6 y(52), 7 z(53). + expect(fragmentToReal(file, "New", 4)).toBe(50); + expect(fragmentToReal(file, "New", 5)).toBe(51); + expect(fragmentToReal(file, "New", 7)).toBe(53); + }); + + it("maps the old side across the gap too", () => { + // Old fragment: 1 a(1), 2 b(2), 3 c(3), 4 x(50), 5 y(51), 6 z(52). + expect(fragmentToReal(file, "Old", 4)).toBe(50); + expect(fragmentToReal(file, "Old", 6)).toBe(52); + }); + + it("real lines inside the collapsed gap map to no fragment line", () => { + // Real new line 10 sits in the gap between hunk 1 (ends at 3) and hunk 2 + // (starts at 50), so it appears in no fragment. + expect(realToFragment(file, "New", 10)).toBeUndefined(); + expect(realToFragment(file, "Old", 25)).toBeUndefined(); + }); +}); + +describe("parseDiff line mapping — added file", () => { + const raw = [ + "diff --git a/src/added.ts b/src/added.ts", + "new file mode 100644", + "index 000..333 100644", + "--- /dev/null", + "+++ b/src/added.ts", + "@@ -0,0 +1,3 @@", + "+one", + "+two", + "+three", + ].join("\n"); + const file = only(parseDiff(raw)); + + it("maps the new side 1:1", () => { + expect(fragmentToReal(file, "New", 1)).toBe(1); + expect(fragmentToReal(file, "New", 3)).toBe(3); + expect(realToFragment(file, "New", 2)).toBe(2); + }); + + it("has no old side", () => { + expect(fragmentToReal(file, "Old", 1)).toBeUndefined(); + expect(realToFragment(file, "Old", 1)).toBeUndefined(); + expect(file.original).toBe(""); + }); +}); + +describe("parseDiff line mapping — deleted file", () => { + const raw = [ + "diff --git a/src/gone.ts b/src/gone.ts", + "deleted file mode 100644", + "index 444..000 100644", + "--- a/src/gone.ts", + "+++ /dev/null", + "@@ -1,3 +0,0 @@", + "-alpha", + "-beta", + "-gamma", + ].join("\n"); + const file = only(parseDiff(raw)); + + it("maps the old side 1:1", () => { + expect(fragmentToReal(file, "Old", 1)).toBe(1); + expect(fragmentToReal(file, "Old", 3)).toBe(3); + expect(realToFragment(file, "Old", 2)).toBe(2); + }); + + it("has no new side", () => { + expect(fragmentToReal(file, "New", 1)).toBeUndefined(); + expect(realToFragment(file, "New", 1)).toBeUndefined(); + expect(file.modified).toBe(""); + }); +}); + +describe("parseDiff line mapping — round trip", () => { + const raw = [ + "diff --git a/src/round.ts b/src/round.ts", + "--- a/src/round.ts", + "+++ b/src/round.ts", + "@@ -10,4 +10,4 @@", + " keep-1", + "-drop", + "+add", + " keep-2", + " keep-3", + ].join("\n"); + const file = only(parseDiff(raw)); + + it("realToFragment(fragmentToReal(x)) === x for present lines", () => { + for (const side of ["Old", "New"] as const) { + for (let frag = 1; frag <= 4; frag += 1) { + const real = fragmentToReal(file, side, frag); + expect(real).toBeDefined(); + if (real !== undefined) { + expect(realToFragment(file, side, real)).toBe(frag); + } + } + } + }); + + it("fragmentToReal(realToFragment(y)) === y for present real lines", () => { + // New side real lines present: 10 keep-1, 11 add, 12 keep-2, 13 keep-3. + for (const real of [10, 11, 12, 13]) { + const frag = realToFragment(file, "New", real); + expect(frag).toBeDefined(); + if (frag !== undefined) { + expect(fragmentToReal(file, "New", frag)).toBe(real); + } + } + }); + + it("returns undefined for real lines not in any hunk", () => { + expect(realToFragment(file, "New", 1)).toBeUndefined(); + expect(realToFragment(file, "New", 999)).toBeUndefined(); + expect(realToFragment(file, "Old", 999)).toBeUndefined(); + }); +}); + +describe("parseDiff line mapping — omitted-count header forms", () => { + it("parses @@ -a +c @@ (both counts omitted, implicit 1)", () => { + const raw = [ + "diff --git a/src/one.ts b/src/one.ts", + "--- a/src/one.ts", + "+++ b/src/one.ts", + "@@ -7 +7 @@", + "-was", + "+now", + ].join("\n"); + const file = only(parseDiff(raw)); + expect(fragmentToReal(file, "Old", 1)).toBe(7); + expect(fragmentToReal(file, "New", 1)).toBe(7); + }); + + it("parses @@ -a,0 +c,n @@ (zero old count, pure insertion)", () => { + const raw = [ + "diff --git a/src/two.ts b/src/two.ts", + "--- a/src/two.ts", + "+++ b/src/two.ts", + "@@ -20,0 +21,2 @@", + "+inserted-1", + "+inserted-2", + ].join("\n"); + const file = only(parseDiff(raw)); + expect(fragmentToReal(file, "New", 1)).toBe(21); + expect(fragmentToReal(file, "New", 2)).toBe(22); + expect(fragmentToReal(file, "Old", 1)).toBeUndefined(); + }); + + it("parses @@ -a,n +c,0 @@ (zero new count, pure deletion)", () => { + const raw = [ + "diff --git a/src/three.ts b/src/three.ts", + "--- a/src/three.ts", + "+++ b/src/three.ts", + "@@ -30,2 +29,0 @@", + "-removed-1", + "-removed-2", + ].join("\n"); + const file = only(parseDiff(raw)); + expect(fragmentToReal(file, "Old", 1)).toBe(30); + expect(fragmentToReal(file, "Old", 2)).toBe(31); + expect(fragmentToReal(file, "New", 1)).toBeUndefined(); + }); +}); + +describe("parseDiff — existing API stays backward-compatible", () => { + const raw = [ + "diff --git a/src/compat.ts b/src/compat.ts", + "--- a/src/compat.ts", + "+++ b/src/compat.ts", + "@@ -1,2 +1,2 @@", + " unchanged", + "-before", + "+after", + ].join("\n"); + + it("still reconstructs original/modified fragments", () => { + const file = only(parseDiff(raw)); + expect(file.path).toBe("src/compat.ts"); + expect(file.original).toBe("unchanged\nbefore"); + expect(file.modified).toBe("unchanged\nafter"); + }); + + it("extractFilePaths and diffStats are unaffected", () => { + expect(extractFilePaths(raw)).toEqual(["src/compat.ts"]); + expect(diffStats(raw)).toEqual({ additions: 1, deletions: 1 }); + }); + + it("returns [] for empty input", () => { + expect(parseDiff("")).toEqual([]); + expect(parseDiff(" \n ")).toEqual([]); + }); +}); diff --git a/app/src/diff-parser.ts b/app/src/diff-parser.ts index abb3cde..5ffe190 100644 --- a/app/src/diff-parser.ts +++ b/app/src/diff-parser.ts @@ -5,8 +5,38 @@ * A unified diff contains one or more file hunks. Each hunk has an `@@` * header describing the affected line ranges, followed by context (`" "`), * added (`"+"`), and removed (`"-"`) lines. + * + * The reconstructed `original`/`modified` strings only contain the lines that + * appear in the diff — hunk gaps are collapsed — so an editor line in those + * fragments does NOT equal the real file line. Every {@link FileDiff} therefore + * carries a {@link LineMap} translating between fragment (editor) lines and real + * file lines on each {@link DiffSide}. Use {@link fragmentToReal} and + * {@link realToFragment} rather than reading the map directly. */ +import type { DiffSide } from "@/bindings/DiffSide"; + +/** + * Bidirectional line mapping for one side of a file's diff. + * + * Fragment lines are the 1-based line numbers within the reconstructed + * `original`/`modified` text (i.e. the lines Monaco renders). Real lines are + * the 1-based line numbers in the actual pre-/post-change file. + */ +interface SideLineMap { + /** + * Fragment line → real file line. Indexed by `fragmentLine - 1`; a `number` + * at an index means that editor line exists on this side, `undefined` means + * it does not. + */ + readonly toReal: readonly number[]; + /** Real file line → fragment (editor) line for lines present in the diff. */ + readonly toFragment: ReadonlyMap<number, number>; +} + +/** Per-file line mapping keyed by {@link DiffSide} (`"Old"` / `"New"`). */ +type LineMap = Record<DiffSide, SideLineMap>; + /** A single file's diff, split into the text Monaco expects. */ interface FileDiff { /** Path relative to repo root. */ @@ -15,8 +45,33 @@ interface FileDiff { readonly original: string; /** Content after the change (lines prefixed with `+` or ` ` in the diff). */ readonly modified: string; + /** + * Translation between fragment (editor) lines and real file lines for both + * sides. Always present on results from {@link parseDiff}; optional so + * existing callers that synthesize a bare {@link FileDiff} keep compiling. + * Consume via {@link fragmentToReal} / {@link realToFragment}. + */ + readonly lineMap?: LineMap; +} + +/** Mutable accumulator used while a single file's hunks are parsed. */ +interface FileAccumulator { + path: string; + originalLines: string[]; + modifiedLines: string[]; + oldToReal: number[]; + newToReal: number[]; + oldToFragment: Map<number, number>; + newToFragment: Map<number, number>; + /** Next real old-file line number to assign; set by each hunk header. */ + oldLine: number; + /** Next real new-file line number to assign; set by each hunk header. */ + newLine: number; } +/** Matches `@@ -a,b +c,d @@`, tolerating the omitted-count forms `-a`/`+c`. */ +const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; + /** * Parse a raw unified diff string into per-file original/modified pairs. * @@ -24,9 +79,11 @@ interface FileDiff { * - Multiple files in a single diff * - `diff --git a/... b/...` headers * - `---`/`+++` file headers - * - `@@` hunk headers + * - `@@` hunk headers, including the omitted-count forms (`@@ -a +c @@`) * - Context, addition, and deletion lines * - New files (original is empty) and deleted files (modified is empty) + * - Non-contiguous hunks (gaps are collapsed in the fragment text but the + * {@link LineMap} preserves the real line numbers) */ function parseDiff(raw: string): readonly FileDiff[] { if (raw.trim() === "") { @@ -36,21 +93,35 @@ function parseDiff(raw: string): readonly FileDiff[] { const results: FileDiff[] = []; const lines = raw.split("\n"); - let currentPath: string | null = null; - let originalLines: string[] = []; - let modifiedLines: string[] = []; + let acc: FileAccumulator | null = null; + + function newAccumulator(path: string): FileAccumulator { + return { + path, + originalLines: [], + modifiedLines: [], + oldToReal: [], + newToReal: [], + oldToFragment: new Map(), + newToFragment: new Map(), + oldLine: 1, + newLine: 1, + }; + } function flushFile(): void { - if (currentPath !== null) { + if (acc !== null) { results.push({ - path: currentPath, - original: originalLines.join("\n"), - modified: modifiedLines.join("\n"), + path: acc.path, + original: acc.originalLines.join("\n"), + modified: acc.modifiedLines.join("\n"), + lineMap: { + Old: { toReal: acc.oldToReal, toFragment: acc.oldToFragment }, + New: { toReal: acc.newToReal, toFragment: acc.newToFragment }, + }, }); } - currentPath = null; - originalLines = []; - modifiedLines = []; + acc = null; } for (const line of lines) { @@ -60,7 +131,7 @@ function parseDiff(raw: string): readonly FileDiff[] { // Extract path from "diff --git a/foo b/foo" const match = /^diff --git a\/.+ b\/(.+)$/.exec(line); if (match?.[1] !== undefined) { - currentPath = match[1]; + acc = newAccumulator(match[1]); } continue; } @@ -82,8 +153,13 @@ function parseDiff(raw: string): readonly FileDiff[] { continue; } - // Hunk header: @@ -a,b +c,d @@ + // Hunk header: @@ -a,b +c,d @@ — reset the real line counters for this hunk. if (line.startsWith("@@")) { + const header = HUNK_HEADER.exec(line); + if (acc !== null && header?.[1] !== undefined && header[3] !== undefined) { + acc.oldLine = Number.parseInt(header[1], 10); + acc.newLine = Number.parseInt(header[3], 10); + } continue; } @@ -92,22 +168,39 @@ function parseDiff(raw: string): readonly FileDiff[] { continue; } - if (currentPath === null) { + if (acc === null) { continue; } - // Context line (unchanged) + // Context line (unchanged): advances both sides. if (line.startsWith(" ")) { - originalLines.push(line.slice(1)); - modifiedLines.push(line.slice(1)); + acc.originalLines.push(line.slice(1)); + const oldFrag = acc.originalLines.length; + acc.oldToReal[oldFrag - 1] = acc.oldLine; + acc.oldToFragment.set(acc.oldLine, oldFrag); + acc.oldLine += 1; + + acc.modifiedLines.push(line.slice(1)); + const newFrag = acc.modifiedLines.length; + acc.newToReal[newFrag - 1] = acc.newLine; + acc.newToFragment.set(acc.newLine, newFrag); + acc.newLine += 1; } - // Removed line + // Removed line: advances the old side only. else if (line.startsWith("-")) { - originalLines.push(line.slice(1)); + acc.originalLines.push(line.slice(1)); + const oldFrag = acc.originalLines.length; + acc.oldToReal[oldFrag - 1] = acc.oldLine; + acc.oldToFragment.set(acc.oldLine, oldFrag); + acc.oldLine += 1; } - // Added line + // Added line: advances the new side only. else if (line.startsWith("+")) { - modifiedLines.push(line.slice(1)); + acc.modifiedLines.push(line.slice(1)); + const newFrag = acc.modifiedLines.length; + acc.newToReal[newFrag - 1] = acc.newLine; + acc.newToFragment.set(acc.newLine, newFrag); + acc.newLine += 1; } // Empty line at end of diff (no prefix) -- treat as context else if (line === "") { @@ -119,6 +212,37 @@ function parseDiff(raw: string): readonly FileDiff[] { return results; } +/** + * Translate a fragment (editor) line to its real file line on `side`. + * + * Returns `undefined` when the fragment line does not exist on that side + * (e.g. the new side of a deleted file, or a line past the fragment's end). + */ +function fragmentToReal( + file: FileDiff, + side: DiffSide, + fragmentLine: number, +): number | undefined { + if (fragmentLine < 1) { + return undefined; + } + return file.lineMap?.[side].toReal[fragmentLine - 1]; +} + +/** + * Translate a real file line on `side` to its fragment (editor) line. + * + * Returns `undefined` when the real line is not present in the diff on that + * side (i.e. it falls in a collapsed gap between hunks, or the side is absent). + */ +function realToFragment( + file: FileDiff, + side: DiffSide, + realLine: number, +): number | undefined { + return file.lineMap?.[side].toFragment.get(realLine); +} + /** Added / removed line counts for a raw unified diff. */ interface DiffStats { /** Number of added (`+`) content lines. */ @@ -165,5 +289,11 @@ function extractFilePaths(raw: string): readonly string[] { return files; } -export { parseDiff, extractFilePaths, diffStats }; -export type { FileDiff, DiffStats }; +export { + parseDiff, + extractFilePaths, + diffStats, + fragmentToReal, + realToFragment, +}; +export type { FileDiff, DiffStats, LineMap, SideLineMap }; diff --git a/app/src/lib/attention.test.ts b/app/src/lib/attention.test.ts index 2c28487..42d2f17 100644 --- a/app/src/lib/attention.test.ts +++ b/app/src/lib/attention.test.ts @@ -20,6 +20,7 @@ describe("sortByAttention", () => { "Pending", "Dispatched", "Approved", + "Merged", ]); }); diff --git a/app/src/lib/attention.ts b/app/src/lib/attention.ts index 883c686..ea582cf 100644 --- a/app/src/lib/attention.ts +++ b/app/src/lib/attention.ts @@ -32,6 +32,9 @@ function gateStateRank(state: GateState): number { return 3; case "Approved": return 4; + // Merged is fully settled — the lowest urgency, below Approved. + case "Merged": + return 5; default: return assertNever(state); } diff --git a/app/src/lib/card-signal.ts b/app/src/lib/card-signal.ts index 2975543..89143e3 100644 --- a/app/src/lib/card-signal.ts +++ b/app/src/lib/card-signal.ts @@ -64,6 +64,8 @@ export function cardSignal(review: Review, now: number = Date.now()): CardSignal } case "Approved": return { reason: "Approved", tone: "done" }; + case "Merged": + return { reason: "Merged", tone: "done" }; default: return assertNever(state); } diff --git a/app/src/lib/stack-tree.test.ts b/app/src/lib/stack-tree.test.ts new file mode 100644 index 0000000..490311a --- /dev/null +++ b/app/src/lib/stack-tree.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from "vitest"; +import { makeReview } from "../test/fixtures"; +import { + buildStackTrees, + flattenStack, + computeHealth, + stackStatus, + frontierReviewId, + buildBoardItems, +} from "./stack-tree"; +import type { StackHealth, StackTreeNode } from "./stack-tree"; + +/** The single root of a set, failing loudly if there is not exactly one. */ +function onlyRoot(nodes: readonly StackTreeNode[]): StackTreeNode { + expect(nodes).toHaveLength(1); + const root = nodes[0]; + if (root === undefined) throw new Error("expected a root"); + return root; +} + +describe("buildStackTrees", () => { + it("builds one tree for a linear parent->child->grandchild stack", () => { + const r1 = makeReview({ id: "r1", children: ["r2"] }); + const r2 = makeReview({ id: "r2", parents: ["r1"], children: ["r3"] }); + const r3 = makeReview({ id: "r3", parents: ["r2"] }); + + const root = onlyRoot(buildStackTrees([r1, r2, r3])); + + expect(root.review.id).toBe("r1"); + expect(root.childNodes.map((c) => c.review.id)).toEqual(["r2"]); + expect(root.childNodes[0]?.childNodes.map((c) => c.review.id)).toEqual([ + "r3", + ]); + }); + + it("does not re-emit a child that is reachable from its parent as a root", () => { + const r1 = makeReview({ id: "r1", children: ["r2"] }); + const r2 = makeReview({ id: "r2", parents: ["r1"] }); + + const trees = buildStackTrees([r1, r2]); + + expect(trees.map((t) => t.review.id)).toEqual(["r1"]); + }); + + it("treats each parentless review as its own root", () => { + const a = makeReview({ id: "a" }); + const b = makeReview({ id: "b" }); + + const trees = buildStackTrees([a, b]); + + expect(trees.map((t) => t.review.id).sort()).toEqual(["a", "b"]); + }); + + it("degrades a review whose parent is outside the set to a root (cross-set parent)", () => { + // r2's parent r1 is NOT in the passed set (a cross-project / unfetched + // parent). It must degrade to a root rather than vanish from the board. + const r2 = makeReview({ id: "r2", parents: ["r1"] }); + + const trees = buildStackTrees([r2]); + + expect(trees.map((t) => t.review.id)).toEqual(["r2"]); + }); +}); + +describe("flattenStack", () => { + it("emits members parent-first (pre-order) with increasing depth", () => { + const r1 = makeReview({ id: "r1", children: ["r2"] }); + const r2 = makeReview({ id: "r2", parents: ["r1"], children: ["r3"] }); + const r3 = makeReview({ id: "r3", parents: ["r2"] }); + const root = onlyRoot(buildStackTrees([r1, r2, r3])); + + const flat = flattenStack(root); + + expect(flat.map((n) => n.review.id)).toEqual(["r1", "r2", "r3"]); + expect(flat.map((n) => n.depth)).toEqual([0, 1, 2]); + }); + + it("visits a diamond member only once", () => { + // r4 is reachable via both r2 and r3. + const r1 = makeReview({ id: "r1", children: ["r2", "r3"] }); + const r2 = makeReview({ id: "r2", parents: ["r1"], children: ["r4"] }); + const r3 = makeReview({ id: "r3", parents: ["r1"], children: ["r4"] }); + const r4 = makeReview({ id: "r4", parents: ["r2", "r3"] }); + const root = onlyRoot(buildStackTrees([r1, r2, r3, r4])); + + const ids = flattenStack(root).map((n) => n.review.id); + + expect(ids.filter((id) => id === "r4")).toHaveLength(1); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("computeHealth", () => { + it("counts total, approved, merged, and stale across the (deduped) stack", () => { + const r1 = makeReview({ id: "r1", gate_state: "Merged", children: ["r2"] }); + const r2 = makeReview({ + id: "r2", + gate_state: "Approved", + parents: ["r1"], + children: ["r3"], + }); + const r3 = makeReview({ + id: "r3", + gate_state: "InReview", + parents: ["r2"], + stale: true, + }); + const root = onlyRoot(buildStackTrees([r1, r2, r3])); + + expect(computeHealth(root)).toEqual({ + total: 3, + approved: 1, + merged: 1, + stale: 1, + }); + }); +}); + +describe("stackStatus", () => { + it("reports a stale member with a warning tone", () => { + const health: StackHealth = { total: 3, approved: 1, merged: 0, stale: 2 }; + expect(stackStatus(health)).toEqual({ tone: "warning", label: "2 stale" }); + }); + + it("singularizes a single stale member", () => { + const health: StackHealth = { total: 3, approved: 0, merged: 0, stale: 1 }; + expect(stackStatus(health).label).toBe("1 stale"); + }); + + it("reads as all approved when every member is approved or merged", () => { + const health: StackHealth = { total: 3, approved: 2, merged: 1, stale: 0 }; + expect(stackStatus(health)).toEqual({ + tone: "approved", + label: "All approved", + }); + }); + + it("reads as all merged when the whole stack is merged", () => { + const health: StackHealth = { total: 2, approved: 0, merged: 2, stale: 0 }; + expect(stackStatus(health).label).toBe("All merged"); + }); + + it("reports the ready count while work remains", () => { + const health: StackHealth = { total: 3, approved: 1, merged: 0, stale: 0 }; + expect(stackStatus(health)).toEqual({ + tone: "progress", + label: "1/3 approved", + }); + }); +}); + +describe("frontierReviewId", () => { + it("selects the most-urgent actionable member (approved parent, in-review child)", () => { + const r1 = makeReview({ + id: "r1", + gate_state: "Approved", + children: ["r2"], + }); + const r2 = makeReview({ + id: "r2", + gate_state: "InReview", + parents: ["r1"], + }); + const root = onlyRoot(buildStackTrees([r1, r2])); + + expect(frontierReviewId(root)).toBe("r2"); + }); + + it("skips a stale member in favor of an actionable ancestor", () => { + const r1 = makeReview({ + id: "r1", + gate_state: "InReview", + children: ["r2"], + }); + const r2 = makeReview({ + id: "r2", + gate_state: "InReview", + parents: ["r1"], + stale: true, + }); + const root = onlyRoot(buildStackTrees([r1, r2])); + + expect(frontierReviewId(root)).toBe("r1"); + }); + + it("returns null when every member is settled", () => { + const r1 = makeReview({ + id: "r1", + gate_state: "Approved", + children: ["r2"], + }); + const r2 = makeReview({ id: "r2", gate_state: "Merged", parents: ["r1"] }); + const root = onlyRoot(buildStackTrees([r1, r2])); + + expect(frontierReviewId(root)).toBeNull(); + }); +}); + +describe("buildBoardItems", () => { + it("classifies lone reviews as singles and multi-node trees as stacks", () => { + const single = makeReview({ id: "s1", gate_state: "InReview" }); + const p = makeReview({ id: "p", gate_state: "Approved", children: ["c"] }); + const c = makeReview({ id: "c", gate_state: "Approved", parents: ["p"] }); + + const items = buildBoardItems([single, p, c]); + + const kinds = items.map((i) => i.kind).sort(); + expect(kinds).toEqual(["single", "stack"]); + const stack = items.find((i) => i.kind === "stack"); + if (stack?.kind !== "stack") throw new Error("expected a stack item"); + expect(stack.nodes.map((n) => n.review.id)).toEqual(["p", "c"]); + }); + + it("interleaves a stack among singles by its most-urgent member", () => { + // Stack's most-urgent member is Reworked (rank 0) — it must sort ahead of a + // lone InReview (rank 1) and behind nothing. + const lone = makeReview({ id: "lone", gate_state: "InReview" }); + const p = makeReview({ id: "p", gate_state: "Approved", children: ["c"] }); + const c = makeReview({ id: "c", gate_state: "Reworked", parents: ["p"] }); + + const items = buildBoardItems([lone, p, c]); + + expect(items[0]?.kind).toBe("stack"); + expect(items[1]?.kind).toBe("single"); + }); + + it("never drops a review, even in a malformed cycle", () => { + // A pure cycle has no root; both members must still surface as singles. + const r1 = makeReview({ id: "r1", parents: ["r2"], children: ["r2"] }); + const r2 = makeReview({ id: "r2", parents: ["r1"], children: ["r1"] }); + + const items = buildBoardItems([r1, r2]); + + const ids = items + .filter((i) => i.kind === "single") + .map((i) => (i.kind === "single" ? i.review.id : "")); + expect(ids.sort()).toEqual(["r1", "r2"]); + }); + + it("keeps a cross-set-parent review on the board as a lone card", () => { + const orphan = makeReview({ id: "orphan", parents: ["missing"] }); + + const items = buildBoardItems([orphan]); + + expect(items).toHaveLength(1); + expect(items[0]?.kind).toBe("single"); + }); +}); diff --git a/app/src/lib/stack-tree.ts b/app/src/lib/stack-tree.ts index 6a7ac6a..7ebd96c 100644 --- a/app/src/lib/stack-tree.ts +++ b/app/src/lib/stack-tree.ts @@ -1,5 +1,6 @@ import type { Review } from "../bindings/Review"; import type { ReviewId } from "../bindings/ReviewId"; +import { attentionRank } from "./attention"; /** A node in the stack tree, wrapping a review with its resolved children. */ export interface StackTreeNode { @@ -7,18 +8,28 @@ export interface StackTreeNode { readonly childNodes: readonly StackTreeNode[]; } +/** A stack node annotated with its render depth (root = 0). */ +export interface FlatStackNode { + readonly review: Review; + readonly depth: number; +} + /** Summary statistics for a single stack rooted at a given review. */ export interface StackHealth { readonly total: number; readonly approved: number; + readonly merged: number; readonly stale: number; } /** * Build stack trees from the flat review list. * - * Roots are reviews with no parents. Each root's subtree is built by - * recursively resolving children from the lookup map. + * A review is a root when none of its parents are present in the passed set: + * that includes true roots (no parents) and reviews whose parents live outside + * the set (a cross-project or unfetched parent), which degrade to roots rather + * than vanishing. Each root's subtree is built by recursively resolving + * children from the lookup map. */ export function buildStackTrees(reviews: readonly Review[]): readonly StackTreeNode[] { const byId = new Map<ReviewId, Review>(); @@ -53,7 +64,8 @@ export function buildStackTrees(reviews: readonly Review[]): readonly StackTreeN const roots: StackTreeNode[] = []; for (const review of reviews) { - if (review.parents.length === 0) { + // Degrade to a root when no parent is in the passed set. + if (review.parents.every((p) => !byId.has(p))) { const tree = buildSubtree(review.id, new Set<ReviewId>()); if (tree !== undefined) { roots.push(tree); @@ -63,18 +75,194 @@ export function buildStackTrees(reviews: readonly Review[]): readonly StackTreeN return roots; } -/** Compute health stats by walking the tree recursively. */ +/** + * Flatten a stack tree into dependency order (parent before children, + * pre-order DFS) with each review's depth for indentation. Each review appears + * at most once even if a malformed DAG reaches it twice. + */ +export function flattenStack(root: StackTreeNode): readonly FlatStackNode[] { + const out: FlatStackNode[] = []; + const seen = new Set<ReviewId>(); + function walk(node: StackTreeNode, depth: number): void { + if (seen.has(node.review.id)) { + return; + } + seen.add(node.review.id); + out.push({ review: node.review, depth }); + for (const child of node.childNodes) { + walk(child, depth + 1); + } + } + walk(root, 0); + return out; +} + +/** + * Compute health stats for a stack by walking its (deduplicated) members, so + * the totals always match the rendered card count. + */ export function computeHealth(node: StackTreeNode): StackHealth { - let total = 1; - let approved = node.review.gate_state === "Approved" ? 1 : 0; - let stale = node.review.stale ? 1 : 0; - - for (const child of node.childNodes) { - const childHealth = computeHealth(child); - total += childHealth.total; - approved += childHealth.approved; - stale += childHealth.stale; + let approved = 0; + let merged = 0; + let stale = 0; + const nodes = flattenStack(node); + for (const { review } of nodes) { + if (review.gate_state === "Approved") approved += 1; + if (review.gate_state === "Merged") merged += 1; + if (review.stale) stale += 1; + } + return { total: nodes.length, approved, merged, stale }; +} + +/** Semantic tone for a stack's health dot. */ +export type StackStatusTone = "warning" | "approved" | "progress"; + +/** A stack's health rendered as a status dot tone plus a short label. */ +export interface StackStatus { + readonly tone: StackStatusTone; + readonly label: string; +} + +/** + * Derive a stack's headline status from its health. A stale member (blocked on + * an ancestor's rework) dominates; otherwise the stack reads as done when every + * member is approved or merged, else it reports its ready count. + */ +export function stackStatus(health: StackHealth): StackStatus { + if (health.stale > 0) { + return { + tone: "warning", + label: health.stale === 1 ? "1 stale" : `${String(health.stale)} stale`, + }; + } + const done = health.approved + health.merged; + if (done === health.total) { + return { + tone: "approved", + label: health.merged === health.total ? "All merged" : "All approved", + }; + } + return { + tone: "progress", + label: `${String(done)}/${String(health.total)} approved`, + }; +} + +/** Whether a review is a candidate for the stack's frontier highlight. */ +function isActionable(review: Review): boolean { + if (review.stale) { + return false; + } + return review.gate_state !== "Approved" && review.gate_state !== "Merged"; +} + +/** + * The frontier-eligible member of a stack: the most-urgent actionable review + * (minimum attention rank among non-stale, non-settled members), or `null` when + * the whole stack is settled or blocked. Ties break by review id for + * determinism. + */ +export function frontierReviewId(root: StackTreeNode): ReviewId | null { + let best: { readonly rank: number; readonly id: ReviewId } | null = null; + for (const { review } of flattenStack(root)) { + if (!isActionable(review)) { + continue; + } + const rank = attentionRank(review); + if ( + best === null || + rank < best.rank || + (rank === best.rank && review.id < best.id) + ) { + best = { rank, id: review.id }; + } + } + return best === null ? null : best.id; +} + +/** + * A board entry: either a lone review (rendered as a flat card) or a stack of + * two-or-more connected reviews (rendered as a container). + */ +export type BoardItem = + | { readonly kind: "single"; readonly review: Review } + | { + readonly kind: "stack"; + readonly root: StackTreeNode; + readonly nodes: readonly FlatStackNode[]; + }; + +/** The attention sort key (rank, id) for a stack: its most-urgent member. */ +function stackSortKey(nodes: readonly FlatStackNode[]): { + readonly rank: number; + readonly id: ReviewId; +} { + const first = nodes[0]; + // INVARIANT: a stack is only built from a non-empty flattened tree. + if (first === undefined) { + throw new Error("stackSortKey called with an empty stack"); + } + let best = { rank: attentionRank(first.review), id: first.review.id }; + for (const { review } of nodes.slice(1)) { + const rank = attentionRank(review); + if (rank < best.rank || (rank === best.rank && review.id < best.id)) { + best = { rank, id: review.id }; + } + } + return best; +} + +/** + * Partition a project group's reviews into board items — singletons and stacks + * — ordered attention-first. Singletons sort by their own attention rank; + * stacks sort by their most-urgent member (so a stack interleaves among the + * lone cards). Every input review appears in exactly one item. + */ +export function buildBoardItems(reviews: readonly Review[]): readonly BoardItem[] { + const trees = buildStackTrees(reviews); + const covered = new Set<ReviewId>(); + const ranked: { + readonly item: BoardItem; + readonly rank: number; + readonly id: ReviewId; + }[] = []; + + for (const tree of trees) { + const nodes = flattenStack(tree); + for (const { review } of nodes) { + covered.add(review.id); + } + if (nodes.length <= 1) { + const review = tree.review; + ranked.push({ + item: { kind: "single", review }, + rank: attentionRank(review), + id: review.id, + }); + } else { + const key = stackSortKey(nodes); + ranked.push({ + item: { kind: "stack", root: tree, nodes }, + rank: key.rank, + id: key.id, + }); + } + } + + // Safety net: a malformed graph (e.g. a pure cycle) must never drop a review + // from the board — surface any uncovered review as a lone card. + for (const review of reviews) { + if (!covered.has(review.id)) { + ranked.push({ + item: { kind: "single", review }, + rank: attentionRank(review), + id: review.id, + }); + } } - return { total, approved, stale }; + ranked.sort( + (a, b) => a.rank - b.rank || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + return ranked.map((r) => r.item); } diff --git a/app/src/store.test.ts b/app/src/store.test.ts index 6ed26ac..a2beeb6 100644 --- a/app/src/store.test.ts +++ b/app/src/store.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import type { Review } from "./bindings/Review"; import type { CiCheck } from "./bindings/CiCheck"; +import type { SubmitReviewResult } from "./bindings/SubmitReviewResult"; import { mockInvoke, mockInvokeReject, @@ -62,7 +63,6 @@ describe("restackPr", () => { const restacked = makeReview({ pr: "pr-1", stale: false }); useAppStore.setState({ reviews: [stale], - frontier: [stale], authoredPrs: [stale], reviewRequests: [], activeReview: stale, @@ -76,7 +76,6 @@ describe("restackPr", () => { const state = useAppStore.getState(); expect(state.reviews[0]?.stale).toBe(false); - expect(state.frontier[0]?.stale).toBe(false); expect(state.authoredPrs[0]?.stale).toBe(false); expect(state.activeReview?.stale).toBe(false); expect(state.error).toBeNull(); @@ -150,7 +149,6 @@ describe("fixCi", () => { useAppStore.setState({ activeReview: before, reviews: [before], - frontier: [before], }); mockInvoke("fix_ci", (args) => { expect(args.pr).toBe("pr-1"); @@ -162,7 +160,6 @@ describe("fixCi", () => { const state = useAppStore.getState(); expect(state.activeReview?.gate_state).toBe("Dispatched"); expect(state.reviews[0]?.gate_state).toBe("Dispatched"); - expect(state.frontier[0]?.gate_state).toBe("Dispatched"); expect(state.error).toBeNull(); }); @@ -205,3 +202,251 @@ describe("requestChanges", () => { expect(useAppStore.getState().activeReview?.gate_state).toBe("Dispatched"); }); }); + +describe("approveReview", () => { + it("advances the review across lists + activeReview on success (D2)", async () => { + const before = makeReview({ pr: "pr-1", gate_state: "InReview" }); + const approved = makeReview({ pr: "pr-1", gate_state: "Approved" }); + useAppStore.setState({ + activeReview: before, + reviews: [before], + }); + mockInvoke("approve_review", (args) => { + expect(args.pr).toBe("pr-1"); + return approved; + }); + + await useAppStore.getState().approveReview("pr-1"); + + const state = useAppStore.getState(); + expect(state.activeReview?.gate_state).toBe("Approved"); + expect(state.reviews[0]?.gate_state).toBe("Approved"); + expect(state.error).toBeNull(); + }); + + it("sets error (non-fatal) on rejection", async () => { + const before = makeReview({ pr: "pr-1", gate_state: "InReview" }); + useAppStore.setState({ activeReview: before, reviews: [before] }); + mockInvokeReject("approve_review", "cannot approve"); + + await useAppStore.getState().approveReview("pr-1"); + + expect(useAppStore.getState().error).toContain("cannot approve"); + expect(useAppStore.getState().activeReview?.gate_state).toBe("InReview"); + }); +}); + +describe("mergeReview", () => { + it("advances the active review to Merged on success (D2)", async () => { + const before = makeReview({ pr: "pr-1", gate_state: "Approved" }); + const merged = makeReview({ pr: "pr-1", gate_state: "Merged" }); + useAppStore.setState({ + activeReview: before, + reviews: [before], + }); + mockInvoke("merge_review", (args) => { + expect(args.pr).toBe("pr-1"); + return merged; + }); + + await useAppStore.getState().mergeReview("pr-1"); + + const state = useAppStore.getState(); + expect(state.activeReview?.gate_state).toBe("Merged"); + expect(state.reviews[0]?.gate_state).toBe("Merged"); + expect(state.error).toBeNull(); + }); + + it("sets error (non-fatal) on rejection", async () => { + const before = makeReview({ pr: "pr-1", gate_state: "Approved" }); + useAppStore.setState({ activeReview: before, reviews: [before] }); + mockInvokeReject("merge_review", "merge conflict"); + + await useAppStore.getState().mergeReview("pr-1"); + + expect(useAppStore.getState().error).toContain("merge conflict"); + expect(useAppStore.getState().activeReview?.gate_state).toBe("Approved"); + }); +}); + +describe("submitGithubReview", () => { + it("returns the result and refreshes the active review on success (D9)", async () => { + const before = makeReview({ pr: "pr-1", gate_state: "InReview" }); + const refreshed = makeReview({ pr: "pr-1", gate_state: "Approved" }); + useAppStore.setState({ + view: { kind: "diff", pr: "pr-1" }, + activeReview: before, + reviews: [before], + }); + const result: SubmitReviewResult = { submitted: 2, skipped: [] }; + mockInvoke("submit_github_review", (args) => { + expect(args.pr).toBe("pr-1"); + expect(args.event).toBe("Approve"); + return result; + }); + mockInvoke("get_review", () => refreshed); + mockInvoke("get_review_diff", () => ({ raw: "" })); + + const returned = await useAppStore + .getState() + .submitGithubReview("pr-1", "Approve", ""); + + expect(returned).toEqual(result); + expect(useAppStore.getState().activeReview?.gate_state).toBe("Approved"); + expect(useAppStore.getState().error).toBeNull(); + }); + + it("maps an empty body to null and passes a non-empty body through", async () => { + useAppStore.setState({ + view: { kind: "diff", pr: "pr-1" }, + activeReview: makeReview({ pr: "pr-1" }), + reviews: [], + }); + const result: SubmitReviewResult = { submitted: 0, skipped: [] }; + let seenBody: unknown = "unset"; + mockInvoke("submit_github_review", (args) => { + seenBody = args.body; + return result; + }); + mockInvoke("get_review", () => makeReview({ pr: "pr-1" })); + mockInvoke("get_review_diff", () => ({ raw: "" })); + + await useAppStore.getState().submitGithubReview("pr-1", "Comment", " "); + expect(seenBody).toBeNull(); + + await useAppStore.getState().submitGithubReview("pr-1", "Comment", "looks good"); + expect(seenBody).toBe("looks good"); + }); + + it("sets a store error listing reasons when comments are skipped", async () => { + const before = makeReview({ pr: "pr-1", gate_state: "InReview" }); + useAppStore.setState({ + view: { kind: "diff", pr: "pr-1" }, + activeReview: before, + reviews: [before], + }); + const result: SubmitReviewResult = { + submitted: 1, + skipped: [["c-1", "line not in diff"]], + }; + mockInvoke("submit_github_review", () => result); + mockInvoke("get_review", () => before); + mockInvoke("get_review_diff", () => ({ raw: "" })); + + const returned = await useAppStore + .getState() + .submitGithubReview("pr-1", "Comment", "hi"); + + expect(returned).toEqual(result); + expect(useAppStore.getState().error).toContain("line not in diff"); + }); + + it("returns null and sets error on rejection", async () => { + useAppStore.setState({ view: { kind: "diff", pr: "pr-1" } }); + mockInvokeReject("submit_github_review", "gh failed"); + + const returned = await useAppStore + .getState() + .submitGithubReview("pr-1", "Comment", ""); + + expect(returned).toBeNull(); + expect(useAppStore.getState().error).toContain("gh failed"); + }); +}); + +describe("killAgent", () => { + it("applies the reconciled review across lists + activeReview on success (D12)", async () => { + const running = makeReview({ + pr: "pr-1", + gate_state: "Dispatched", + agent: makeAgentRun({ mode: "Fix" }), + }); + const settled = makeReview({ + pr: "pr-1", + gate_state: "InReview", + agent: null, + }); + useAppStore.setState({ + activeReview: running, + reviews: [running], + authoredPrs: [running], + }); + mockInvoke("kill_agent", (args) => { + expect(args.pr).toBe("pr-1"); + return settled; + }); + + await useAppStore.getState().killAgent("pr-1"); + + const state = useAppStore.getState(); + expect(state.activeReview?.gate_state).toBe("InReview"); + expect(state.activeReview?.agent).toBeNull(); + expect(state.reviews[0]?.agent).toBeNull(); + expect(state.authoredPrs[0]?.agent).toBeNull(); + expect(state.error).toBeNull(); + }); + + it("sets error (non-fatal) and leaves the review untouched on rejection", async () => { + const running = makeReview({ + pr: "pr-1", + gate_state: "Dispatched", + agent: makeAgentRun({ mode: "Fix" }), + }); + useAppStore.setState({ activeReview: running, reviews: [running] }); + mockInvokeReject("kill_agent", "no such process"); + + await useAppStore.getState().killAgent("pr-1"); + + const state = useAppStore.getState(); + expect(state.error).toContain("no such process"); + // Non-fatal: the agent handle is untouched so the loop is not blocked. + expect(state.activeReview?.agent).not.toBeNull(); + }); +}); + +describe("ensureReviewWorktree", () => { + it("returns the materialized worktree path on success (D12)", async () => { + mockInvoke("ensure_review_worktree", (args) => { + expect(args.pr).toBe("pr-1"); + return "/tmp/wt/pr-1"; + }); + + const path = await useAppStore.getState().ensureReviewWorktree("pr-1"); + + expect(path).toBe("/tmp/wt/pr-1"); + expect(useAppStore.getState().error).toBeNull(); + }); + + it("returns null and sets error on failure", async () => { + mockInvokeReject("ensure_review_worktree", "checkout failed"); + + const path = await useAppStore.getState().ensureReviewWorktree("pr-1"); + + expect(path).toBeNull(); + expect(useAppStore.getState().error).toContain("checkout failed"); + }); +}); + +describe("fetchInterdiff", () => { + it("returns the interdiff on success (D10)", async () => { + const diff = { raw: "diff --git a/x b/x" }; + mockInvoke("get_interdiff", (args) => { + expect(args.pr).toBe("pr-1"); + return diff; + }); + + const result = await useAppStore.getState().fetchInterdiff("pr-1"); + + expect(result).toEqual(diff); + expect(useAppStore.getState().error).toBeNull(); + }); + + it("returns null and sets error on failure", async () => { + mockInvokeReject("get_interdiff", "no snapshot"); + + const result = await useAppStore.getState().fetchInterdiff("pr-1"); + + expect(result).toBeNull(); + expect(useAppStore.getState().error).toContain("no snapshot"); + }); +}); diff --git a/app/src/store.ts b/app/src/store.ts index eaf4e25..30e4d90 100644 --- a/app/src/store.ts +++ b/app/src/store.ts @@ -3,6 +3,8 @@ import { invoke } from "@tauri-apps/api/core"; import type { Review } from "./bindings/Review"; import type { DiffData } from "./bindings/DiffData"; import type { MirrorResult } from "./bindings/MirrorResult"; +import type { SubmitReviewResult } from "./bindings/SubmitReviewResult"; +import type { ReviewEvent } from "./bindings/ReviewEvent"; import type { ProjectPlan } from "./bindings/ProjectPlan"; import type { Config } from "./bindings/Config"; import type { KickoffResult } from "./bindings/KickoffResult"; @@ -13,6 +15,7 @@ import type { Skill } from "./bindings/Skill"; import type { SyncReport } from "./bindings/SyncReport"; import type { AgentMode } from "./bindings/AgentMode"; import type { CiCheck } from "./bindings/CiCheck"; +import type { DiffSide } from "./bindings/DiffSide"; /** * Navigation state discriminated union. @@ -33,7 +36,6 @@ type ViewState = interface AppStore { readonly reviews: readonly Review[]; - readonly frontier: readonly Review[]; readonly plan: ProjectPlan | null; readonly loading: boolean; readonly error: string | null; @@ -51,7 +53,6 @@ interface AppStore { readonly activeDiff: DiffData | null; fetchReviews: () => Promise<void>; - fetchFrontier: () => Promise<void>; openReview: (pr: string) => Promise<void>; /** Navigate to the diff view for a specific PR. */ @@ -78,12 +79,16 @@ interface AppStore { /** Navigate to the settings view. */ navigateToSettings: () => void; - /** Add an anchored comment to the active review. */ + /** + * Add an anchored comment to the active review. `side` selects which side of + * the diff the line refers to (D12). + */ addComment: ( file: string, lineStart: number, lineEnd: number, body: string, + side: DiffSide, ) => Promise<void>; /** Request changes on the active review (InReview -> Dispatched). */ @@ -117,9 +122,39 @@ interface AppStore { /** Generate the plan document via the plan agent for a project. */ generatePlan: (projectId: ProjectId) => Promise<void>; - /** Approve a single review by PR ref (explicit user action). */ + /** Approve a single review by PR ref (explicit user action; `InReview` -> `Approved`). */ approveReview: (pr: string) => Promise<void>; + /** + * Merge an approved review's PR (explicit, confirmed user action; Invariant 5). + * + * Squash-merges on GitHub and deletes the branch, advancing the local gate to + * `Merged`. Failure is surfaced via the store `error` and never blocks the loop. + */ + mergeReview: (pr: string) => Promise<void>; + + /** + * Submit a real GitHub PR review (approve / request changes / comment) carrying + * the review's inline Local comments (explicit, confirmed user action; + * Invariant 5 / §9). Returns the [`SubmitReviewResult`] so the caller can show + * the submitted count; on partial success (non-empty `skipped`) the store + * `error` is set listing the skipped comments' reasons. Returns `null` on + * failure (also surfaced via `error`). + */ + submitGithubReview: ( + pr: string, + event: ReviewEvent, + body: string, + ) => Promise<SubmitReviewResult | null>; + + /** + * Fetch the interdiff (changes since the last review dispatch) for a PR. + * + * Returns `null` on failure, setting the store `error`; the caller then falls + * back to the full diff (D10). Requires a dispatch snapshot server-side. + */ + fetchInterdiff: (pr: string) => Promise<DiffData | null>; + /** * Restack a stale review onto its parent's new head (explicit user action). * @@ -129,6 +164,24 @@ interface AppStore { */ restackPr: (pr: string) => Promise<void>; + /** + * Kill the agent running on a review (explicit user action; D11/D12). + * + * Applies the returned reconciled [`Review`] (comments preserved, agent + * cleared, back to `InReview`). Failure is non-fatal: it sets the store + * `error` and never blocks the loop. + */ + killAgent: (pr: string) => Promise<void>; + + /** + * Ensure a review has a checked-out worktree on disk and return its path + * (D12). Materializes a dedicated branch worktree for an imported same-repo + * PR; a no-op for cockpit-managed worktrees. Returns `null` on failure + * (also surfaced via `error`) so callers can fall back to the review's + * recorded worktree. + */ + ensureReviewWorktree: (pr: string) => Promise<string | null>; + // ------------------------------------------------------------------------- // Config // ------------------------------------------------------------------------- @@ -268,7 +321,6 @@ interface AppStore { export const useAppStore = create<AppStore>((set, get) => ({ reviews: [], - frontier: [], plan: null, loading: false, error: null, @@ -302,15 +354,6 @@ export const useAppStore = create<AppStore>((set, get) => ({ } }, - fetchFrontier: async () => { - try { - const frontier = await invoke<Review[]>("get_frontier"); - set({ frontier }); - } catch (e: unknown) { - set({ error: String(e) }); - } - }, - openReview: async (pr: string) => { set({ loading: true, error: null }); try { @@ -324,7 +367,6 @@ export const useAppStore = create<AppStore>((set, get) => ({ loading: false, authoredPrs: get().authoredPrs.map(replace), reviewRequests: get().reviewRequests.map(replace), - frontier: get().frontier.map(replace), reviews: get().reviews.map(replace), }); } catch (e: unknown) { @@ -347,7 +389,6 @@ export const useAppStore = create<AppStore>((set, get) => ({ loading: false, authoredPrs: get().authoredPrs.map(replace), reviewRequests: get().reviewRequests.map(replace), - frontier: get().frontier.map(replace), reviews: get().reviews.map(replace), }); } catch (e: unknown) { @@ -367,7 +408,6 @@ export const useAppStore = create<AppStore>((set, get) => ({ activeDiff: null, }); void get().fetchReviews(); - void get().fetchFrontier(); void get().fetchAuthoredPrs(); }, @@ -399,6 +439,7 @@ export const useAppStore = create<AppStore>((set, get) => ({ lineStart: number, lineEnd: number, body: string, + side: DiffSide, ) => { const { view } = get(); if (view.kind !== "diff") return; @@ -409,13 +450,13 @@ export const useAppStore = create<AppStore>((set, get) => ({ lineStart, lineEnd, body, + side, }); const replace = (r: Review) => (r.pr === review.pr ? review : r); set({ activeReview: review, authoredPrs: get().authoredPrs.map(replace), reviewRequests: get().reviewRequests.map(replace), - frontier: get().frontier.map(replace), reviews: get().reviews.map(replace), }); }, @@ -433,7 +474,6 @@ export const useAppStore = create<AppStore>((set, get) => ({ activeReview: review, authoredPrs: get().authoredPrs.map(replace), reviewRequests: get().reviewRequests.map(replace), - frontier: get().frontier.map(replace), reviews: get().reviews.map(replace), }); } catch (e: unknown) { @@ -469,7 +509,6 @@ export const useAppStore = create<AppStore>((set, get) => ({ activeDiff: diff, authoredPrs: get().authoredPrs.map(replace), reviewRequests: get().reviewRequests.map(replace), - frontier: get().frontier.map(replace), reviews: get().reviews.map(replace), }); } catch (e: unknown) { @@ -542,11 +581,75 @@ export const useAppStore = create<AppStore>((set, get) => ({ approveReview: async (pr: string) => { try { - await invoke<Review>("approve_review", { pr }); - await get().fetchFrontier(); - await get().fetchReviews(); + const review = await invoke<Review>("approve_review", { pr }); + const replace = (r: Review) => (r.pr === review.pr ? review : r); + set({ + activeReview: + get().activeReview?.pr === review.pr ? review : get().activeReview, + authoredPrs: get().authoredPrs.map(replace), + reviewRequests: get().reviewRequests.map(replace), + reviews: get().reviews.map(replace), + }); + } catch (e: unknown) { + set({ error: String(e) }); + } + }, + + mergeReview: async (pr: string) => { + try { + const review = await invoke<Review>("merge_review", { pr }); + const replace = (r: Review) => (r.pr === review.pr ? review : r); + set({ + activeReview: + get().activeReview?.pr === review.pr ? review : get().activeReview, + authoredPrs: get().authoredPrs.map(replace), + reviewRequests: get().reviewRequests.map(replace), + reviews: get().reviews.map(replace), + }); + } catch (e: unknown) { + set({ error: String(e) }); + } + }, + + submitGithubReview: async ( + pr: string, + event: ReviewEvent, + body: string, + ): Promise<SubmitReviewResult | null> => { + try { + const result = await invoke<SubmitReviewResult>("submit_github_review", { + pr, + event, + // The command takes `Option<String>`; an empty body maps to `None`. + body: body.trim() === "" ? null : body, + }); + // The backend may clear submitted Local comments and/or advance the local + // gate (Approve on a review-requested PR), so refresh the active review to + // reflect that. Best-effort: a refresh failure is non-fatal. + await get().refreshActiveReview(); + if (result.skipped.length > 0) { + const reasons = result.skipped + .map(([, reason]) => reason) + .join("; "); + set({ + error: `${String(result.skipped.length)} comment${ + result.skipped.length === 1 ? "" : "s" + } skipped: ${reasons}`, + }); + } + return result; } catch (e: unknown) { set({ error: String(e) }); + return null; + } + }, + + fetchInterdiff: async (pr: string): Promise<DiffData | null> => { + try { + return await invoke<DiffData>("get_interdiff", { pr }); + } catch (e: unknown) { + set({ error: String(e) }); + return null; } }, @@ -559,7 +662,6 @@ export const useAppStore = create<AppStore>((set, get) => ({ get().activeReview?.pr === review.pr ? review : get().activeReview, authoredPrs: get().authoredPrs.map(replace), reviewRequests: get().reviewRequests.map(replace), - frontier: get().frontier.map(replace), reviews: get().reviews.map(replace), }); } catch (e: unknown) { @@ -567,6 +669,31 @@ export const useAppStore = create<AppStore>((set, get) => ({ } }, + killAgent: async (pr: string) => { + try { + const review = await invoke<Review>("kill_agent", { pr }); + const replace = (r: Review) => (r.pr === review.pr ? review : r); + set({ + activeReview: + get().activeReview?.pr === review.pr ? review : get().activeReview, + authoredPrs: get().authoredPrs.map(replace), + reviewRequests: get().reviewRequests.map(replace), + reviews: get().reviews.map(replace), + }); + } catch (e: unknown) { + set({ error: String(e) }); + } + }, + + ensureReviewWorktree: async (pr: string): Promise<string | null> => { + try { + return await invoke<string>("ensure_review_worktree", { pr }); + } catch (e: unknown) { + set({ error: String(e) }); + return null; + } + }, + // ------------------------------------------------------------------------- // Config // ------------------------------------------------------------------------- @@ -641,7 +768,6 @@ export const useAppStore = create<AppStore>((set, get) => ({ }); set({ kickoffLoading: false, kickoffResult: result }); void get().fetchReviews(); - void get().fetchFrontier(); void get().listProjects(); } catch (e: unknown) { set({ error: String(e), kickoffLoading: false }); @@ -658,7 +784,6 @@ export const useAppStore = create<AppStore>((set, get) => ({ set({ authoredPrs: get().authoredPrs.map(replace), reviewRequests: get().reviewRequests.map(replace), - frontier: get().frontier.map(replace), reviews: get().reviews.map(replace), }); } catch (e: unknown) { @@ -785,11 +910,11 @@ export const useAppStore = create<AppStore>((set, get) => ({ try { const review = await invoke<Review>("fix_ci", { pr }); const replace = (r: Review) => (r.pr === review.pr ? review : r); + const active = get().activeReview; set({ - activeReview: review, + activeReview: active?.pr === review.pr ? review : active, authoredPrs: get().authoredPrs.map(replace), reviewRequests: get().reviewRequests.map(replace), - frontier: get().frontier.map(replace), reviews: get().reviews.map(replace), }); } catch (e: unknown) { diff --git a/app/src/test/fixtures.ts b/app/src/test/fixtures.ts index 9eb1ad1..4ede98b 100644 --- a/app/src/test/fixtures.ts +++ b/app/src/test/fixtures.ts @@ -31,6 +31,8 @@ export function makeReview(overrides: Partial<Review> = {}): Review { id: "rev-1", issue: "NEX-1", pr: "https://github.com/o/r/pull/1", + title: "Do the thing", + body: "", branch: "alejandro/nex-1-do-thing", base: "main", base_sha: "abc123", @@ -62,11 +64,12 @@ export function makeCheck(overrides: Partial<CiCheck> = {}): CiCheck { }; } -/** The five gate states, for exhaustive iteration in tests. */ +/** Every gate state, for exhaustive iteration in tests. */ export const ALL_GATE_STATES = [ "Pending", "InReview", "Dispatched", "Reworked", "Approved", + "Merged", ] as const satisfies readonly GateState[]; diff --git a/cockpit-docs/RESEARCH_REVIEW_BOTTLENECK.md b/cockpit-docs/RESEARCH_REVIEW_BOTTLENECK.md new file mode 100644 index 0000000..8b241f6 --- /dev/null +++ b/cockpit-docs/RESEARCH_REVIEW_BOTTLENECK.md @@ -0,0 +1,134 @@ +# The review bottleneck — verified research synthesis + +*Compiled 2026-07-01 from a deep-research pass (105 agents: 5 search angles → source fetch → +per-claim 3-vote adversarial verification → synthesis). Every claim below survived +verification against its primary source; three popular claims were refuted and are listed at +the end so nobody re-imports them. This document is the evidence base for +[`ROADMAP.md`](./ROADMAP.md).* + +--- + +## 1. The bottleneck is real, quantified, and growing — with one nuance + +- **Meta (peer-reviewed, PACM-SE 2026)** is the most direct quantification of cockpit's core + thesis: significant lines of code per human-landed diff grew **+105.9% YoY**, per-developer + diff volume rose **+51%** (agentic AI responsible for **>80%** of that growth), while the + share of diffs reviewed within 24h **declined**. The paper's own words: *"a widening gap + between code supply and reviewer bandwidth."* + [arXiv:2605.30208](https://arxiv.org/pdf/2605.30208) — confidence: high. +- **DX telemetry** (51k+ developers, 435 companies, Q4 2025): daily AI users merge a median + 2.3 PRs/week vs 1.4 for non-users — *"Daily AI users ship 60% more PRs."* Independently, + Faros.ai (1,255 teams): high-AI-adoption teams merge **+98% more PRs** with **PR review + time up +91%**. [getdx.com Q4 2025 report](https://getdx.com/blog/ai-assisted-engineering-q4-impact-report-2025/) + — confidence: high (correlational; DX flags self-selection). +- **DORA 2025** (n≈5,000): AI adoption now correlates **positively with throughput** and + **negatively with delivery stability**; DORA's stated mechanism is that AI-accelerated + change volume *exposes weaknesses downstream* where control systems (automated testing, + fast feedback loops) are lacking. + [2025 DORA report](https://cloud.google.com/blog/products/ai-machine-learning/announcing-the-2025-dora-report) + — confidence: high. +- **The nuance:** the same DX data ranks *meetings and interruptions* — not review wait — as + developers' biggest obstacle. The bottleneck claim is strongest specifically where + agent-PR volume is high (Meta, Cloudflare). Cockpit's market is exactly that segment. +- **Review burden is also getting heavier per line:** GitClear (211M changed lines, + 2020–2024): cloned-line share rose 8.3%→12.3% while refactoring fell 25%→10%; block-level + duplicates (5+ lines) grew ~8x during 2024. + [GitClear 2025](https://www.gitclear.com/ai_assistant_code_quality_2025_research) — + confidence: medium (vendor research). + +## 2. Human review remains a required gate — full autonomy fails empirically + +- **MSR 2026** (peer-reviewed, 3,109 agent-authored PRs): PRs reviewed *only* by code-review + agents merged at **45.20% vs 68.37%** for human-only review (23pp gap, p<0.001) and were + abandoned at 34.88% vs 21.60%. The paper's conclusion: *"CRAs cannot effectively replace + human reviewers."* [arXiv:2604.03196](https://arxiv.org/pdf/2604.03196) — confidence: high. +- **DORA 2025:** 30% of respondents report little or no trust in AI-generated code (down from + ~39% in 2024 — trust is rising, but slowly). +- **Rubber-stamping is the default failure mode, not a hypothetical:** in ≥100-star OSS + repos, **61.38%** of agent-authored PRs receive *no recorded review activity at all* + (EASE 2026, AIDev dataset, 33,596 PRs). + [arXiv:2605.02273](https://arxiv.org/html/2605.02273v1) — confidence: high (recorded + activity ≠ total oversight; silent inspection is invisible). + +## 3. What actually works at scale: risk-tiered pre-review funnels + +Independent at-scale deployments converged on the same architecture — **layered +deterministic gates before LLM judgment, conservative auto-accept, human as the default +route**: + +- **Meta RADAR** (peer-reviewed): source-type eligibility gates (vetted codemods, runbooks + with 60-day clean history, volume caps, denylists) → ML Diff Risk Score percentile + threshold → LLM reviewer that auto-accepts only at confidence ≥8/10 in predefined safe + categories; *any* risk signal routes to a human. Results on 535K+ diffs: revert rate **1/3** + and production-incident rate **1/50** of non-RADAR diffs, median time-to-close ~4.3x faster + (observational — eligibility gates select low-risk diffs; the ratios embed selection). + Every production incident that did occur had been *expert-reviewed*, and none were judged + human-detectable. — confidence: high, with the selection caveat. +- **Cloudflare** (self-reported): explicitly names human review as a primary bottleneck + (median first-review wait "measured in hours"). Their CI-native multi-agent reviewer, first + 30 days: 131,246 review runs across 48,095 MRs in 5,169 repos; 3 size/risk tiers + (trivial ≤10 lines ~$0.20 → full >100 lines / security-touching, 7+ specialist agents, + ~$1.68); median review 3m39s, avg $1.19. Their own words: *"This isn't a replacement for + human code review."* [blog.cloudflare.com/ai-code-review](https://blog.cloudflare.com/ai-code-review/) + — confidence: medium (first-party telemetry). +- **Atlassian Rovo Dev Code Reviewer** (peer-reviewed, ICSE 2026 SEIP; 1,900+ repos, 54k+ AI + comments over a year): **−30.8% median PR cycle time**, **−35.6% human review comments per + PR**; 38.7% of AI comments led directly to code changes (humans: 44.45%). + [arXiv:2601.01129](https://arxiv.org/abs/2601.01129) — confidence: high + (quasi-experimental, vendor-on-own-repos). +- **Microsoft reports** >90% of PRs covered (600K+/month) and 10–20% median PR completion + time improvement across ~5,000 repos. + [Engineering@Microsoft](https://devblogs.microsoft.com/engineering-at-microsoft/enhancing-code-quality-at-scale-with-ai-powered-code-reviews/) + — confidence: medium (self-reported, no methodology). + +## 4. Reviewing agent PRs is *behaviorally different work* — direct validation of the loop + +EASE 2026, holding repositories constant: human participation is nearly identical on agent- +vs human-authored PRs (30.12% vs 30.83%), **but** only 65.53% of human comments on agent PRs +are direct line review (vs 93.56% on human PRs), while **agent-steering commands make up +25.92%** of comments on agent PRs (vs 1.63%). Review of agent code shifts toward +**steering/rework direction** rather than line-level evaluation. — confidence: high. + +This is the single most product-relevant finding: the natural unit of interaction with an +agent PR is a **steer→rework cycle**, exactly cockpit's gated `request_changes → +Dispatched → Reworked` loop with ephemeral comments — not a durable GitHub comment thread. + +## 5. Implications for cockpit (evidence → product) + +1. **The gated review→rework loop is the right model** (§4). Invest in making the cycle + cheap, not in replicating GitHub's thread model. +2. **AI pre-review as a first pass, never a gate** (§2, §3): an advisory reviewer-subagent + pass (SPEC already permits it) mirrors Atlassian's −30.8% cycle time — but per MSR 2026 it + must not become the merge decision. +3. **Risk-based routing of human attention** (§3): cockpit can't train a Meta-scale risk + model, but the minimum viable risk signal is available locally: diff size, file + sensitivity (config/migrations/auth/CI), test-delta, CI status, stack position, agent + trajectory anomalies. Rank the board with it; give small+green+low-risk a fast lane — + *presentation*, not auto-approve (§9 guardrails stay). +4. **Verification instead of reading** (§3, DORA's control-systems mechanism): surface + machine evidence — CI per card, test deltas, what the agent ran — before human eyes reach + the diff. +5. **Design against rubber-stamping** (§2): with 61% of agent PRs unreviewed in the wild, + cockpit's job is making real review cheap enough to actually happen — and being honest + when it isn't happening (e.g., approve-without-opening metrics), never adding one-click + batch approval. + +## Refuted claims — do not cite + +Adversarial verification killed these; they circulate widely: + +1. *"60.2% of closed CRA-only PRs had 0–30% signal-to-noise"* — refuted 1-2. +2. *"PRs waited days-to-weeks at Microsoft pre-AI"* — refuted. +3. *"84% of agent PRs lack any direct human evaluation"* — the number (61.38% unreviewed + + 22.6% agent-only = 84.0%) is real, but the *"no direct human evaluation"* inference was + refuted 0-3 (recorded-activity data can't support it). + +## Open questions (candidate follow-up research) + +1. Minimum viable risk signal for small teams / local-first tools without Meta-scale history. +2. RCT-grade defect-escape comparison of AI vs human review (all current safety evidence is + observational with selection effects). +3. **The commercial tool landscape** (Graphite, CodeRabbit, Greptile, Cursor BugBot, + Conductor, Terragon) and stacked-PR/batch-review quantitative evidence — findings here did + **not** survive verification; competitive positioning needs a separate targeted pass. +4. At what signal-to-noise threshold AI review comments become net-negative. diff --git a/cockpit-docs/ROADMAP.md b/cockpit-docs/ROADMAP.md new file mode 100644 index 0000000..bfc17c5 --- /dev/null +++ b/cockpit-docs/ROADMAP.md @@ -0,0 +1,127 @@ +# Cockpit roadmap — reducing the review bottleneck + +*Written 2026-07-01. Grounded in two things: the verified external evidence in +[`RESEARCH_REVIEW_BOTTLENECK.md`](./RESEARCH_REVIEW_BOTTLENECK.md) (cited below as R§n) and +three independent code audits of what cockpit actually does today. Supersedes nothing; +`SPEC.md` remains the model of record — this is the prioritized path.* + +## Why cockpit exists (sharpened) + +Code production is cheap; human review is the constraint — Meta measures the gap directly +(+105.9% lines/diff YoY while review-within-24h falls, R§1), and in the wild the failure +mode is already **rubber-stamping**: 61% of agent PRs get no recorded review at all (R§2). +The evidence says review of agent PRs is *steering work* — 26% of comments are rework +commands, not line critique (R§4) — which is exactly cockpit's gated review→rework loop. + +So cockpit's job, precisely: **maximize verified decisions per reviewer-hour, without +becoming the rubber stamp.** Every roadmap item below is judged against that sentence. + +--- + +## Phase A — Close the loop (implemented — PR #12) + +The audits found cockpit couldn't yet *finish* a review. This program fixes the defects; +it is the precondition for everything else. + +| # | Item | Defect it fixes | +|---|------|-----------------| +| A1 | Real diff line numbers (map hunk fragments → file lines, `side` on anchors) | Comment anchors, rework prompts, and GitHub mirrors all pointed at wrong lines | +| A2 | Approve + Merge (`GateState::Merged`, `gh pr merge --squash` behind explicit confirm, worktree GC) | The happy path was unreachable — no Approve button existed, no merge anywhere | +| A3 | HEAD-authoritative agent outcome (`apply_agent_completion`) | Failed/no-op agents reported success, flipped to Reworked, and destroyed the reviewer's comments | +| A4 | Intent surfacing (PR title/body on `Review`, intent panel, real intent in rework prompts) | Reviewers judged "did it do what was asked" with only a branch slug; fixers got a one-token intent | +| A5 | State persistence (`~/.cockpit/state.json`, atomic, revision-driven flush) | Every restart erased all gate states, comments, and plans | +| A6 | Stack edges for imported PRs + concurrent ingestion | gh-stack stacks rendered flat (stale/restack/frontier dead); refresh took 2N+1 serial subprocesses | +| A7 | Stale propagation (descendants marked stale on dispatch/merge) | The Restack affordance existed but nothing ever set `stale` | +| A8 | Non-blocking plan approve (background fan-out via the streaming spawn path) | Approving a plan froze the UI until every implementer finished; piped stdout could deadlock | +| A9 | Real GitHub reviews (line-anchored `pulls/{n}/reviews` with APPROVE / REQUEST_CHANGES verdicts) | "Submit Review" posted top-level comment spam with wrong line numbers; approval never reached the teammate | +| A10 | Interdiff re-review (`dispatch_snapshot`, default "changes since your review" view) | Every rework cycle cost a full re-read; the requests being addressed were deleted | +| A11 | Agent panel scoping + Stop + logs (events keyed by review, `kill_agent`, open log) | Parallel agents interleaved into one timeline; no way to stop a runaway agent | +| A12 | Worktree correctness for imported PRs (shell + fixer run on the PR branch, not the main checkout) | The Shell tab "verified" whatever branch the main checkout happened to be on | +| A13 | Dead-code removal (workflow.rs engine, `get_version`, `transition_event`, dead frontier slice) | Unused seams misleading every future change | +| A14 | Stack-grouped board (render the DAG with the existing `stack-tree` lib, enabled by A6) | Reviews form a dependency DAG; the board showed a flat list | + +## Phase B — Verification instead of reading + +DORA's mechanism (volume exposes weak control systems, R§1) and Meta's RADAR result (R§3) +both say the same thing: the cheapest review minute is the one replaced by machine evidence +the reviewer can *trust at a glance*. + +- **B1. Evidence strip per PR** — one glanceable row above the diff: CI x/y with the failing + job named · test delta (added/changed/deleted test files and assertion counts — SPEC §8's + unimplemented `ci_delta`/`test_count_delta`) · "agent ran: `cargo test` ✓ (from trajectory)" + · lockfile/migration/config-touch flags. *This is the RADAR eligibility-gate idea recast as + presentation: deterministic signals first, human judgment second.* +- **B2. Advisory reviewer pre-pass** — a local reviewer subagent (SPEC §2/§10 already permits + it) that annotates the diff with flags before the human opens it. Atlassian measured + −30.8% cycle time from exactly this (R§3); MSR 2026 says it must stay advisory, never the + gate (R§2). Findings render as dismissible pins, never block, never auto-approve. +- **B3. Test-weakening detector** — diff-level heuristic (deleted assertions, `#[ignore]`, + `|| true`, snapshot wholesale updates) surfaced as a red flag. Agents weaken tests to get + green; CLAUDE.md §0.6 bans it — cockpit should *see* it. +- **B4. Full-file diff context** — feed Monaco whole files (worktree when local, `gh api` + contents when not) so reviewers can scroll beyond hunks and LSP hovers are truthful. + (A1 fixed the line numbers; this fixes the blindfold.) + +## Phase C — Route human attention by risk + +Cockpit can't train a Diff Risk Score on Meta-scale history, but the *minimum viable risk +signal* (open question R-OQ1) is computable locally today: diff size, path sensitivity +(auth/config/migrations/CI/infra), test delta, CI status, stack position, agent-trajectory +anomalies (retries, scope excursions, long thinking on one file). + +- **C1. Triage ranking v2** — board order = needs-human ∧ CI-green ∧ small ∧ frontier-root + first ("decisions you can make in 2 minutes"), risky/red/large last, with the *reason* + shown on the card (NASA-annunciator style, already the design system's discipline). +- **C2. Fast lane presentation** — a visually distinct "small + green + low-risk + plan-conformant" + shelf. Explicitly NOT auto-approve (30% distrust, R§2; §9 guardrails) — it compresses the + *decision*, not the *authority*. +- **C3. Risk chips on cards** — size class, sensitive-path flag, test-delta, CI — the four + signals the research says carry most of the routing value. +- **C4. Batch CI without N subprocesses** — `gh pr list/search --json statusCheckRollup` gives + rollups in the list call; cards get CI state at fetch cost ~0. + +## Phase D — Make the rework cycle disappear + +The loop is cockpit's moat (R§4). After A10's interdiff, the remaining cycle costs: + +- **D1. Addressed-request checklist** — map each dispatched request to the interdiff region + that answers it ("request → change" pairing), so re-review is confirm-per-request, not + re-read. +- **D2. Agent transcript persistence** — keep per-review trajectory summaries + (`~/.cockpit/logs` already holds raw logs; surface them) so "what did it try?" never + requires re-running. +- **D3. Auto-rebase hygiene** — when a parent merges, offer one-click dependency-ordered + restack of the whole stack (A7's ordering helper makes this possible). +- **D4. Notify on reviewable** — background refresh + OS notification when a PR flips + Reworked / a new review-request arrives; suggest the next frontier item while agents run. + +## Phase E — The teammate half of the job + +The user reviews colleagues' PRs too; today that flow is read-only-plus-comment-spam (fixed +in A9). Next: + +- **E1. Ingest GitHub review state** — show teammates' existing reviews/comments (as + read-only context, `GitHubMirror` origin) so cockpit isn't blind to the conversation. +- **E2. Re-review on push** — when a review-requested PR gets new commits, the interdiff + machinery (A10) applies: "changes since your last review" for teammates' PRs. +- **E3. Linear description in intent** — kickoff reviews currently carry only the issue + title; fetch the description for the intent panel and prompts. + +## Deliberate non-goals (guardrails, restated) + +- **No auto-approve, no auto-merge, no batch-approve.** Every terminal action stays behind + an explicit human click (§9). The research's strongest negative result (R§2) is what + happens when this erodes. +- **No AI verdict as gate.** Pre-review is advisory annotation only. +- **No durable comment threads.** Comments stay ephemeral per cycle (Invariant §0.4); + `dispatch_snapshot` is single-cycle read-only history, not a thread. +- **Local-first stands.** Nothing in the loop blocks on GitHub round-trips (§0.1). + +## Known evidence gaps + +- The commercial landscape (Graphite, CodeRabbit, Greptile, BugBot, Conductor, Terragon) + did not survive verification in the research pass — competitive positioning needs a + dedicated follow-up before we copy or counter-position against any of them. +- Stacked-PR throughput gains are believed but unquantified in the literature; cockpit's own + dogfooding is currently our best data source. Instrumenting review-session times + (locally, private) would let cockpit measure its own thesis. diff --git a/crates/cockpit-core/src/adapters/git.rs b/crates/cockpit-core/src/adapters/git.rs index 927e3be..aeac802 100644 --- a/crates/cockpit-core/src/adapters/git.rs +++ b/crates/cockpit-core/src/adapters/git.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; -use git2::{BranchType, Oid, Repository, WorktreeAddOptions, WorktreePruneOptions}; +use git2::{BranchType, DiffFormat, Oid, Repository, WorktreeAddOptions, WorktreePruneOptions}; use tokio::process::Command; /// Errors from git worktree operations. @@ -159,6 +159,40 @@ pub fn reconcile(worktree_path: &Path) -> Result<git2::Oid, Error> { Ok(commit.id()) } +/// Produce a unified diff of the changes between two revisions in a worktree. +/// +/// Resolves `from` and `to` as revisions (SHAs, refs, or any revspec git +/// understands) within `worktree` and returns the `from..to` patch in standard +/// unified-diff format — the same `diff --git` / `@@` shape that `gh pr diff` +/// emits, so it parses with the frontend's unified-diff parser. +/// +/// Used for interdiff re-review: showing the reviewer only the changes since +/// their last dispatch rather than the full PR again. +/// +/// An unknown revision or invalid SHA yields [`Error::Git2`] rather than +/// panicking. Identical `from` and `to` produce an empty string. +pub fn diff_range(worktree: &Path, from: &str, to: &str) -> Result<String, Error> { + let repo = Repository::open(worktree)?; + let from_tree = repo.revparse_single(from)?.peel_to_tree()?; + let to_tree = repo.revparse_single(to)?.peel_to_tree()?; + + let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?; + + let mut patch = String::new(); + diff.print(DiffFormat::Patch, |_delta, _hunk, line| { + // Context/added/removed lines carry their prefix in `origin`; file and + // hunk headers already include their prefix in `content`, so only + // prepend the marker for the three body-line kinds. + if matches!(line.origin(), ' ' | '+' | '-') { + patch.push(line.origin()); + } + patch.push_str(&String::from_utf8_lossy(line.content())); + true + })?; + + Ok(patch) +} + /// Remove a worktree and clean up its directory. /// /// Accepts the branch name and flattens it with [`worktree_name`] to match @@ -769,6 +803,61 @@ mod tests { assert!(result.is_err()); } + #[test] + fn diff_range_two_commits() { + let (repo, dir) = scratch_repo_with_file(); + + // scratch_repo_with_file seeds base.txt with "line 1\n" on `main`. + // Replace line 1 and append a line so the patch has both -/+ lines. + commit_file(&repo, "base.txt", b"line one\nline 2\n", "edit base"); + + let patch = diff_range(dir.path(), "HEAD~1", "HEAD").unwrap(); + + assert!( + patch.contains("@@"), + "diff should contain a hunk header, got:\n{patch}" + ); + assert!( + patch.contains("-line 1"), + "diff should show the removed line, got:\n{patch}" + ); + assert!( + patch.contains("+line one"), + "diff should show the added line, got:\n{patch}" + ); + assert!( + patch.contains("base.txt"), + "diff should name the changed file, got:\n{patch}" + ); + } + + #[test] + fn diff_range_unknown_revision() { + let (_repo, dir) = scratch_repo_with_file(); + + // A well-formed but nonexistent SHA must error, not panic. + let result = diff_range( + dir.path(), + "HEAD", + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ); + assert!( + matches!(result, Err(Error::Git2(_))), + "unknown revision should yield Error::Git2, got {result:?}" + ); + } + + #[test] + fn diff_range_identical_is_empty() { + let (_repo, dir) = scratch_repo_with_file(); + + let patch = diff_range(dir.path(), "HEAD", "HEAD").unwrap(); + assert!( + patch.is_empty(), + "identical revisions should produce an empty diff, got:\n{patch}" + ); + } + #[test] fn prune_worktree_removes_worktree() { let (repo, dir) = scratch_repo(); diff --git a/crates/cockpit-core/src/adapters/github.rs b/crates/cockpit-core/src/adapters/github.rs index 5b652c8..35054d6 100644 --- a/crates/cockpit-core/src/adapters/github.rs +++ b/crates/cockpit-core/src/adapters/github.rs @@ -5,10 +5,11 @@ //! so cockpit links PR to issue by parsing the head branch. See `SPEC.md` S16. use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; use tokio::process::Command; use ts_rs::TS; -use crate::model::{Anchor, Comment, CommentId, CommentOrigin, IssueRef, PrRef}; +use crate::model::{Anchor, Comment, CommentId, CommentOrigin, DiffSide, IssueRef, PrRef}; // --------------------------------------------------------------------------- // Errors @@ -59,6 +60,9 @@ pub struct PrData { pub base_ref_name: String, /// PR title. pub title: String, + /// PR description / body. Absent in legacy field sets, so defaulted. + #[serde(default)] + pub body: String, /// Current PR state (e.g. "OPEN", "MERGED", "CLOSED"). pub state: String, /// Full URL of the PR on GitHub. @@ -198,7 +202,7 @@ pub async fn list_prs() -> Result<Vec<PrData>, Error> { "pr", "list", "--json", - "number,headRefName,baseRefName,title,state,url", + "number,headRefName,baseRefName,title,body,state,url", "--limit", "100", ]) @@ -505,9 +509,15 @@ pub enum PrFilter { /// List open PRs with a filter across all accessible repositories. /// -/// Uses `gh search prs` (cross-repo) to discover PRs, then enriches each -/// with branch info from `gh pr view --repo`. This way PRs from any repo -/// the user has access to are returned, not just a single configured repo. +/// Uses `gh search prs` (cross-repo) to discover PRs, then enriches each with +/// branch info from `gh pr view --repo`. Enrichment runs with a bounded number +/// of in-flight `gh` calls ([`ENRICH_CONCURRENCY`]) and the results are +/// re-sorted so the output order matches the search order. This way PRs from any +/// repo the user has access to are returned, not just a single configured repo. +/// +/// A per-PR enrichment failure is non-fatal: that PR falls back to the fields +/// already known from the search result (with empty branch names) rather than +/// failing the whole call. /// /// - [`PrFilter::Authored`] → `gh search prs --author=@me` /// - [`PrFilter::ReviewRequested`] → searches both `--review-requested=@me` @@ -522,7 +532,10 @@ pub async fn list_prs_filtered( PrFilter::ReviewRequested => { let mut pending = search_prs(&["--review-requested", "@me"]).await?; let reviewed = search_prs(&["--reviewed-by", "@me"]).await?; - let me = gh_whoami().await.unwrap_or_default(); + // GitHub logins are case-insensitive; lowercase our identity so the + // self-exclusion below compares like-for-like against the likewise + // lowercased `author_login`. + let me = gh_whoami().await.unwrap_or_default().to_lowercase(); // Merge and deduplicate by URL. let mut seen = std::collections::HashSet::new(); @@ -544,30 +557,73 @@ pub async fn list_prs_filtered( } }; - // Enrich each PR with branch names via `gh pr view --repo`. - let mut prs = Vec::with_capacity(search_results.len()); - for sr in &search_results { - let slug = &sr.repository.name_with_owner; - match enrich_pr(slug, sr.number).await { - Ok(mut pr) => { - pr.repo_slug = slug.clone(); - prs.push(pr); - } - Err(_) => { - prs.push(PrData { - number: sr.number, - head_ref_name: String::new(), - base_ref_name: String::new(), - title: sr.title.clone(), - state: sr.state.clone(), - url: sr.url.clone(), - repo_slug: slug.clone(), - }); + // Enrich each PR with branch names via `gh pr view --repo`, running up to + // ENRICH_CONCURRENCY calls at once. Each task is tagged with its search-order + // index so the results can be re-sorted below; a task never fails (an enrich + // error resolves to a fallback `PrData`), preserving the old semantics where + // one PR's failure does not abort the whole listing. + let total = search_results.len(); + let mut slots: Vec<Option<PrData>> = vec![None; total]; + let mut join_set: tokio::task::JoinSet<(usize, PrData)> = tokio::task::JoinSet::new(); + let mut pending = search_results.into_iter().enumerate(); + + // Prime the initial window of in-flight enrichments. + for _ in 0..ENRICH_CONCURRENCY { + match pending.next() { + Some((index, sr)) => { + join_set.spawn(enrich_task(index, sr)); } + None => break, + } + } + + // As each enrichment completes, record it and start the next one. + while let Some(joined) = join_set.join_next().await { + let (index, pr) = + joined.map_err(|e| Error::GhCommand(format!("enrich task failed to join: {e}")))?; + if let Some(slot) = slots.get_mut(index) { + *slot = Some(pr); + } + if let Some((index, sr)) = pending.next() { + join_set.spawn(enrich_task(index, sr)); } } - Ok(prs) + Ok(slots.into_iter().flatten().collect()) +} + +/// Maximum number of concurrent `gh pr view` enrichment calls in +/// [`list_prs_filtered`]. Bounds fan-out so a large PR list cannot spawn an +/// unbounded number of `gh` subprocesses at once. +const ENRICH_CONCURRENCY: usize = 8; + +/// Enrich a single search result into a full [`PrData`], returning its +/// search-order `index` alongside the result. +/// +/// On enrichment failure the PR falls back to the fields already known from the +/// search result (with empty branch names), so the task always resolves — a +/// per-PR failure never aborts [`list_prs_filtered`]. +async fn enrich_task(index: usize, sr: SearchPrResult) -> (usize, PrData) { + let slug = sr.repository.name_with_owner.clone(); + match enrich_pr(&slug, sr.number).await { + Ok(mut pr) => { + pr.repo_slug = slug; + (index, pr) + } + Err(_) => ( + index, + PrData { + number: sr.number, + head_ref_name: String::new(), + base_ref_name: String::new(), + title: sr.title, + body: String::new(), + state: sr.state, + url: sr.url, + repo_slug: slug, + }, + ), + } } /// Run `gh search prs --state=open` with the given extra args. @@ -629,9 +685,12 @@ struct SearchPrResult { } impl SearchPrResult { - /// Login of the PR author, lowercased for comparison. - fn author_login(&self) -> &str { - &self.author.login + /// Login of the PR author, lowercased for case-insensitive comparison. + /// + /// GitHub logins are case-insensitive, so callers compare against a + /// likewise-lowercased identity (see [`list_prs_filtered`]). + fn author_login(&self) -> String { + self.author.login.to_lowercase() } } @@ -659,7 +718,7 @@ async fn enrich_pr(repo_slug: &str, pr_number: u64) -> Result<PrData, Error> { "--repo", repo_slug, "--json", - "number,headRefName,baseRefName,title,state,url", + "number,headRefName,baseRefName,title,body,state,url", ]) .output() .await?; @@ -740,6 +799,8 @@ pub fn build_review_from_pr( id: ReviewId::new(id_prefix), issue, pr: PrRef::new(&pr.url), + title: pr.title.clone(), + body: pr.body.clone(), branch: pr.head_ref_name.clone(), base: pr.base_ref_name.clone(), base_sha: String::new(), @@ -755,6 +816,7 @@ pub fn build_review_from_pr( agent: None, repo_slug: slug, project: None, + dispatch_snapshot: None, } } @@ -772,6 +834,73 @@ pub fn link_prs_to_issues(prs: &[PrData]) -> Vec<(PrRef, Option<IssueRef>)> { .collect() } +/// Wire stack `parents`/`children` edges across GitHub-imported reviews by +/// matching each review's `base` branch against another review's head `branch`. +/// +/// A review whose `base` is another review's `branch` is stacked on top of it: +/// the base review becomes the parent and this review becomes its child. This +/// mirrors the semantics of `kickoff::wire_children`, but derives the graph from +/// GitHub branch topology rather than the Linear DAG. +/// +/// Only reviews sourced from GitHub ([`ReviewSource::Authored`] / +/// [`ReviewSource::ReviewRequested`]) have their edges cleared and rebuilt. +/// Reviews created by a project kickoff ([`ReviewSource::Frontier`]) keep the +/// edges wired from the Linear DAG untouched — a review's `base` matching a +/// frontier branch may still add the frontier review as a parent, but the +/// frontier review's own edges are never cleared. +pub fn wire_stack_edges(reviews: &mut [crate::model::Review]) { + use crate::model::ReviewSource; + + // GitHub-imported reviews derive their stack purely from branch topology, so + // their edges are safe to clear and rebuild. Frontier reviews carry + // kickoff-wired edges that must be preserved. + fn is_github_sourced(source: ReviewSource) -> bool { + matches!( + source, + ReviewSource::Authored | ReviewSource::ReviewRequested + ) + } + + // Clear existing edges only on the reviews we own the topology for. + for review in reviews.iter_mut() { + if is_github_sourced(review.source) { + review.parents.clear(); + review.children.clear(); + } + } + + // Map head branch -> review id so a base branch can be resolved to a parent. + let branch_to_id: std::collections::HashMap<String, crate::model::ReviewId> = reviews + .iter() + .map(|r| (r.branch.clone(), r.id.clone())) + .collect(); + + // Derive parent edges from base==branch, collecting the (parent, child) + // pairs to wire children in a second pass (avoids a mutable-borrow conflict). + let mut edges: Vec<(crate::model::ReviewId, crate::model::ReviewId)> = Vec::new(); + for review in reviews.iter_mut() { + if !is_github_sourced(review.source) { + continue; + } + if let Some(parent_id) = branch_to_id.get(&review.base) { + // A review can never be its own parent (guards a self-referential + // base branch). + if parent_id != &review.id { + review.parents.push(parent_id.clone()); + edges.push((parent_id.clone(), review.id.clone())); + } + } + } + + for (parent_id, child_id) in edges { + if let Some(parent) = reviews.iter_mut().find(|r| r.id == parent_id) + && !parent.children.contains(&child_id) + { + parent.children.push(child_id); + } + } +} + // --------------------------------------------------------------------------- // Comment mirroring // --------------------------------------------------------------------------- @@ -797,7 +926,7 @@ pub fn format_comment_body(comment: &Comment) -> String { let anchor_label = match &comment.anchor { Anchor::PlanStep(idx) => format!("**Plan step {idx}**"), Anchor::PlanFile(path) => format!("**Plan file:** `{}`", path.display()), - Anchor::DiffLine { path, range } => { + Anchor::DiffLine { path, range, .. } => { if range.0 == range.1 { format!("**{}** line {}", path.display(), range.0) } else { @@ -859,6 +988,367 @@ pub async fn mirror_comments(pr_ref: &PrRef, comments: &[Comment]) -> Result<Mir Ok(MirrorResult { posted, failed }) } +// --------------------------------------------------------------------------- +// Merge +// --------------------------------------------------------------------------- + +/// How GitHub should combine a PR's commits when merging. +/// +/// Mirrors `gh pr merge`'s mutually-exclusive strategy flags. Defaults to +/// [`MergeMethod::Squash`], the common single-commit-per-PR workflow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../app/src/bindings/")] +pub enum MergeMethod { + /// Squash all commits into one, then merge (`--squash`). The default. + #[default] + Squash, + /// Create a merge commit (`--merge`). + Merge, + /// Rebase the commits onto the base (`--rebase`). + Rebase, +} + +/// Merge a PR via `gh pr merge`, deleting the head branch on success. +/// +/// When `repo_slug` is `Some`, targets that repository with `--repo` so the +/// call works cross-repo without a `current_dir`. `method` selects the merge +/// strategy flag. +/// +/// This is a guarded side effect (Invariant 5): it must only be invoked after an +/// explicit human confirmation in the UI, never automatically or from agent +/// output. A non-zero `gh` exit captures stderr into [`Error::GhCommand`]. +pub async fn merge_pr( + repo_slug: Option<&str>, + pr_number: u64, + method: MergeMethod, +) -> Result<(), Error> { + let pr = pr_number.to_string(); + let method_flag = match method { + MergeMethod::Squash => "--squash", + MergeMethod::Merge => "--merge", + MergeMethod::Rebase => "--rebase", + }; + let mut args: Vec<&str> = vec!["pr", "merge", &pr, method_flag, "--delete-branch"]; + if let Some(slug) = repo_slug { + args.push("--repo"); + args.push(slug); + } + + let output = Command::new("gh").args(&args).output().await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::GhCommand(stderr.into_owned())); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Compare +// --------------------------------------------------------------------------- + +/// Fetch a unified diff between two commits via the GitHub compare API. +/// +/// Runs `gh api repos/<slug>/compare/<base>...<head>` with the +/// `application/vnd.github.v3.diff` media type so the response body IS a unified +/// diff (rather than the default JSON summary). Used to diff arbitrary commit +/// ranges (e.g. a review's `base_sha`..`head_sha`) without a local checkout. +pub async fn compare(repo_slug: &str, base_sha: &str, head_sha: &str) -> Result<String, Error> { + let endpoint = format!("repos/{repo_slug}/compare/{base_sha}...{head_sha}"); + let output = Command::new("gh") + .args([ + "api", + &endpoint, + "-H", + "Accept: application/vnd.github.v3.diff", + ]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::GhCommand(stderr.into_owned())); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +// --------------------------------------------------------------------------- +// Reviews (inline review comments) +// --------------------------------------------------------------------------- + +/// The kind of review to submit to GitHub, matching the API's `event` values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../app/src/bindings/")] +pub enum ReviewEvent { + /// Approve the pull request (`APPROVE`). + Approve, + /// Request changes on the pull request (`REQUEST_CHANGES`). + RequestChanges, + /// Leave a review without approving or requesting changes (`COMMENT`). + Comment, +} + +/// Result of submitting a review to a GitHub PR. +/// +/// `submitted` counts the inline comments actually included in the review; +/// `skipped` lists the comments that were left out with a human-readable reason +/// (e.g. their anchored line is not part of the PR's diff), so the caller can +/// report partial success. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../app/src/bindings/")] +pub struct SubmitReviewResult { + /// Number of inline comments included in the submitted review. + pub submitted: usize, + /// Comments left out of the review: `(comment_id, reason)`. + pub skipped: Vec<(CommentId, String)>, +} + +/// Convert a cockpit [`Comment`] into a GitHub review-comment JSON object. +/// +/// Only [`Anchor::DiffLine`] anchors map to an inline review comment; every +/// other anchor kind returns `None`. The GitHub side string is `"RIGHT"` for the +/// new side and `"LEFT"` for the old side. A multi-line range additionally +/// carries `start_line`/`start_side`, matching GitHub's multi-line comment shape. +fn review_comment_payload(comment: &Comment) -> Option<serde_json::Value> { + let Anchor::DiffLine { path, range, side } = &comment.anchor else { + return None; + }; + let (start, end) = *range; + let side_str = diff_side_label(*side); + + let mut payload = serde_json::json!({ + "path": path.display().to_string(), + "line": end, + "side": side_str, + "body": comment.body, + }); + if start != end { + payload["start_line"] = serde_json::json!(start); + payload["start_side"] = serde_json::json!(side_str); + } + Some(payload) +} + +/// GitHub's side label for a [`DiffSide`]. +fn diff_side_label(side: DiffSide) -> &'static str { + match side { + DiffSide::New => "RIGHT", + DiffSide::Old => "LEFT", + } +} + +/// A parsed unified-diff hunk header: `@@ -old_start,old_len +new_start,new_len @@`. +#[derive(Debug, Clone, Copy)] +struct HunkHeader { + old_start: u32, + old_len: u32, + new_start: u32, + new_len: u32, +} + +/// Parse a hunk header line (`@@ -a,b +c,d @@`) into its four numbers. +/// +/// The count is optional in unified diffs (`@@ -a +c @@` means a count of 1), +/// so a missing `,len` defaults to 1. Returns `None` for any non-hunk line. +fn parse_hunk_header(line: &str) -> Option<HunkHeader> { + let rest = line.strip_prefix("@@ ")?; + let close = rest.find(" @@")?; + let spec = &rest[..close]; + + let mut parts = spec.split_whitespace(); + let old = parts.next()?.strip_prefix('-')?; + let new = parts.next()?.strip_prefix('+')?; + let (old_start, old_len) = parse_hunk_range(old)?; + let (new_start, new_len) = parse_hunk_range(new)?; + + Some(HunkHeader { + old_start, + old_len, + new_start, + new_len, + }) +} + +/// Parse a hunk range component (`a,b` or `a`) into `(start, len)`. +fn parse_hunk_range(s: &str) -> Option<(u32, u32)> { + match s.split_once(',') { + Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)), + None => Some((s.parse().ok()?, 1)), + } +} + +/// Extract the repo-relative path from a `---`/`+++` file header value. +/// +/// Strips the `a/` or `b/` prefix and any trailing tab-delimited metadata. +/// Returns `None` for `/dev/null` (an added or deleted file has no counterpart). +fn parse_file_header_path(value: &str) -> Option<String> { + let token = value.split('\t').next().unwrap_or(value).trim(); + if token == "/dev/null" { + return None; + } + let stripped = token + .strip_prefix("a/") + .or_else(|| token.strip_prefix("b/")) + .unwrap_or(token); + Some(stripped.to_string()) +} + +/// Validate that a comment's anchored line range is part of a PR's diff. +/// +/// GitHub rejects (422) review comments whose line is not in the diff, so this +/// pre-flights each comment. It walks the unified diff, tracking the current +/// file's old/new paths and each hunk header. A [`Anchor::DiffLine`] is valid +/// when some hunk on its side covers the whole `range` for the matching path, +/// where a header `@@ -a,b +c,d @@` covers old lines `[a, a+b)` and new lines +/// `[c, c+d)`. Non-`DiffLine` anchors cannot be inline comments and are rejected. +fn validate_comment_in_diff(comment: &Comment, diff: &str) -> Result<(), String> { + let Anchor::DiffLine { path, range, side } = &comment.anchor else { + return Err("comment is not anchored to a diff line".to_string()); + }; + let (start, end) = *range; + let target = path.display().to_string(); + let side = *side; + + let mut old_path: Option<String> = None; + let mut new_path: Option<String> = None; + + for line in diff.lines() { + if let Some(value) = line.strip_prefix("--- ") { + old_path = parse_file_header_path(value); + continue; + } + if let Some(value) = line.strip_prefix("+++ ") { + new_path = parse_file_header_path(value); + continue; + } + let Some(hunk) = parse_hunk_header(line) else { + continue; + }; + + let path_matches = match side { + DiffSide::New => new_path.as_deref() == Some(target.as_str()), + DiffSide::Old => old_path.as_deref() == Some(target.as_str()), + }; + if !path_matches { + continue; + } + + let (lo, len) = match side { + DiffSide::New => (hunk.new_start, hunk.new_len), + DiffSide::Old => (hunk.old_start, hunk.old_len), + }; + // Covered lines are [lo, lo + len); the inclusive range [start, end] fits + // when start >= lo and end < lo + len. + if len > 0 && start >= lo && end < lo + len { + return Ok(()); + } + } + + Err(format!( + "line {end} on the {} side of {target} is not part of the diff", + match side { + DiffSide::New => "new", + DiffSide::Old => "old", + } + )) +} + +/// Assemble the review payload posted to `POST /pulls/<n>/reviews`. +fn build_review_payload( + event: ReviewEvent, + body: &str, + comments: &[serde_json::Value], +) -> serde_json::Value { + let event_str = match event { + ReviewEvent::Approve => "APPROVE", + ReviewEvent::RequestChanges => "REQUEST_CHANGES", + ReviewEvent::Comment => "COMMENT", + }; + serde_json::json!({ + "body": body, + "event": event_str, + "comments": comments, + }) +} + +/// Submit a review with inline comments to a GitHub PR via the reviews API. +/// +/// Only [`CommentOrigin::Local`] comments are submitted (the same rule as +/// [`mirror_comments`]): comments mirrored from GitHub are skipped to avoid +/// duplicates. Each local comment is pre-validated against `diff` with +/// [`validate_comment_in_diff`]; invalid comments are recorded in +/// [`SubmitReviewResult::skipped`] rather than failing the whole review, because +/// GitHub returns a 422 for the entire request if any line is out of range. +/// +/// The assembled payload is POSTed with +/// `gh api --method POST repos/<slug>/pulls/<n>/reviews --input -`, streaming the +/// JSON to the child's stdin. A non-zero `gh` exit captures stderr into +/// [`Error::GhCommand`]. +/// +/// This is a guarded side effect (Invariant 5): it is never called automatically +/// or from agent output. +pub async fn submit_review( + repo_slug: &str, + pr_number: u64, + event: ReviewEvent, + comments: &[Comment], + body: &str, + diff: &str, +) -> Result<SubmitReviewResult, Error> { + let mut payloads: Vec<serde_json::Value> = Vec::new(); + let mut skipped: Vec<(CommentId, String)> = Vec::new(); + + for comment in comments { + // Only locally-authored comments are submitted; GitHub-mirrored ones + // would duplicate existing threads. + if comment.origin != CommentOrigin::Local { + continue; + } + match validate_comment_in_diff(comment, diff) { + Ok(()) => match review_comment_payload(comment) { + Some(payload) => payloads.push(payload), + None => skipped.push(( + comment.id.clone(), + "comment is not anchored to a diff line".to_string(), + )), + }, + Err(reason) => skipped.push((comment.id.clone(), reason)), + } + } + + let submitted = payloads.len(); + let payload = build_review_payload(event, body, &payloads); + let body_bytes = serde_json::to_vec(&payload).map_err(|e| Error::ParseOutput(e.to_string()))?; + + let endpoint = format!("repos/{repo_slug}/pulls/{pr_number}/reviews"); + let mut child = Command::new("gh") + .args(["api", "--method", "POST", &endpoint, "--input", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + // Stream the JSON body to stdin, then drop the handle to signal EOF so `gh` + // proceeds. Scoped so the borrow ends before `wait_with_output`. + { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| Error::GhCommand("failed to open stdin for gh api".to_string()))?; + stdin.write_all(&body_bytes).await?; + } + + let output = child.wait_with_output().await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::GhCommand(stderr.into_owned())); + } + + Ok(SubmitReviewResult { submitted, skipped }) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -868,6 +1358,45 @@ mod tests { use std::path::PathBuf; use super::*; + use crate::model::{DiffData, DiffSide, GateState, Review, ReviewId, ReviewSource}; + + // -- author_login (self-exclusion) tests -- + + /// Build a minimal search result authored by `login`. + fn search_result_by(login: &str) -> SearchPrResult { + SearchPrResult { + number: 1, + title: String::new(), + state: "open".into(), + url: "https://example/pr/1".into(), + repository: SearchRepo { + name_with_owner: "owner/repo".into(), + }, + author: SearchAuthor { + login: login.into(), + }, + } + } + + #[test] + fn author_login_lowercases_for_comparison() { + let sr = search_result_by("Alejandro"); + assert_eq!( + sr.author_login(), + "alejandro", + "author_login must lowercase so self-exclusion is case-insensitive" + ); + } + + #[test] + fn author_login_matches_differently_cased_identity() { + // Mirrors the self-exclusion comparison in `list_prs_filtered`: the + // authenticated identity is lowercased too, so a case mismatch between + // the search result and `gh_whoami` must still count as "self". + let sr = search_result_by("AlejandroPerez"); + let me = "alejandroperez".to_string(); + assert_eq!(sr.author_login(), me, "case-insensitive self-match"); + } // -- parse_issue_from_branch tests -- @@ -1016,6 +1545,7 @@ mod tests { head_ref_name: "alejandro/NEX-100-thing".into(), base_ref_name: "main".into(), title: "Thing".into(), + body: String::new(), state: "OPEN".into(), url: "https://github.com/o/r/pull/1".into(), repo_slug: String::new(), @@ -1025,6 +1555,7 @@ mod tests { head_ref_name: "feature/no-id-here".into(), base_ref_name: "main".into(), title: "No ID".into(), + body: String::new(), state: "OPEN".into(), url: "https://github.com/o/r/pull/2".into(), repo_slug: String::new(), @@ -1034,6 +1565,7 @@ mod tests { head_ref_name: "alejandro/ab-7-short".into(), base_ref_name: "develop".into(), title: "Short".into(), + body: String::new(), state: "MERGED".into(), url: "https://github.com/o/r/pull/3".into(), repo_slug: String::new(), @@ -1093,6 +1625,7 @@ mod tests { anchor: Anchor::DiffLine { path: PathBuf::from("src/main.rs"), range: (42, 42), + side: DiffSide::New, }, body: "This variable is unused.".into(), origin: CommentOrigin::Local, @@ -1113,6 +1646,7 @@ mod tests { anchor: Anchor::DiffLine { path: PathBuf::from("lib/util.rs"), range: (10, 20), + side: DiffSide::New, }, body: "Refactor this block.".into(), origin: CommentOrigin::Local, @@ -1172,6 +1706,7 @@ mod tests { anchor: Anchor::DiffLine { path: PathBuf::from("a.rs"), range: (1, 1), + side: DiffSide::New, }, body: "fix this".into(), origin: CommentOrigin::Local, @@ -1181,6 +1716,7 @@ mod tests { anchor: Anchor::DiffLine { path: PathBuf::from("b.rs"), range: (5, 10), + side: DiffSide::New, }, body: "from github".into(), origin: CommentOrigin::GitHubMirror, @@ -1190,6 +1726,7 @@ mod tests { anchor: Anchor::DiffLine { path: PathBuf::from("c.rs"), range: (3, 3), + side: DiffSide::New, }, body: "also fix this".into(), origin: CommentOrigin::Local, @@ -1308,4 +1845,248 @@ mod tests { assert_eq!(parsed.failed[0].0, CommentId::new("c-5")); assert_eq!(parsed.failed[0].1, "timeout"); } + + // -- review_comment_payload -- + + /// Build a diff-line comment for payload/validation tests. + fn diff_comment(id: &str, path: &str, range: (u32, u32), side: DiffSide) -> Comment { + Comment { + id: CommentId::new(id), + anchor: Anchor::DiffLine { + path: PathBuf::from(path), + range, + side, + }, + body: "please fix".into(), + origin: CommentOrigin::Local, + } + } + + #[test] + fn payload_single_line_new_is_right() { + let comment = diff_comment("c-1", "src/main.rs", (42, 42), DiffSide::New); + let payload = review_comment_payload(&comment).expect("diff-line yields a payload"); + + assert_eq!(payload["path"], "src/main.rs"); + assert_eq!(payload["line"], 42); + assert_eq!(payload["side"], "RIGHT"); + assert_eq!(payload["body"], "please fix"); + // Single-line comment carries no multi-line start fields. + assert!(payload.get("start_line").is_none()); + assert!(payload.get("start_side").is_none()); + } + + #[test] + fn payload_range_carries_start_fields() { + let comment = diff_comment("c-2", "lib/util.rs", (10, 20), DiffSide::New); + let payload = review_comment_payload(&comment).expect("diff-line yields a payload"); + + assert_eq!(payload["line"], 20); + assert_eq!(payload["side"], "RIGHT"); + assert_eq!(payload["start_line"], 10); + assert_eq!(payload["start_side"], "RIGHT"); + } + + #[test] + fn payload_old_side_is_left() { + let comment = diff_comment("c-3", "src/main.rs", (7, 7), DiffSide::Old); + let payload = review_comment_payload(&comment).expect("diff-line yields a payload"); + + assert_eq!(payload["side"], "LEFT"); + assert_eq!(payload["line"], 7); + } + + #[test] + fn payload_plan_step_is_none() { + let comment = Comment { + id: CommentId::new("c-4"), + anchor: Anchor::PlanStep(1), + body: "vague".into(), + origin: CommentOrigin::Local, + }; + assert!( + review_comment_payload(&comment).is_none(), + "non-diff-line anchors have no inline payload" + ); + } + + // -- validate_comment_in_diff -- + + /// A diff touching `src/main.rs`: old lines [10,13), new lines [10,14). + const SAMPLE_DIFF: &str = "\ +diff --git a/src/main.rs b/src/main.rs +index 1111111..2222222 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -10,3 +10,4 @@ fn main() { + let a = 1; +-let b = 2; ++let b = 3; ++let c = 4; + let d = 5; +"; + + #[test] + fn validate_new_line_inside_hunk_ok() { + let comment = diff_comment("v-1", "src/main.rs", (12, 12), DiffSide::New); + assert!(validate_comment_in_diff(&comment, SAMPLE_DIFF).is_ok()); + } + + #[test] + fn validate_new_line_outside_hunk_err() { + let comment = diff_comment("v-2", "src/main.rs", (50, 50), DiffSide::New); + let err = validate_comment_in_diff(&comment, SAMPLE_DIFF) + .expect_err("line 50 is not part of the diff"); + assert!(err.contains("50"), "reason names the offending line: {err}"); + } + + #[test] + fn validate_old_line_in_deletion_hunk_ok() { + // Old line 11 is the deleted `let b = 2;`, within old range [10,13). + let comment = diff_comment("v-3", "src/main.rs", (11, 11), DiffSide::Old); + assert!(validate_comment_in_diff(&comment, SAMPLE_DIFF).is_ok()); + } + + #[test] + fn validate_wrong_path_err() { + let comment = diff_comment("v-4", "other/file.rs", (11, 11), DiffSide::New); + assert!( + validate_comment_in_diff(&comment, SAMPLE_DIFF).is_err(), + "a path not present in the diff must be rejected" + ); + } + + #[test] + fn validate_non_diff_line_err() { + let comment = Comment { + id: CommentId::new("v-5"), + anchor: Anchor::PlanFile(PathBuf::from("src/lib.rs")), + body: "x".into(), + origin: CommentOrigin::Local, + }; + assert!(validate_comment_in_diff(&comment, SAMPLE_DIFF).is_err()); + } + + // -- build_review_payload -- + + #[test] + fn review_payload_event_strings() { + let cases = [ + (ReviewEvent::Approve, "APPROVE"), + (ReviewEvent::RequestChanges, "REQUEST_CHANGES"), + (ReviewEvent::Comment, "COMMENT"), + ]; + for (event, expected) in cases { + let payload = build_review_payload(event, "looks good", &[]); + assert_eq!(payload["event"], expected); + assert_eq!(payload["body"], "looks good"); + assert_eq!(payload["comments"], serde_json::json!([])); + } + } + + // -- wire_stack_edges -- + + /// Build a minimal [`Review`] for stack-edge tests. + fn stack_review(id: &str, branch: &str, base: &str, source: ReviewSource) -> Review { + Review { + id: ReviewId::new(id), + issue: IssueRef::new(format!("ISSUE-{id}")), + pr: PrRef::new(format!("owner/repo#{id}")), + title: String::new(), + body: String::new(), + branch: branch.into(), + base: base.into(), + base_sha: "000".into(), + source, + worktree: PathBuf::from(format!("/tmp/wt-{id}")), + gate_state: GateState::Pending, + diff: DiffData { raw: String::new() }, + head_sha: "aaa".into(), + comments: vec![], + parents: vec![], + children: vec![], + stale: false, + agent: None, + repo_slug: None, + project: None, + dispatch_snapshot: None, + } + } + + #[test] + fn wire_stack_edges_linear_chain() { + let mut reviews = vec![ + stack_review("a", "fa", "main", ReviewSource::Authored), + stack_review("b", "fb", "fa", ReviewSource::Authored), + stack_review("c", "fc", "fb", ReviewSource::Authored), + ]; + wire_stack_edges(&mut reviews); + + assert!(reviews[0].parents.is_empty()); + assert_eq!(reviews[0].children, vec![ReviewId::new("b")]); + assert_eq!(reviews[1].parents, vec![ReviewId::new("a")]); + assert_eq!(reviews[1].children, vec![ReviewId::new("c")]); + assert_eq!(reviews[2].parents, vec![ReviewId::new("b")]); + assert!(reviews[2].children.is_empty()); + } + + #[test] + fn wire_stack_edges_diamond_fan_out() { + // A is the root; B and C both stack on A; D stacks on B. + let mut reviews = vec![ + stack_review("a", "fa", "main", ReviewSource::Authored), + stack_review("b", "fb", "fa", ReviewSource::Authored), + stack_review("c", "fc", "fa", ReviewSource::ReviewRequested), + stack_review("d", "fd", "fb", ReviewSource::Authored), + ]; + wire_stack_edges(&mut reviews); + + assert!(reviews[0].parents.is_empty()); + assert_eq!( + reviews[0].children, + vec![ReviewId::new("b"), ReviewId::new("c")] + ); + assert_eq!(reviews[1].parents, vec![ReviewId::new("a")]); + assert_eq!(reviews[1].children, vec![ReviewId::new("d")]); + assert_eq!(reviews[2].parents, vec![ReviewId::new("a")]); + assert!(reviews[2].children.is_empty()); + assert_eq!(reviews[3].parents, vec![ReviewId::new("b")]); + assert!(reviews[3].children.is_empty()); + } + + #[test] + fn wire_stack_edges_orphan_on_main() { + let mut reviews = vec![stack_review( + "solo", + "fsolo", + "main", + ReviewSource::Authored, + )]; + wire_stack_edges(&mut reviews); + assert!(reviews[0].parents.is_empty()); + assert!(reviews[0].children.is_empty()); + } + + #[test] + fn wire_stack_edges_preserves_frontier() { + // A Frontier review carries kickoff-wired edges; even though its base + // matches another review's branch, those edges must be left untouched. + let mut frontier = stack_review("f", "ff", "fbase", ReviewSource::Frontier); + frontier.parents = vec![ReviewId::new("pre-parent")]; + frontier.children = vec![ReviewId::new("pre-child")]; + + let mut reviews = vec![ + frontier, + stack_review("p", "fbase", "main", ReviewSource::Authored), + ]; + wire_stack_edges(&mut reviews); + + // Frontier edges untouched. + assert_eq!(reviews[0].parents, vec![ReviewId::new("pre-parent")]); + assert_eq!(reviews[0].children, vec![ReviewId::new("pre-child")]); + // The authored review's edges were rebuilt: base=main, no parent; and the + // frontier review was NOT added as its child. + assert!(reviews[1].parents.is_empty()); + assert!(reviews[1].children.is_empty()); + } } diff --git a/crates/cockpit-core/src/gate.rs b/crates/cockpit-core/src/gate.rs index 8e5ab87..3f0ef49 100644 --- a/crates/cockpit-core/src/gate.rs +++ b/crates/cockpit-core/src/gate.rs @@ -6,8 +6,7 @@ //! `SPEC.md` §7's transition table. Implementors supply only the accessor //! plumbing and the effectful `dispatch`/`reconcile`. -use crate::model::{AgentRun, Comment, GateState, ProjectPlan, Review}; -use crate::workflow::TransitionEvent; +use crate::model::{AgentRun, Comment, DispatchSnapshot, GateState, ProjectPlan, Review}; /// Errors from gate state transitions. #[derive(Debug, thiserror::Error)] @@ -30,13 +29,16 @@ pub enum Error { NotImplemented, } -/// Record a transition for workflow automation. +/// The outcome of applying an agent's completion to a [`Review`]. /// -/// Constructs a [`TransitionEvent`] from the object ID and the before/after -/// states. Callers use this after a successful transition to feed into -/// [`crate::workflow::evaluate_rules`]. -pub fn transition_event(object_id: &str, from: GateState, to: GateState) -> TransitionEvent { - crate::workflow::transition_event(object_id, from, to) +/// Distinct from a raw `bool` so callers branch on intent, not truthiness. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentOutcome { + /// The agent advanced HEAD; the review moved `Dispatched → Reworked`. + Reworked, + /// The agent produced no new commit; the review returned `Dispatched → + /// InReview` with its comments preserved for re-dispatch. + Failed, } /// The shared review loop, implemented by both [`ProjectPlan`] and [`Review`]. @@ -223,6 +225,75 @@ impl Review { pub fn clear_stale(&mut self) { self.stale = false; } + + /// `Approved → Merged`. Terminal. + /// + /// Inherent to [`Review`] rather than part of [`Gated`]: only reviews merge, + /// plans never do. Every other transition out of `Merged` is rejected by the + /// `Gated` methods, so `Merged` is a true sink. + pub fn mark_merged(&mut self) -> Result<(), Error> { + if self.gate_state != GateState::Approved { + return Err(Error::IllegalTransition { + from: self.gate_state, + event: "mark_merged", + }); + } + self.gate_state = GateState::Merged; + Ok(()) + } + + /// Apply an agent's completion, using git HEAD — not agent output — as the + /// source of truth for whether work landed. + /// + /// Agent stdout can claim success while committing nothing; the only trusted + /// signal is whether the branch HEAD actually advanced. When `new_head` is + /// `Some(h)` and differs from the current [`Review::head_sha`], the rework + /// landed a commit: adopt the new HEAD, clear the agent handle, and move + /// `Dispatched → Reworked` (which clears comments). Otherwise the agent made + /// no progress: clear the agent handle and move `Dispatched → InReview`, + /// preserving comments for re-dispatch. + pub fn apply_agent_completion( + &mut self, + new_head: Option<String>, + ) -> Result<AgentOutcome, Error> { + // Validate the transition is legal before mutating any state: an illegal + // call (state != Dispatched) must leave head_sha and the agent handle + // untouched, not partially applied. Both downstream transitions + // (`mark_reworked` / `mark_agent_failed`) also require `Dispatched`, so + // this is the single precondition for either branch. + if self.gate_state != GateState::Dispatched { + return Err(Error::IllegalTransition { + from: self.gate_state, + event: "apply_agent_completion", + }); + } + match new_head { + Some(h) if h != self.head_sha => { + self.head_sha = h; + self.agent = None; + self.mark_reworked()?; + Ok(AgentOutcome::Reworked) + } + _ => { + self.agent = None; + self.mark_agent_failed()?; + Ok(AgentOutcome::Failed) + } + } + } + + /// Capture a [`DispatchSnapshot`] of the current HEAD and comments, + /// overwriting any previous snapshot. + /// + /// Records what the reviewer asked for at dispatch time for the current cycle + /// only. It is not a durable comment store (Invariant §0.4): comments stay + /// ephemeral and are cleared on `Reworked`. + pub fn snapshot_dispatch(&mut self) { + self.dispatch_snapshot = Some(DispatchSnapshot { + reviewed_sha: self.head_sha.clone(), + comments: self.comments.clone(), + }); + } } // --------------------------------------------------------------------------- @@ -235,9 +306,10 @@ mod tests { use super::*; use crate::model::{ - Anchor, CommentId, CommentOrigin, DiffData, IssueRef, PlanDoc, PrRef, ProjectRef, ReviewId, - ReviewSource, + AgentMode, AgentRun, Anchor, CommentId, CommentOrigin, DiffData, DiffSide, IssueRef, + PlanDoc, PrRef, ProjectRef, ReviewId, ReviewSource, }; + use std::time::SystemTime; /// Build a minimal `Review` starting at the given `GateState`. fn review_in(state: GateState) -> Review { @@ -245,6 +317,8 @@ mod tests { id: ReviewId::new("r-1"), issue: IssueRef::new("ISSUE-1"), pr: PrRef::new("owner/repo#1"), + title: String::new(), + body: String::new(), branch: "alejandro/test".into(), base: "main".into(), base_sha: "000".into(), @@ -260,6 +334,7 @@ mod tests { agent: None, repo_slug: None, project: None, + dispatch_snapshot: None, } } @@ -288,6 +363,7 @@ mod tests { anchor: Anchor::DiffLine { path: PathBuf::from("src/main.rs"), range: (1, 5), + side: DiffSide::New, }, body: "fix this".into(), origin: CommentOrigin::Local, @@ -700,4 +776,172 @@ mod tests { in_review.approve().unwrap(); assert_eq!(in_review.gate_state(), GateState::Approved); } + + // --------------------------------------------------------------- + // Merged (terminal) — Review-only + // --------------------------------------------------------------- + + /// Build a minimal running agent handle for tests that need one attached. + fn dummy_agent() -> AgentRun { + AgentRun { + pid: 1234, + mode: AgentMode::Fix, + started_at: SystemTime::UNIX_EPOCH, + prompt_hash: "hash".into(), + log_path: PathBuf::from("/tmp/agent.log"), + } + } + + #[test] + fn approved_to_merged() { + let mut r = review_in(GateState::Approved); + r.mark_merged().unwrap(); + assert_eq!(r.gate_state(), GateState::Merged); + } + + #[test] + fn mark_merged_only_from_approved() { + for state in [ + GateState::Pending, + GateState::InReview, + GateState::Dispatched, + GateState::Reworked, + GateState::Merged, + ] { + let mut r = review_in(state); + assert!( + r.mark_merged().is_err(), + "mark_merged from {state:?} must be illegal" + ); + } + } + + #[test] + fn merged_is_terminal() { + // Every other transition out of Merged is rejected — it is a true sink. + let mut r = review_in(GateState::Merged); + assert!(r.open().is_err(), "open from Merged"); + + let mut r = review_in(GateState::Merged); + add_comment(&mut r); + assert!(r.request_changes().is_err(), "request_changes from Merged"); + + let mut r = review_in(GateState::Merged); + assert!(r.approve().is_err(), "approve from Merged"); + + let mut r = review_in(GateState::Merged); + assert!(r.mark_reworked().is_err(), "mark_reworked from Merged"); + + let mut r = review_in(GateState::Merged); + assert!( + r.mark_agent_failed().is_err(), + "mark_agent_failed from Merged" + ); + + let mut r = review_in(GateState::Merged); + assert!(r.mark_merged().is_err(), "mark_merged from Merged"); + } + + // --------------------------------------------------------------- + // apply_agent_completion — HEAD is authoritative + // --------------------------------------------------------------- + + #[test] + fn apply_agent_completion_advanced_head_reworks() { + let mut r = review_in(GateState::Dispatched); + add_comment(&mut r); + r.agent = Some(dummy_agent()); + + let outcome = r + .apply_agent_completion(Some("newsha".into())) + .expect("advancing HEAD should succeed"); + + assert_eq!(outcome, AgentOutcome::Reworked); + assert_eq!(r.gate_state(), GateState::Reworked); + assert_eq!(r.head_sha, "newsha", "HEAD updated to the new commit"); + assert!(r.agent.is_none(), "agent handle cleared"); + assert!(r.comments().is_empty(), "comments cleared on rework"); + } + + #[test] + fn apply_agent_completion_same_head_fails() { + let mut r = review_in(GateState::Dispatched); + add_comment(&mut r); + r.agent = Some(dummy_agent()); + let head_before = r.head_sha.clone(); + + let outcome = r + .apply_agent_completion(Some(head_before.clone())) + .expect("unchanged HEAD should still return Ok(Failed)"); + + assert_eq!(outcome, AgentOutcome::Failed); + assert_eq!(r.gate_state(), GateState::InReview); + assert_eq!(r.head_sha, head_before, "HEAD unchanged"); + assert!(r.agent.is_none(), "agent handle cleared"); + assert_eq!(r.comments().len(), 1, "comments preserved on failure"); + } + + #[test] + fn apply_agent_completion_none_head_fails() { + let mut r = review_in(GateState::Dispatched); + add_comment(&mut r); + r.agent = Some(dummy_agent()); + let head_before = r.head_sha.clone(); + + let outcome = r + .apply_agent_completion(None) + .expect("missing HEAD should return Ok(Failed)"); + + assert_eq!(outcome, AgentOutcome::Failed); + assert_eq!(r.gate_state(), GateState::InReview); + assert_eq!(r.head_sha, head_before, "HEAD unchanged"); + assert!(r.agent.is_none(), "agent handle cleared"); + assert_eq!(r.comments().len(), 1, "comments preserved on failure"); + } + + // --------------------------------------------------------------- + // snapshot_dispatch + // --------------------------------------------------------------- + + #[test] + fn snapshot_dispatch_captures_sha_and_comments() { + let mut r = review_in(GateState::InReview); + add_comment(&mut r); + + r.snapshot_dispatch(); + + let snap = r + .dispatch_snapshot + .as_ref() + .expect("snapshot should be set"); + assert_eq!(snap.reviewed_sha, r.head_sha); + assert_eq!(snap.comments.len(), 1); + assert_eq!(snap.comments, r.comments); + } + + #[test] + fn snapshot_dispatch_overwrites_previous() { + let mut r = review_in(GateState::InReview); + add_comment(&mut r); + r.snapshot_dispatch(); + assert_eq!( + r.dispatch_snapshot.as_ref().map(|s| s.comments.len()), + Some(1) + ); + + // A second cycle with a different HEAD and no comments overwrites it. + r.head_sha = "later-sha".into(); + r.comments_mut().clear(); + r.snapshot_dispatch(); + + let snap = r + .dispatch_snapshot + .as_ref() + .expect("snapshot should still be set"); + assert_eq!(snap.reviewed_sha, "later-sha"); + assert!( + snap.comments.is_empty(), + "snapshot reflects the latest dispatch, not the previous one" + ); + } } diff --git a/crates/cockpit-core/src/kickoff.rs b/crates/cockpit-core/src/kickoff.rs index 9ca9766..e5b578d 100644 --- a/crates/cockpit-core/src/kickoff.rs +++ b/crates/cockpit-core/src/kickoff.rs @@ -17,7 +17,7 @@ use crate::adapters::{agent, git, linear}; use crate::config; use crate::dag; use crate::model::{ - DiffData, GateState, IssueRef, PrRef, Project, ProjectId, ProjectPlan, ProjectRef, + DiffData, GateState, IssueRef, PlanDoc, PrRef, Project, ProjectId, ProjectPlan, ProjectRef, ProjectSource, Review, ReviewId, ReviewSource, }; use crate::plan_parser; @@ -180,6 +180,8 @@ fn build_review( id: ReviewId::new(format!("r-{}", issue.as_str())), issue: issue.clone(), pr: PrRef::new(format!("pending-{}", issue.as_str())), + title: String::new(), + body: String::new(), branch, base, base_sha: String::new(), @@ -195,6 +197,7 @@ fn build_review( agent: None, repo_slug: None, project: project.cloned(), + dispatch_snapshot: None, } } @@ -341,7 +344,7 @@ pub fn prepare_batch_worktrees( for (index, review) in reviews.iter_mut().enumerate() { let base_oid = git::ensure_worktree(repo, &review.worktree, &review.branch, &review.base)?; review.base_sha = base_oid.to_string(); - let prompt = assemble_implement_prompt(review, project, custom_preamble); + let prompt = assemble_implement_prompt(review, project, None, custom_preamble); prepared.push(PreparedReview { index, prompt }); } Ok(prepared) @@ -398,11 +401,18 @@ pub async fn spawn_batch( /// Assemble a minimal implementation prompt for an issue. /// -/// The implementer uses the issue reference and project context to build -/// the initial PR from scratch. -fn assemble_implement_prompt( +/// The implementer uses the issue reference and project context to build the +/// initial PR from scratch. When `approved_plan` is `Some`, the plan's raw +/// markdown is threaded into the prompt as the contract the implementer must +/// follow (`SPEC.md` §9); `None` omits the plan section (byte-identical to the +/// no-plan prompt). +/// +/// Exposed so the plan-approval fan-out can build the exact same implementer +/// prompt without duplicating the intent text, while carrying the approved plan. +pub fn assemble_implement_prompt( review: &Review, project: &ProjectRef, + approved_plan: Option<&PlanDoc>, custom_preamble: Option<&str>, ) -> prompt::AssembledPrompt { let intent = format!( @@ -418,7 +428,7 @@ fn assemble_implement_prompt( let input = ReworkInput { intent: &intent, custom_preamble, - approved_plan: None, + approved_plan, artifact: &crate::model::Artifact::Diff(review.diff.clone()), comments: &[], ci_failures: None, @@ -751,6 +761,8 @@ mod tests { id: ReviewId::new("r-1"), issue: IssueRef::new("ISS-1"), pr: PrRef::new("p-1"), + title: String::new(), + body: String::new(), branch: "b-1".into(), base: "main".into(), base_sha: String::new(), @@ -766,6 +778,7 @@ mod tests { agent: None, repo_slug: None, project: None, + dispatch_snapshot: None, }]; wire_children(&mut reviews); @@ -778,6 +791,8 @@ mod tests { id: ReviewId::new("r-NEX-1"), issue: IssueRef::new("NEX-1"), pr: PrRef::new("pending-NEX-1"), + title: String::new(), + body: String::new(), branch: "alejandro/nex-1-thing".into(), base: "main".into(), base_sha: String::new(), @@ -793,9 +808,10 @@ mod tests { agent: None, repo_slug: None, project: None, + dispatch_snapshot: None, }; - let prompt = assemble_implement_prompt(&review, &ProjectRef::new("proj-1"), None); + let prompt = assemble_implement_prompt(&review, &ProjectRef::new("proj-1"), None, None); assert!( prompt.text.contains("NEX-1"), "prompt should mention the issue" diff --git a/crates/cockpit-core/src/lib.rs b/crates/cockpit-core/src/lib.rs index 5ec87f9..0156c36 100644 --- a/crates/cockpit-core/src/lib.rs +++ b/crates/cockpit-core/src/lib.rs @@ -17,12 +17,12 @@ pub mod adapters; pub mod dag; pub mod gate; pub mod kickoff; +pub mod persist; pub mod plan_parser; pub mod prompt; pub mod restack; pub mod skills; pub mod store; -pub mod workflow; pub mod hook_server; diff --git a/crates/cockpit-core/src/model.rs b/crates/cockpit-core/src/model.rs index 2062030..b102f5c 100644 --- a/crates/cockpit-core/src/model.rs +++ b/crates/cockpit-core/src/model.rs @@ -96,6 +96,8 @@ pub enum GateState { Reworked, /// Human approved — terminal for the loop. Approved, + /// PR merged — terminal. Only reviews reach this; plans never merge. + Merged, } /// Which mode the spawned agent runs in. @@ -150,6 +152,21 @@ pub enum ProjectSource { AdHoc, } +/// Which side of a diff an [`Anchor::DiffLine`] range refers to. +/// +/// A comment can anchor to the old (pre-change) side or the new (post-change) +/// side of the diff. Defaults to [`DiffSide::New`], matching the common case of +/// commenting on added or changed lines. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../app/src/bindings/")] +pub enum DiffSide { + /// The old (pre-change) side of the diff. + Old, + /// The new (post-change) side of the diff. + #[default] + New, +} + /// A location inside the current artifact that a [`Comment`] points to. /// /// Anchors are ephemeral — they reference the *current* artifact version only @@ -167,6 +184,10 @@ pub enum Anchor { path: PathBuf, /// Inclusive start and end line in the current head. range: (u32, u32), + /// Which side of the diff the range refers to. Defaults to + /// [`DiffSide::New`] for legacy data that predates this field. + #[serde(default)] + side: DiffSide, }, } @@ -245,6 +266,22 @@ pub struct Comment { pub origin: CommentOrigin, } +/// A read-only, single-cycle audit record of what the reviewer asked for at the +/// moment comments were dispatched to an agent. +/// +/// Captured by [`crate::model::Review::snapshot_dispatch`] and overwritten on +/// the next dispatch. This is **not** a durable comment store: comments remain +/// ephemeral and are cleared on `Reworked` (Invariant §0.4). The snapshot exists +/// only so the UI can show "what was asked for" during the in-flight cycle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../app/src/bindings/")] +pub struct DispatchSnapshot { + /// The review's HEAD SHA at the moment of dispatch. + pub reviewed_sha: String, + /// The comments dispatched to the agent this cycle. + pub comments: Vec<Comment>, +} + /// A running or completed agent process. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export, export_to = "../../../app/src/bindings/")] @@ -321,6 +358,12 @@ pub struct Review { pub issue: IssueRef, /// The GitHub PR opened for this review. pub pr: PrRef, + /// PR title. Empty for legacy data that predates this field. + #[serde(default)] + pub title: String, + /// PR description / body. Empty for legacy data that predates this field. + #[serde(default)] + pub body: String, /// Git branch name (e.g. `alejandro/nex-123-do-thing`). pub branch: String, /// Base branch — either `main` or a parent review's branch (stacked). @@ -357,6 +400,13 @@ pub struct Review { /// The first-class [`Project`] this review belongs to, if any. `None` for /// ungrouped reviews (e.g. GitHub-imported PRs with no project attached). pub project: Option<ProjectId>, + /// Read-only audit record of the most recent dispatch cycle, if any. + /// + /// Overwritten on each dispatch (see [`DispatchSnapshot`]). `None` before + /// the first dispatch and for legacy data. + #[serde(default)] + #[ts(optional)] + pub dispatch_snapshot: Option<DispatchSnapshot>, } // --------------------------------------------------------------------------- @@ -373,6 +423,8 @@ mod tests { id: ReviewId::new(id), issue: IssueRef::new(format!("ISSUE-{id}")), pr: PrRef::new(format!("owner/repo#{id}")), + title: String::new(), + body: String::new(), branch: format!("alejandro/{id}"), base: "main".into(), base_sha: "000".into(), @@ -388,6 +440,7 @@ mod tests { agent: None, repo_slug: None, project: None, + dispatch_snapshot: None, } } @@ -442,6 +495,7 @@ mod tests { anchor: Anchor::DiffLine { path: PathBuf::from("src/main.rs"), range: (10, 15), + side: DiffSide::New, }, body: "fix this".into(), origin: CommentOrigin::Local, @@ -503,4 +557,54 @@ mod tests { Artifact::Plan(_) => panic!("expected Diff"), } } + + #[test] + fn review_deserializes_without_new_intent_fields() { + // Legacy payloads predate `title`, `body`, and `dispatch_snapshot`; they + // must deserialize via `#[serde(default)]` rather than erroring. + let json = r#"{ + "id": "r-1", + "issue": "ISSUE-1", + "pr": "owner/repo#1", + "branch": "alejandro/legacy", + "base": "main", + "base_sha": "000", + "source": "Frontier", + "worktree": "/tmp/wt", + "gate_state": "Pending", + "diff": { "raw": "" }, + "head_sha": "abc", + "comments": [], + "parents": [], + "children": [], + "stale": false, + "agent": null, + "repo_slug": null, + "project": null + }"#; + + let review: Review = serde_json::from_str(json).expect("legacy Review should deserialize"); + assert_eq!(review.title, ""); + assert_eq!(review.body, ""); + assert_eq!(review.dispatch_snapshot, None); + } + + #[test] + fn diff_line_anchor_deserializes_without_side() { + // Anchors persisted before the `side` field default to `New`. + let json = r#"{ "DiffLine": { "path": "src/main.rs", "range": [10, 15] } }"#; + let anchor: Anchor = serde_json::from_str(json).expect("legacy Anchor should deserialize"); + match anchor { + Anchor::DiffLine { side, range, .. } => { + assert_eq!(side, DiffSide::New); + assert_eq!(range, (10, 15)); + } + other => panic!("expected DiffLine, got {other:?}"), + } + } + + #[test] + fn diff_side_defaults_to_new() { + assert_eq!(DiffSide::default(), DiffSide::New); + } } diff --git a/crates/cockpit-core/src/persist.rs b/crates/cockpit-core/src/persist.rs new file mode 100644 index 0000000..ea943d3 --- /dev/null +++ b/crates/cockpit-core/src/persist.rs @@ -0,0 +1,241 @@ +//! On-disk persistence of cockpit session state (D5). +//! +//! The whole session — active [`Review`]s and first-class [`Project`]s — is +//! serialized to a single JSON document at `<cockpit_home>/state.json` (see +//! [`crate::config::cockpit_home`]). Writes are atomic: state is written to +//! `state.json.tmp` first and then renamed over `state.json`, so a crash +//! mid-write can never leave a torn file. +//! +//! Loading never panics (Invariant §0.1). A missing file yields `None`, and a +//! corrupt or version-mismatched file is moved aside to `state.json.corrupt` +//! (best-effort) before `None` is returned — the app starts clean while the bad +//! document is preserved on disk for manual inspection. There is deliberately +//! no logging call here: cockpit-core pulls in no logging crate, and the +//! renamed `state.json.corrupt` is itself the durable record of the failure. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::model::{Project, Review}; + +/// Current schema version of the persisted state document. +/// +/// Bumped whenever [`PersistedState`]'s layout changes incompatibly. A loaded +/// file whose `version` differs is treated as unreadable (see [`load`]). +pub const STATE_VERSION: u32 = 1; + +/// File name of the persisted state document under the cockpit home directory. +const STATE_FILE: &str = "state.json"; +/// Temp file name used for the atomic write-then-rename. +const STATE_TMP_FILE: &str = "state.json.tmp"; +/// File name the bad document is moved to when it cannot be loaded. +const STATE_CORRUPT_FILE: &str = "state.json.corrupt"; + +/// The full serializable snapshot of a cockpit session. +/// +/// Persisted as pretty JSON so it stays human-inspectable on disk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersistedState { + /// Schema version; see [`STATE_VERSION`]. + pub version: u32, + /// All active reviews at save time. + pub reviews: Vec<Review>, + /// All first-class projects at save time. + pub projects: Vec<Project>, +} + +/// Errors from saving cockpit state to disk. +/// +/// Loading deliberately has no error type: [`load`] never fails loudly +/// (Invariant §0.1) and returns `None` for every non-success outcome. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Writing the state files to disk failed. + #[error("state I/O error: {0}")] + Io(#[from] std::io::Error), + /// Serializing the state to JSON failed. + #[error("state serialize error: {0}")] + Serialize(#[from] serde_json::Error), +} + +/// Atomically save `state` to `<home>/state.json`. +/// +/// Creates `home` if it does not exist, serializes `state` to pretty JSON, +/// writes it to `<home>/state.json.tmp`, then renames that over +/// `<home>/state.json`. The rename is atomic within a single directory on +/// every supported platform, so a reader never observes a partially-written +/// document. +pub fn save_atomic(home: &Path, state: &PersistedState) -> Result<(), Error> { + std::fs::create_dir_all(home)?; + + let json = serde_json::to_string_pretty(state)?; + + let tmp_path = home.join(STATE_TMP_FILE); + let final_path = home.join(STATE_FILE); + + std::fs::write(&tmp_path, json)?; + std::fs::rename(&tmp_path, &final_path)?; + + Ok(()) +} + +/// Load the persisted state from `<home>/state.json`, if any. +/// +/// Returns: +/// - `Some(state)` when the file exists, parses, and its `version` matches +/// [`STATE_VERSION`]; +/// - `None` when the file is missing (first launch — nothing to load); +/// - `None` after moving the file aside to `<home>/state.json.corrupt` when it +/// cannot be parsed or its `version` does not match [`STATE_VERSION`]. +/// +/// This function never panics: an unreadable file, a parse failure, or a failed +/// rename all resolve to `None` so the app can always start (Invariant §0.1). +pub fn load(home: &Path) -> Option<PersistedState> { + let path = home.join(STATE_FILE); + + // A missing (or otherwise unreadable) file is not an error: start clean. + // We do not move anything aside here — there may be nothing to move. + let content = std::fs::read_to_string(&path).ok()?; + + match serde_json::from_str::<PersistedState>(&content) { + Ok(state) if state.version == STATE_VERSION => Some(state), + _ => { + // Corrupt JSON or an incompatible schema version. Preserve the bad + // document for inspection and start clean. Best-effort: a failed + // rename must not stop the app from launching. + let corrupt_path = home.join(STATE_CORRUPT_FILE); + let _ = std::fs::rename(&path, &corrupt_path); + None + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::model::{ + DiffData, GateState, IssueRef, PrRef, Project, ProjectId, ProjectSource, Review, ReviewId, + ReviewSource, + }; + + /// Build a minimal `Review` with the given PR number. + fn make_review(pr_num: u64) -> Review { + Review { + id: ReviewId::new(format!("r-{pr_num}")), + issue: IssueRef::new(format!("ISSUE-{pr_num}")), + pr: PrRef::new(format!("owner/repo#{pr_num}")), + title: String::new(), + body: String::new(), + branch: format!("alejandro/test-{pr_num}"), + base: "main".into(), + base_sha: "000".into(), + source: ReviewSource::Frontier, + worktree: PathBuf::from(format!("/tmp/wt-{pr_num}")), + gate_state: GateState::Pending, + diff: DiffData { raw: String::new() }, + head_sha: "abc123".into(), + comments: vec![], + parents: vec![], + children: vec![], + stale: false, + agent: None, + repo_slug: None, + project: None, + dispatch_snapshot: None, + } + } + + /// Build a minimal ad-hoc `Project` with the given id. + fn make_project(id: &str) -> Project { + Project { + id: ProjectId::new(id), + name: format!("Project {id}"), + source: ProjectSource::AdHoc, + plan: None, + } + } + + fn sample_state() -> PersistedState { + PersistedState { + version: STATE_VERSION, + reviews: vec![make_review(1), make_review(2)], + projects: vec![make_project("p-1")], + } + } + + #[test] + fn save_then_load_round_trips() { + let dir = tempfile::tempdir().expect("temp dir"); + let state = sample_state(); + + save_atomic(dir.path(), &state).expect("save should succeed"); + let loaded = load(dir.path()).expect("load should return the saved state"); + + assert_eq!(loaded, state); + } + + #[test] + fn save_creates_home_directory() { + let dir = tempfile::tempdir().expect("temp dir"); + // A nested home directory that does not yet exist. + let home = dir.path().join("nested").join("cockpit"); + assert!(!home.exists()); + + save_atomic(&home, &sample_state()).expect("save should create the home dir"); + assert!(home.join("state.json").exists()); + } + + #[test] + fn load_missing_file_returns_none() { + let dir = tempfile::tempdir().expect("temp dir"); + // No state.json written. + assert!(load(dir.path()).is_none()); + // A missing file must not spawn a corrupt sidecar. + assert!(!dir.path().join("state.json.corrupt").exists()); + } + + #[test] + fn load_corrupt_json_returns_none_and_moves_file_aside() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("state.json"); + std::fs::write(&path, "{ this is not valid json").expect("write corrupt file"); + + assert!(load(dir.path()).is_none()); + + // The bad file is preserved for inspection and removed from its slot. + assert!(!path.exists(), "corrupt state.json should be moved aside"); + assert!( + dir.path().join("state.json.corrupt").exists(), + "corrupt copy should exist" + ); + } + + #[test] + fn load_version_mismatch_returns_none_and_moves_file_aside() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("state.json"); + + // Valid JSON, but a schema version this build does not understand. + let future = PersistedState { + version: STATE_VERSION + 1, + reviews: vec![], + projects: vec![], + }; + let json = serde_json::to_string_pretty(&future).expect("serialize"); + std::fs::write(&path, json).expect("write mismatched-version file"); + + assert!(load(dir.path()).is_none()); + assert!( + !path.exists(), + "mismatched state.json should be moved aside" + ); + assert!(dir.path().join("state.json.corrupt").exists()); + } +} diff --git a/crates/cockpit-core/src/prompt.rs b/crates/cockpit-core/src/prompt.rs index 2120c31..b0b76ac 100644 --- a/crates/cockpit-core/src/prompt.rs +++ b/crates/cockpit-core/src/prompt.rs @@ -342,7 +342,7 @@ pub fn render_anchor(anchor: &Anchor, plan_doc: Option<&PlanDoc>) -> String { format!("plan step {idx}") } Anchor::PlanFile(path) => format!("plan file: {}", path.display()), - Anchor::DiffLine { path, range } => { + Anchor::DiffLine { path, range, .. } => { format!("{}:{}-{}", path.display(), range.0, range.1) } } @@ -381,7 +381,7 @@ mod tests { use std::path::PathBuf; use super::*; - use crate::model::{CommentId, CommentOrigin, DiffData, PlanStep}; + use crate::model::{CommentId, CommentOrigin, DiffData, DiffSide, PlanStep}; fn sample_plan_doc() -> PlanDoc { PlanDoc { @@ -414,6 +414,7 @@ mod tests { anchor: Anchor::DiffLine { path: PathBuf::from("src/main.rs"), range: (10, 15), + side: DiffSide::New, }, body: "This function needs error handling".into(), origin: CommentOrigin::Local, @@ -423,6 +424,7 @@ mod tests { anchor: Anchor::DiffLine { path: PathBuf::from("src/lib.rs"), range: (42, 42), + side: DiffSide::New, }, body: "Missing doc comment on public method".into(), origin: CommentOrigin::Local, @@ -698,6 +700,7 @@ mod tests { let anchor = Anchor::DiffLine { path: PathBuf::from("src/main.rs"), range: (10, 15), + side: DiffSide::New, }; assert_eq!(render_anchor(&anchor, None), "src/main.rs:10-15"); } diff --git a/crates/cockpit-core/src/restack.rs b/crates/cockpit-core/src/restack.rs index 47b9adb..08538c6 100644 --- a/crates/cockpit-core/src/restack.rs +++ b/crates/cockpit-core/src/restack.rs @@ -11,6 +11,7 @@ //! The git-level cherry-pick lives in `adapters::git::restack`; this module //! provides the graph-level helpers that operate on `&mut [Review]`. +use std::collections::{HashSet, VecDeque}; use std::path::Path; use git2::{Oid, Repository}; @@ -155,27 +156,30 @@ pub async fn restack_or_resolve( // Graph helpers // --------------------------------------------------------------------------- -/// Mark all descendants of a review as stale. +/// Mark every transitive descendant of `parent_id` as stale. /// -/// Called when a review enters `Dispatched` — its descendants should not be -/// deep-reviewed until the restack completes. +/// The loop calls this when a parent review enters `Dispatched` or merges: its +/// descendants must not be deep-reviewed until they have been restacked onto the +/// reworked base (SPEC §7/§13). The parent itself is left untouched. /// -/// Walks the `children` edges transitively (breadth-first) and sets -/// `stale = true` on every descendant. The parent itself is *not* marked. -/// -/// Gated to `#[cfg(test)]`: the graph-level batch restack seam (SPEC §13) is -/// not yet wired into the loop, so this helper is currently exercised only by -/// its own coverage. Promote it to `pub` when the batch-restack driver lands. -#[cfg(test)] -fn mark_descendants_stale(reviews: &mut [Review], parent_id: &ReviewId) { - // Collect children IDs from the parent to seed the traversal. +/// Traversal is breadth-first over the `children` edges and guarded by a visited +/// set, so diamonds and (malformed) cycles terminate rather than looping forever. +/// Child ids with no matching review in `reviews` are silently skipped. +pub fn mark_descendants_stale(reviews: &mut [Review], parent_id: &ReviewId) { + // Seed the traversal with the parent's direct children. let mut queue: Vec<ReviewId> = reviews .iter() .filter(|r| r.id == *parent_id) .flat_map(|r| r.children.clone()) .collect(); + // Track visited ids so diamonds and cycles are each processed once. + let mut visited: HashSet<ReviewId> = HashSet::new(); + while let Some(id) = queue.pop() { + if !visited.insert(id.clone()) { + continue; + } for review in reviews.iter_mut() { if review.id == id { review.mark_stale(); @@ -219,40 +223,53 @@ pub fn restack_review( Ok(clean) } -/// Build a breadth-first traversal order of descendants with their parent branch names. +/// Build the restack order for the descendants of `root_id`. /// -/// Returns `(child_id, parent_branch)` pairs in BFS order, guaranteeing that -/// every review's parent is processed before the review itself. +/// Returns `(child_id, parent_branch)` pairs in breadth-first order, so every +/// review's parent is restacked before the review itself. The loop consumes this +/// after a parent enters `Dispatched` or merges, walking the pairs to rebase each +/// stale descendant onto its new base (SPEC §7/§13). /// -/// Gated to `#[cfg(test)]`: the graph-level batch restack seam (SPEC §13) that -/// would consume this ordering is not yet wired into the loop, so the helper is -/// currently exercised only by its own coverage. Promote it (and add the -/// driver that walks the order) when the batch-restack step lands. -#[cfg(test)] -fn dependency_order(reviews: &[Review], root_id: &ReviewId) -> Vec<(ReviewId, String)> { +/// A visited set keeps diamonds and (malformed) cycles finite — each descendant +/// appears at most once, restacked onto the parent reached first in the BFS. +/// Child ids with no matching review in `reviews` are skipped (they cannot be +/// restacked), and an unknown `root_id` yields an empty order. +pub fn dependency_order(reviews: &[Review], root_id: &ReviewId) -> Vec<(ReviewId, String)> { let mut order = Vec::new(); // Find root's branch and children. - let root = reviews.iter().find(|r| r.id == *root_id); - let Some(root) = root else { + let Some(root) = reviews.iter().find(|r| r.id == *root_id) else { return order; }; - let mut queue: Vec<(ReviewId, String)> = root + // Track visited ids so each descendant is emitted once, even across + // diamonds, and so cycles terminate. + let mut visited: HashSet<ReviewId> = HashSet::new(); + visited.insert(root.id.clone()); + + let mut queue: VecDeque<(ReviewId, String)> = root .children .iter() .map(|child_id| (child_id.clone(), root.branch.clone())) .collect(); - while let Some((child_id, parent_branch)) = queue.first().cloned() { - queue.remove(0); - order.push((child_id.clone(), parent_branch)); + while let Some((child_id, parent_branch)) = queue.pop_front() { + // Skip nodes already emitted via another edge (or a cycle). + if !visited.insert(child_id.clone()) { + continue; + } - // Find this child and enqueue its children. - if let Some(child) = reviews.iter().find(|r| r.id == child_id) { - for grandchild_id in &child.children { - queue.push((grandchild_id.clone(), child.branch.clone())); - } + // Skip dangling edges: a child id with no matching review can't be + // restacked, so it never enters the order. + let Some(child) = reviews.iter().find(|r| r.id == child_id) else { + continue; + }; + + order.push((child_id, parent_branch)); + + // Enqueue this child's children onto the child's own branch. + for grandchild_id in &child.children { + queue.push_back((grandchild_id.clone(), child.branch.clone())); } } @@ -282,6 +299,8 @@ mod tests { id: ReviewId::new(id), issue: IssueRef::new(format!("ISSUE-{id}")), pr: PrRef::new(format!("owner/repo#{id}")), + title: String::new(), + body: String::new(), branch: branch.to_string(), base: base.to_string(), base_sha: "000".into(), @@ -297,6 +316,7 @@ mod tests { agent: None, repo_slug: None, project: None, + dispatch_snapshot: None, } } diff --git a/crates/cockpit-core/src/store.rs b/crates/cockpit-core/src/store.rs index c44e6d5..4b74025 100644 --- a/crates/cockpit-core/src/store.rs +++ b/crates/cockpit-core/src/store.rs @@ -6,12 +6,21 @@ //! //! These back [`AppState`](../../app/src-tauri) and are driven by the Tauri //! commands. Thread-safe in-memory access via `Arc<Mutex<…>>`; the app owns -//! the lifetime, so there is no on-disk persistence layer here. +//! the lifetime. Each store carries a monotonic revision counter so the +//! persistence layer ([`crate::persist`]) can cheaply detect changes and +//! re-save without diffing the whole map. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use crate::model::{GateState, PrRef, Project, ProjectId, ProjectPlan, Review}; +use crate::model::{GateState, PrRef, Project, ProjectId, ProjectPlan, Review, ReviewId}; + +// INVARIANT: every `.lock().expect("... lock poisoned")` below deliberately +// propagates a poisoned-lock panic (CLAUDE.md §2). A `Mutex` becomes poisoned +// only when another thread panicked while holding it, leaving the map in an +// unknown, unrecoverable state; continuing on that state would be worse than +// crashing, so re-panicking via `expect` is the correct response. // --------------------------------------------------------------------------- // ReviewStore (in-memory) @@ -20,10 +29,13 @@ use crate::model::{GateState, PrRef, Project, ProjectId, ProjectPlan, Review}; /// Thread-safe in-memory store for active reviews. /// /// Keyed by [`PrRef`]. Uses `std::sync::Mutex` because the lock is held only -/// for trivial `HashMap` operations (no `.await` while locked). +/// for trivial `HashMap` operations (no `.await` while locked). A shared +/// [`AtomicU64`] revision counter bumps on every mutation so the persistence +/// layer can detect changes without diffing the map. #[derive(Debug, Clone, Default)] pub struct ReviewStore { inner: Arc<Mutex<HashMap<PrRef, Review>>>, + revision: Arc<AtomicU64>, } impl ReviewStore { @@ -32,11 +44,31 @@ impl ReviewStore { Self::default() } + /// Current revision of the store. + /// + /// A monotonic generation counter that increments on every mutating call. + /// Callers snapshot it and compare later to decide whether a re-save is + /// needed; the absolute value carries no meaning beyond "changed since". + pub fn revision(&self) -> u64 { + self.revision.load(Ordering::Relaxed) + } + + /// Increment the revision counter. + /// + /// `Relaxed` is sufficient: the counter is a coarse change signal, not a + /// synchronization primitive for the map contents (that is the `Mutex`'s + /// job), so it needs only atomicity, not ordering. + fn bump(&self) { + self.revision.fetch_add(1, Ordering::Relaxed); + } + /// Insert a review, keyed by its `pr` field. pub fn insert(&self, review: Review) { // INVARIANT: lock held only for a HashMap insert — no .await, no blocking. let mut map = self.inner.lock().expect("review store lock poisoned"); map.insert(review.pr.clone(), review); + drop(map); + self.bump(); } /// Get a clone of the review for the given PR reference. @@ -52,19 +84,29 @@ impl ReviewStore { pub fn update(&self, pr: &PrRef, f: impl FnOnce(&mut Review)) -> bool { // INVARIANT: lock held only for a HashMap op, no .await, no blocking. let mut map = self.inner.lock().expect("review store lock poisoned"); - if let Some(review) = map.get_mut(pr) { + let found = if let Some(review) = map.get_mut(pr) { f(review); true } else { false + }; + drop(map); + // Only bump when the closure actually ran on an existing entry: a + // missing-key update mutates nothing and must not trigger a re-save. + if found { + self.bump(); } + found } /// Remove the review for the given PR reference, returning it if present. pub fn remove(&self, pr: &PrRef) -> Option<Review> { // INVARIANT: lock held only for a HashMap op, no .await, no blocking. let mut map = self.inner.lock().expect("review store lock poisoned"); - map.remove(pr) + let removed = map.remove(pr); + drop(map); + self.bump(); + removed } /// Clone all reviews as a `Vec`. @@ -73,6 +115,65 @@ impl ReviewStore { let map = self.inner.lock().expect("review store lock poisoned"); map.values().cloned().collect() } + + /// Bulk-replace the store's contents with `reviews` and bump the revision. + /// + /// Used at startup to hydrate the store from persisted state: every existing + /// entry is dropped and each review is re-keyed by its `pr` field. + pub fn hydrate(&self, reviews: Vec<Review>) { + // INVARIANT: lock held only for HashMap ops, no .await, no blocking. + let mut map = self.inner.lock().expect("review store lock poisoned"); + map.clear(); + for review in reviews { + map.insert(review.pr.clone(), review); + } + drop(map); + self.bump(); + } + + /// Mark every descendant of `parent` as [`stale`](Review::stale). + /// + /// Performs a breadth-first walk over `children` edges starting from + /// `parent`'s direct children and sets `stale = true` on each review it + /// reaches. The `parent` itself is never marked (it is not its own + /// descendant), and an unknown `parent` is a no-op that leaves the revision + /// untouched. + /// + /// `children` edges hold [`ReviewId`]s while the map is keyed by [`PrRef`], + /// so each hop resolves a review by scanning on id (the graph is small). A + /// `visited` set makes the walk terminate even if the edges form a cycle. + pub fn mark_descendants_stale(&self, parent: &ReviewId) { + // INVARIANT: lock held only for in-memory graph work, no .await, no blocking. + let mut map = self.inner.lock().expect("review store lock poisoned"); + + let mut visited: HashSet<ReviewId> = HashSet::new(); + // Pre-mark the parent visited: it is not a descendant, and this also + // guards against a cycle whose back-edge points at the parent. + visited.insert(parent.clone()); + + let mut queue: VecDeque<ReviewId> = VecDeque::new(); + // Seed with the parent's direct children. Unknown parent -> no seeds. + if let Some(parent_review) = map.values().find(|r| &r.id == parent) { + queue.extend(parent_review.children.iter().cloned()); + } + + let mut changed = false; + while let Some(id) = queue.pop_front() { + if !visited.insert(id.clone()) { + continue; + } + if let Some(review) = map.values_mut().find(|r| r.id == id) { + review.stale = true; + changed = true; + queue.extend(review.children.iter().cloned()); + } + } + + drop(map); + if changed { + self.bump(); + } + } } /// Return all reviews belonging to the given project. @@ -147,10 +248,13 @@ pub fn batch_status(store: &ReviewStore, project: Option<&ProjectId>) -> BatchSt /// Thread-safe in-memory store for first-class projects. /// /// Keyed by [`ProjectId`]. Mirrors [`ReviewStore`]: the lock is held only for -/// trivial `HashMap` operations (no `.await` while locked). +/// trivial `HashMap` operations (no `.await` while locked), and a shared +/// [`AtomicU64`] revision counter bumps on every mutation so the persistence +/// layer can detect changes without diffing the map. #[derive(Debug, Clone, Default)] pub struct ProjectStore { inner: Arc<Mutex<HashMap<ProjectId, Project>>>, + revision: Arc<AtomicU64>, } impl ProjectStore { @@ -159,11 +263,29 @@ impl ProjectStore { Self::default() } + /// Current revision of the store. + /// + /// A monotonic generation counter that increments on every mutating call; + /// see [`ReviewStore::revision`] for the same contract. + pub fn revision(&self) -> u64 { + self.revision.load(Ordering::Relaxed) + } + + /// Increment the revision counter. + /// + /// `Relaxed` is sufficient: the counter is a coarse change signal, not a + /// synchronization primitive for the map contents. + fn bump(&self) { + self.revision.fetch_add(1, Ordering::Relaxed); + } + /// Insert a project, keyed by its `id` field. pub fn insert(&self, project: Project) { // INVARIANT: lock held only for a HashMap insert — no .await, no blocking. let mut map = self.inner.lock().expect("project store lock poisoned"); map.insert(project.id.clone(), project); + drop(map); + self.bump(); } /// Get a clone of the project for the given id. @@ -179,19 +301,29 @@ impl ProjectStore { pub fn update(&self, id: &ProjectId, f: impl FnOnce(&mut Project)) -> bool { // INVARIANT: lock held only for a HashMap op, no .await, no blocking. let mut map = self.inner.lock().expect("project store lock poisoned"); - if let Some(project) = map.get_mut(id) { + let found = if let Some(project) = map.get_mut(id) { f(project); true } else { false + }; + drop(map); + // Only bump when the closure actually ran on an existing entry: a + // missing-key update mutates nothing and must not trigger a re-save. + if found { + self.bump(); } + found } /// Remove the project for the given id, returning it if present. pub fn remove(&self, id: &ProjectId) -> Option<Project> { // INVARIANT: lock held only for a HashMap op, no .await, no blocking. let mut map = self.inner.lock().expect("project store lock poisoned"); - map.remove(id) + let removed = map.remove(id); + drop(map); + self.bump(); + removed } /// Clone all projects as a `Vec`. @@ -201,6 +333,21 @@ impl ProjectStore { map.values().cloned().collect() } + /// Bulk-replace the store's contents with `projects` and bump the revision. + /// + /// Used at startup to hydrate the store from persisted state: every existing + /// entry is dropped and each project is re-keyed by its `id` field. + pub fn hydrate(&self, projects: Vec<Project>) { + // INVARIANT: lock held only for HashMap ops, no .await, no blocking. + let mut map = self.inner.lock().expect("project store lock poisoned"); + map.clear(); + for project in projects { + map.insert(project.id.clone(), project); + } + drop(map); + self.bump(); + } + /// Get a clone of the plan owned by the given project, if any. /// /// Returns `None` when the project is unknown or has no plan yet. @@ -218,12 +365,19 @@ impl ProjectStore { pub fn update_plan(&self, id: &ProjectId, f: impl FnOnce(&mut Option<ProjectPlan>)) -> bool { // INVARIANT: lock held only for a HashMap op, no .await, no blocking. let mut map = self.inner.lock().expect("project store lock poisoned"); - if let Some(project) = map.get_mut(id) { + let found = if let Some(project) = map.get_mut(id) { f(&mut project.plan); true } else { false + }; + drop(map); + // Only bump when the closure actually ran on an existing entry: a + // missing-key update mutates nothing and must not trigger a re-save. + if found { + self.bump(); } + found } } @@ -244,6 +398,8 @@ mod tests { id: ReviewId::new(format!("r-{pr_num}")), issue: IssueRef::new(format!("ISSUE-{pr_num}")), pr: PrRef::new(format!("owner/repo#{pr_num}")), + title: String::new(), + body: String::new(), branch: format!("alejandro/test-{pr_num}"), base: "main".into(), base_sha: "000".into(), @@ -259,6 +415,7 @@ mod tests { agent: None, repo_slug: None, project: None, + dispatch_snapshot: None, } } @@ -516,4 +673,209 @@ mod tests { assert_eq!(status.ready, 0); assert_eq!(status.approved, 0); } + + // --------------------------------------------------------------- + // Revision counter + hydrate + // --------------------------------------------------------------- + + #[test] + fn review_store_revision_bumps_on_mutators() { + let store = ReviewStore::new(); + assert_eq!(store.revision(), 0, "fresh store starts at revision 0"); + + store.insert(make_review(1)); + let after_insert = store.revision(); + assert!(after_insert > 0, "insert bumps the revision"); + + store.update(&PrRef::new("owner/repo#1"), |r| r.stale = true); + let after_update = store.revision(); + assert!(after_update > after_insert, "update bumps the revision"); + + store.remove(&PrRef::new("owner/repo#1")); + let after_remove = store.revision(); + assert!(after_remove > after_update, "remove bumps the revision"); + + // A missing-key update mutates nothing and must not bump the revision. + let missing = store.update(&PrRef::new("owner/repo#404"), |r| r.stale = true); + assert!(!missing, "update on a missing key returns false"); + assert_eq!( + store.revision(), + after_remove, + "a missing-key update must not bump the revision" + ); + } + + #[test] + fn review_store_hydrate_replaces_and_bumps() { + let store = ReviewStore::new(); + store.insert(make_review(1)); + let before = store.revision(); + + store.hydrate(vec![make_review(2), make_review(3)]); + assert!(store.revision() > before, "hydrate bumps the revision"); + + // Old contents are gone; only the hydrated reviews remain. + assert!(store.get(&PrRef::new("owner/repo#1")).is_none()); + assert_eq!(store.list().len(), 2); + } + + #[test] + fn project_store_revision_bumps_on_mutators() { + let store = ProjectStore::new(); + assert_eq!(store.revision(), 0, "fresh store starts at revision 0"); + + store.insert(make_project("p-1", ProjectSource::AdHoc)); + let after_insert = store.revision(); + assert!(after_insert > 0, "insert bumps the revision"); + + store.update(&ProjectId::new("p-1"), |p| p.name = "renamed".into()); + let after_update = store.revision(); + assert!(after_update > after_insert, "update bumps the revision"); + + store.update_plan(&ProjectId::new("p-1"), |slot| *slot = Some(make_plan())); + let after_plan = store.revision(); + assert!(after_plan > after_update, "update_plan bumps the revision"); + + store.remove(&ProjectId::new("p-1")); + let after_remove = store.revision(); + assert!(after_remove > after_plan, "remove bumps the revision"); + + // Missing-key mutators (`update` / `update_plan`) change nothing and + // must not bump the revision. + let missing = store.update(&ProjectId::new("absent"), |p| p.name = "x".into()); + assert!(!missing, "update on a missing key returns false"); + assert_eq!( + store.revision(), + after_remove, + "a missing-key update must not bump the revision" + ); + + let missing_plan = + store.update_plan(&ProjectId::new("absent"), |slot| *slot = Some(make_plan())); + assert!(!missing_plan, "update_plan on a missing key returns false"); + assert_eq!( + store.revision(), + after_remove, + "a missing-key update_plan must not bump the revision" + ); + } + + #[test] + fn project_store_hydrate_replaces_and_bumps() { + let store = ProjectStore::new(); + store.insert(make_project("p-1", ProjectSource::AdHoc)); + let before = store.revision(); + + store.hydrate(vec![ + make_project("p-2", ProjectSource::AdHoc), + make_project("p-3", ProjectSource::AdHoc), + ]); + assert!(store.revision() > before, "hydrate bumps the revision"); + + assert!(store.get(&ProjectId::new("p-1")).is_none()); + assert_eq!(store.list().len(), 2); + } + + // --------------------------------------------------------------- + // mark_descendants_stale + // --------------------------------------------------------------- + + /// Build a review numbered `num` whose `children` edges point at `children`. + fn make_linked(num: u64, children: &[u64]) -> Review { + let mut review = make_review(num); + review.children = children + .iter() + .map(|c| ReviewId::new(format!("r-{c}"))) + .collect(); + review + } + + /// Read the `stale` flag of the review numbered `num`. + fn is_stale(store: &ReviewStore, num: u64) -> bool { + store + .get(&PrRef::new(format!("owner/repo#{num}"))) + .expect("review should be present") + .stale + } + + #[test] + fn mark_descendants_stale_linear_chain() { + // A → B → C + let store = ReviewStore::new(); + store.insert(make_linked(1, &[2])); + store.insert(make_linked(2, &[3])); + store.insert(make_linked(3, &[])); + let before = store.revision(); + + store.mark_descendants_stale(&ReviewId::new("r-1")); + + assert!(!is_stale(&store, 1), "parent is not its own descendant"); + assert!(is_stale(&store, 2)); + assert!(is_stale(&store, 3)); + assert!(store.revision() > before, "marking bumps the revision"); + } + + #[test] + fn mark_descendants_stale_from_middle() { + // A → B → C, staling from B. + let store = ReviewStore::new(); + store.insert(make_linked(1, &[2])); + store.insert(make_linked(2, &[3])); + store.insert(make_linked(3, &[])); + + store.mark_descendants_stale(&ReviewId::new("r-2")); + + assert!(!is_stale(&store, 1), "ancestor is untouched"); + assert!(!is_stale(&store, 2), "starting node is not a descendant"); + assert!(is_stale(&store, 3), "only descendants are staled"); + } + + #[test] + fn mark_descendants_stale_diamond() { + // A → B, A → C, B → D, C → D + let store = ReviewStore::new(); + store.insert(make_linked(1, &[2, 3])); + store.insert(make_linked(2, &[4])); + store.insert(make_linked(3, &[4])); + store.insert(make_linked(4, &[])); + + store.mark_descendants_stale(&ReviewId::new("r-1")); + + assert!(!is_stale(&store, 1)); + assert!(is_stale(&store, 2)); + assert!(is_stale(&store, 3)); + assert!(is_stale(&store, 4), "converging descendant marked once"); + } + + #[test] + fn mark_descendants_stale_unknown_parent_is_noop() { + let store = ReviewStore::new(); + store.insert(make_linked(1, &[2])); + store.insert(make_linked(2, &[])); + let before = store.revision(); + + store.mark_descendants_stale(&ReviewId::new("r-999")); + + assert!(!is_stale(&store, 1)); + assert!(!is_stale(&store, 2)); + assert_eq!(store.revision(), before, "a no-op must not bump"); + } + + #[test] + fn mark_descendants_stale_handles_cycles() { + // Pathological cycle A → B → C → A: the walk must terminate. + let store = ReviewStore::new(); + store.insert(make_linked(1, &[2])); + store.insert(make_linked(2, &[3])); + store.insert(make_linked(3, &[1])); + + store.mark_descendants_stale(&ReviewId::new("r-1")); + + assert!( + !is_stale(&store, 1), + "back-edge to parent leaves it unmarked" + ); + assert!(is_stale(&store, 2)); + assert!(is_stale(&store, 3)); + } } diff --git a/crates/cockpit-core/src/workflow.rs b/crates/cockpit-core/src/workflow.rs deleted file mode 100644 index c8b3367..0000000 --- a/crates/cockpit-core/src/workflow.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! Workflow automation: dispatch side effects on gate state transitions. -//! -//! When the gate state machine transitions (e.g. `InReview -> Dispatched`), -//! callers can evaluate a set of [`Rule`]s against the [`TransitionEvent`] -//! to determine which [`Action`]s should fire. The actions themselves are -//! data — execution is the caller's responsibility, keeping this module -//! pure and testable. - -use crate::model::GateState; -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// Event -// --------------------------------------------------------------------------- - -/// A state transition event emitted after a [`Gated`](crate::gate::Gated) -/// transition succeeds. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TransitionEvent { - /// The object that transitioned (review ID or plan ID as a string). - pub object_id: String, - /// Previous gate state. - pub from: GateState, - /// New gate state. - pub to: GateState, -} - -// --------------------------------------------------------------------------- -// Actions -// --------------------------------------------------------------------------- - -/// An action to perform when a transition matches a rule. -/// -/// Actions are data-only; the caller is responsible for executing them -/// (e.g. calling the Linear API, sending a notification, or POSTing to -/// a webhook). This keeps the workflow module free of side effects. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum Action { - /// Update a Linear issue status. - UpdateLinearStatus { - /// The target status string (e.g. `"In Review"`, `"Done"`). - status: String, - }, - /// Send a desktop notification. - Notify { - /// Notification title. - title: String, - /// Notification body text. - body: String, - }, - /// POST to a webhook URL. - Webhook { - /// The URL to POST the transition event to. - url: String, - }, -} - -// --------------------------------------------------------------------------- -// Rules -// --------------------------------------------------------------------------- - -/// A rule mapping a transition pattern to a set of actions. -/// -/// Both `from_state` and `to_state` are optional wildcards: `None` matches -/// any state, `Some(s)` matches only that specific state. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Rule { - /// Match transitions originating from this state (`None` = any). - pub from_state: Option<GateState>, - /// Match transitions arriving at this state (`None` = any). - pub to_state: Option<GateState>, - /// Actions to execute when the rule matches. - pub actions: Vec<Action>, -} - -// --------------------------------------------------------------------------- -// Evaluation -// --------------------------------------------------------------------------- - -/// Evaluate rules against a transition event and return matching actions. -/// -/// A rule matches if both its `from_state` (when set) equals `event.from` -/// and its `to_state` (when set) equals `event.to`. `None` acts as a -/// wildcard, matching any state. -pub fn evaluate_rules<'a>(event: &TransitionEvent, rules: &'a [Rule]) -> Vec<&'a Action> { - rules - .iter() - .filter(|rule| { - rule.from_state.as_ref().is_none_or(|s| *s == event.from) - && rule.to_state.as_ref().is_none_or(|s| *s == event.to) - }) - .flat_map(|rule| &rule.actions) - .collect() -} - -// --------------------------------------------------------------------------- -// Transition event constructor (for use in gate.rs callers) -// --------------------------------------------------------------------------- - -/// Construct a [`TransitionEvent`] from its parts. -/// -/// Convenience helper so callers don't need to import the struct and build -/// it manually. -pub fn transition_event(object_id: &str, from: GateState, to: GateState) -> TransitionEvent { - TransitionEvent { - object_id: object_id.to_owned(), - from, - to, - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - fn notify_action(title: &str, body: &str) -> Action { - Action::Notify { - title: title.into(), - body: body.into(), - } - } - - fn linear_action(status: &str) -> Action { - Action::UpdateLinearStatus { - status: status.into(), - } - } - - fn webhook_action(url: &str) -> Action { - Action::Webhook { url: url.into() } - } - - // -- evaluate_rules --------------------------------------------------- - - #[test] - fn matches_exact_transition() { - let rules = vec![Rule { - from_state: Some(GateState::InReview), - to_state: Some(GateState::Dispatched), - actions: vec![notify_action("Dispatched", "Review dispatched to agent")], - }]; - - let event = transition_event("r-1", GateState::InReview, GateState::Dispatched); - let actions = evaluate_rules(&event, &rules); - - assert_eq!(actions.len(), 1); - assert_eq!( - *actions[0], - notify_action("Dispatched", "Review dispatched to agent") - ); - } - - #[test] - fn wildcard_from_matches_any_source() { - let rules = vec![Rule { - from_state: None, - to_state: Some(GateState::Approved), - actions: vec![linear_action("Done")], - }]; - - // Should match regardless of the source state. - let event = transition_event("r-1", GateState::InReview, GateState::Approved); - let actions = evaluate_rules(&event, &rules); - assert_eq!(actions.len(), 1); - assert_eq!(*actions[0], linear_action("Done")); - } - - #[test] - fn wildcard_to_matches_any_target() { - let rules = vec![Rule { - from_state: Some(GateState::Dispatched), - to_state: None, - actions: vec![webhook_action("https://example.com/hook")], - }]; - - let event = transition_event("r-1", GateState::Dispatched, GateState::Reworked); - let actions = evaluate_rules(&event, &rules); - assert_eq!(actions.len(), 1); - assert_eq!(*actions[0], webhook_action("https://example.com/hook")); - } - - #[test] - fn fully_wildcard_matches_everything() { - let rules = vec![Rule { - from_state: None, - to_state: None, - actions: vec![webhook_action("https://example.com/all")], - }]; - - let event = transition_event("r-1", GateState::Pending, GateState::InReview); - let actions = evaluate_rules(&event, &rules); - assert_eq!(actions.len(), 1); - } - - #[test] - fn no_match_returns_empty() { - let rules = vec![Rule { - from_state: Some(GateState::InReview), - to_state: Some(GateState::Approved), - actions: vec![notify_action("Approved", "Nice")], - }]; - - let event = transition_event("r-1", GateState::Dispatched, GateState::Reworked); - let actions = evaluate_rules(&event, &rules); - assert!(actions.is_empty()); - } - - #[test] - fn multiple_rules_can_match() { - let rules = vec![ - Rule { - from_state: None, - to_state: Some(GateState::Approved), - actions: vec![linear_action("Done")], - }, - Rule { - from_state: Some(GateState::InReview), - to_state: Some(GateState::Approved), - actions: vec![notify_action("Approved", "Review approved")], - }, - ]; - - let event = transition_event("r-1", GateState::InReview, GateState::Approved); - let actions = evaluate_rules(&event, &rules); - assert_eq!(actions.len(), 2); - } - - #[test] - fn multiple_actions_per_rule() { - let rules = vec![Rule { - from_state: None, - to_state: Some(GateState::Approved), - actions: vec![ - linear_action("Done"), - notify_action("Merged", "PR merged"), - webhook_action("https://example.com/merged"), - ], - }]; - - let event = transition_event("r-1", GateState::InReview, GateState::Approved); - let actions = evaluate_rules(&event, &rules); - assert_eq!(actions.len(), 3); - } - - #[test] - fn empty_rules_returns_empty() { - let event = transition_event("r-1", GateState::Pending, GateState::InReview); - let actions = evaluate_rules(&event, &[]); - assert!(actions.is_empty()); - } - - #[test] - fn rule_with_no_actions_contributes_nothing() { - let rules = vec![Rule { - from_state: None, - to_state: None, - actions: vec![], - }]; - - let event = transition_event("r-1", GateState::Pending, GateState::InReview); - let actions = evaluate_rules(&event, &rules); - assert!(actions.is_empty()); - } - - // -- transition_event ------------------------------------------------- - - #[test] - fn transition_event_construction() { - let event = transition_event("plan-42", GateState::Pending, GateState::InReview); - assert_eq!(event.object_id, "plan-42"); - assert_eq!(event.from, GateState::Pending); - assert_eq!(event.to, GateState::InReview); - } - - // -- serialization round-trip ----------------------------------------- - - #[test] - fn action_round_trip() { - let action = Action::UpdateLinearStatus { - status: "In Progress".into(), - }; - let json = serde_json::to_string(&action).expect("serialize"); - let back: Action = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(action, back); - } - - #[test] - fn rule_round_trip() { - let rule = Rule { - from_state: Some(GateState::InReview), - to_state: Some(GateState::Dispatched), - actions: vec![ - notify_action("Review sent", "Dispatched to agent"), - webhook_action("https://example.com/hook"), - ], - }; - - let json = serde_json::to_string(&rule).expect("serialize"); - let back: Rule = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(back.from_state, rule.from_state); - assert_eq!(back.to_state, rule.to_state); - assert_eq!(back.actions, rule.actions); - } -} diff --git a/crates/cockpit-core/tests/batch_fan_out_e2e.rs b/crates/cockpit-core/tests/batch_fan_out_e2e.rs index 045ef24..fe45d42 100644 --- a/crates/cockpit-core/tests/batch_fan_out_e2e.rs +++ b/crates/cockpit-core/tests/batch_fan_out_e2e.rs @@ -123,6 +123,8 @@ fn make_review(id: &str, branch: &str, worktree: PathBuf, project: &ProjectId) - id: ReviewId::new(id), issue: IssueRef::new(format!("ISSUE-{id}")), pr: PrRef::new(format!("owner/repo#{id}")), + title: String::new(), + body: String::new(), branch: branch.into(), base: "main".into(), base_sha: String::new(), @@ -138,6 +140,7 @@ fn make_review(id: &str, branch: &str, worktree: PathBuf, project: &ProjectId) - agent: None, repo_slug: None, project: Some(project.clone()), + dispatch_snapshot: None, } } diff --git a/crates/cockpit-core/tests/e2e_round_trip.rs b/crates/cockpit-core/tests/e2e_round_trip.rs index 9048ee0..e6b503d 100644 --- a/crates/cockpit-core/tests/e2e_round_trip.rs +++ b/crates/cockpit-core/tests/e2e_round_trip.rs @@ -22,6 +22,8 @@ fn make_test_review(worktree: PathBuf) -> Review { id: ReviewId::new("e2e-review-1"), issue: IssueRef::new("TEST-1"), pr: PrRef::new("owner/repo#1"), + title: String::new(), + body: String::new(), branch: "alejandro/test-1-feature".into(), base: "main".into(), base_sha: "000".into(), @@ -39,6 +41,7 @@ fn make_test_review(worktree: PathBuf) -> Review { agent: None, repo_slug: None, project: None, + dispatch_snapshot: None, } } @@ -62,6 +65,7 @@ async fn full_review_loop_round_trip() { anchor: Anchor::DiffLine { path: PathBuf::from("test.rs"), range: (1, 1), + side: DiffSide::New, }, body: "Add error handling to this function".into(), origin: CommentOrigin::Local, @@ -258,6 +262,7 @@ async fn agent_failure_preserves_comments() { anchor: Anchor::DiffLine { path: PathBuf::from("lib.rs"), range: (5, 10), + side: DiffSide::New, }, body: "Fix the off-by-one error".into(), origin: CommentOrigin::Local, @@ -323,6 +328,7 @@ async fn stale_flag_set_on_parent_dispatch() { anchor: Anchor::DiffLine { path: PathBuf::from("main.rs"), range: (1, 1), + side: DiffSide::New, }, body: "Refactor this".into(), origin: CommentOrigin::Local, @@ -348,6 +354,7 @@ async fn stale_flag_set_on_parent_dispatch() { anchor: Anchor::DiffLine { path: PathBuf::from("child.rs"), range: (1, 1), + side: DiffSide::New, }, body: "Fix child issue".into(), origin: CommentOrigin::Local, diff --git a/crates/cockpit-core/tests/fix_loop_e2e.rs b/crates/cockpit-core/tests/fix_loop_e2e.rs index 0870fee..0008034 100644 --- a/crates/cockpit-core/tests/fix_loop_e2e.rs +++ b/crates/cockpit-core/tests/fix_loop_e2e.rs @@ -151,6 +151,8 @@ fn make_review(worktree: PathBuf, branch: &str, head_sha: &str) -> Review { id: ReviewId::new("fix-loop-review-1"), issue: IssueRef::new("TEST-42"), pr: PrRef::new("owner/repo#7"), + title: String::new(), + body: String::new(), branch: branch.into(), base: "main".into(), base_sha: "000".into(), @@ -168,6 +170,7 @@ fn make_review(worktree: PathBuf, branch: &str, head_sha: &str) -> Review { agent: None, repo_slug: None, project: None, + dispatch_snapshot: None, } } @@ -211,6 +214,7 @@ async fn diff_gate_fix_loop_round_trip() { anchor: Anchor::DiffLine { path: PathBuf::from("app.rs"), range: (1, 1), + side: DiffSide::New, }, body: "Add a fixed() function".into(), origin: CommentOrigin::Local, @@ -420,6 +424,7 @@ async fn agent_failed_edge_preserves_comments_for_redispatch() { anchor: Anchor::DiffLine { path: PathBuf::from("app.rs"), range: (1, 1), + side: DiffSide::New, }, body: "Fix the off-by-one".into(), origin: CommentOrigin::Local,