diff --git a/Cargo.lock b/Cargo.lock index e2cebe1b..9016734a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2533,9 +2533,10 @@ dependencies = [ name = "merge-request" version = "0.1.0" dependencies = [ + "chrono", "rusqlite", "serde", - "sha2 0.11.0", + "serde_json", "tempfile", "thiserror 2.0.18", ] diff --git a/crates/merge-request/Cargo.toml b/crates/merge-request/Cargo.toml index d5865a9c..e8a5ed85 100644 --- a/crates/merge-request/Cargo.toml +++ b/crates/merge-request/Cargo.toml @@ -5,9 +5,10 @@ edition.workspace = true license.workspace = true [dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } rusqlite.workspace = true serde = { workspace = true, features = ["derive"] } -sha2.workspace = true +serde_json.workspace = true thiserror.workspace = true [dev-dependencies] diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 04c2b1bb..74b52a11 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -1,89 +1,39 @@ -//! Workspace-scoped Merge Request authority. -//! -//! Merge Requests deliberately do not reuse Ticket thread review events. A review is -//! evidence for one immutable revision and can only be committed with a one-shot -//! capability registered from an actual Runtime-owned direct-child reviewer session. +use std::path::Path; +use std::sync::{Arc, Mutex}; -use rusqlite::{Connection, OptionalExtension, params}; +use chrono::{DateTime, Utc}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; -use std::time::Duration; use thiserror::Error; -const SCHEMA_VERSION: i64 = 11; -const REVIEWER_PROFILE: &str = "builtin:reviewer"; -const MAX_SUMMARY_BYTES: usize = 16 * 1024; -const MAX_REVIEW_BODY_BYTES: usize = 64 * 1024; -const MAX_CHANGED_PATHS: usize = 1_000; -const MAX_FINDINGS: usize = 1_000; -const MAX_FIELD_BYTES: usize = 4 * 1024; - -pub type Result = std::result::Result; - -#[derive(Debug, Error)] -pub enum MergeRequestError { - #[error("merge request database error: {0}")] - Database(String), - #[error("{0} must not be empty")] - Empty(&'static str), - #[error("{field} exceeds its bounded limit of {max} bytes/items")] - TooLarge { field: &'static str, max: usize }, - #[error("merge request not found for ticket {0}")] - NotFound(String), - #[error("merge request already exists for ticket {0}")] - AlreadyExists(String), - #[error("immutable revision {0} already exists with different content")] - RevisionConflict(String), - #[error("stale merge request revision: expected {expected}, current {current}")] - StaleRevision { expected: String, current: String }, - #[error("current Ticket assignment does not match the authenticated Coder")] - AssignmentMismatch, - #[error("reviewer must be an actual direct-child with effective profile builtin:reviewer")] - InvalidReviewer, - #[error("review attempt is invalid, revoked, already used, or belongs to another revision")] - InvalidReviewAttempt, - #[error("review result cannot be supplied by the assigned Coder itself")] - SelfApproval, - #[error("merge request current revision is not approved")] - NotApproved, - #[error("merge request is {0}, expected open")] - NotOpen(String), - #[error("completion operation id was reused with different input")] - OperationConflict, - #[error("completion merge outcome is invalid: {0}")] - InvalidMergeOutcome(String), - #[error("Merge Request target is unknown and must be resolved explicitly")] - UnknownTarget, - #[error("Ticket must be inprogress before Merge Request completion (current: {0})")] - TicketStateConflict(String), -} +const SCHEMA_VERSION: i64 = 12; +const PREVIOUS_SCHEMA_VERSION: i64 = 11; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MergeRequestState { - Draft, Open, - Closed, Merged, + Closed, } impl MergeRequestState { fn as_str(self) -> &'static str { match self { - Self::Draft => "draft", Self::Open => "open", - Self::Closed => "closed", Self::Merged => "merged", + Self::Closed => "closed", } } - fn parse(value: &str) -> Self { + fn parse(value: &str) -> Result { match value { - "draft" => Self::Draft, - "closed" => Self::Closed, - "merged" => Self::Merged, - _ => Self::Open, + "open" | "draft" => Ok(Self::Open), + "merged" => Ok(Self::Merged), + "closed" => Ok(Self::Closed), + other => Err(MergeRequestError::Corrupt(format!( + "unknown merge request state `{other}`" + ))), } } } @@ -95,44 +45,73 @@ pub enum ReviewDecision { RequestChanges, } -impl ReviewDecision { - fn as_str(self) -> &'static str { - match self { - Self::Approve => "approve", - Self::RequestChanges => "request_changes", - } - } - - fn parse(value: &str) -> Self { - match value { - "approve" => Self::Approve, - _ => Self::RequestChanges, - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum ReviewStatus { - Pending, - Approved, - ChangesRequested, +pub enum FindingSeverity { + Blocker, + Major, + Minor, + Note, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MergeRequestTargetStatus { - Known, - Unknown, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewFinding { + pub severity: FindingSeverity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, + pub message: String, } -impl MergeRequestTargetStatus { - fn parse(value: &str) -> Self { - match value { - "known" => Self::Known, - _ => Self::Unknown, - } - } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerIdentity { + pub runtime_id: String, + pub worker_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergeRequestAuth { + pub workspace_id: String, + pub repository_id: String, + pub runtime_id: String, + pub worker_id: String, + pub assignment_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequestForReview { + pub base_commit: String, + pub head_commit: String, + #[serde(default)] + pub changed_paths: Vec, + #[serde(default)] + pub summary: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequestForReviewEvent { + pub event_seq: u64, + pub base_commit: String, + pub head_commit: String, + pub changed_paths: Vec, + pub summary: String, + pub assignment_id: String, + pub requested_by: WorkerIdentity, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewEvent { + pub event_seq: u64, + pub request_event_seq: u64, + pub head_commit: String, + pub reviewer_worker: WorkerIdentity, + pub reviewer_profile: String, + pub decision: ReviewDecision, + pub body: String, + pub findings: Vec, + pub created_at: DateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -142,2688 +121,1772 @@ pub enum MergeStrategy { Merge, } -impl MergeStrategy { - fn as_str(self) -> &'static str { - match self { - Self::FastForward => "fast_forward", - Self::Merge => "merge", - } - } - - fn parse(value: &str) -> Self { - match value { - "fast_forward" => Self::FastForward, - _ => Self::Merge, - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum MergeResolution { +pub enum ConflictResolution { None, Clean, ConflictsResolved, } -impl MergeResolution { - fn as_str(self) -> &'static str { - match self { - Self::None => "none", - Self::Clean => "clean", - Self::ConflictsResolved => "conflicts_resolved", - } - } - - fn parse(value: &str) -> Self { - match value { - "none" => Self::None, - "conflicts_resolved" => Self::ConflictsResolved, - _ => Self::Clean, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeRequestRevision { - pub revision_id: String, - pub ordinal: u64, - pub base_commit: String, - pub head_commit: String, - pub changed_paths: Vec, - pub summary: String, - pub assignment_id: String, - pub created_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReviewFinding { - pub severity: String, - pub code: Option, - pub path: Option, - pub line: Option, - pub body: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeRequestReview { - pub attempt_id: String, - pub revision_id: String, - pub decision: ReviewDecision, - pub body: String, - pub findings: Vec, - pub parent_assignment_id: String, - pub parent_runtime_id: String, - pub parent_worker_id: String, - pub reviewer_child_session_id: String, - pub reviewer_effective_profile: String, - pub submitted_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeRequest { - pub merge_request_id: String, - pub workspace_id: String, - pub ticket_id: String, - pub repository_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_ref_selector: Option, - pub target_status: MergeRequestTargetStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub observed_target_commit: Option, - pub state: MergeRequestState, - pub lifecycle_generation: u64, - pub current_revision: MergeRequestRevision, - pub review_status: ReviewStatus, - pub current_review: Option, - pub created_at: String, - pub updated_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_revision_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_target_commit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_result_commit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merge_strategy: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merge_resolution: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_by_runtime_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_by_worker_id: Option, - pub merged_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OpenMergeRequest { - pub merge_request_id: String, - pub ticket_id: String, - pub repository_id: String, - pub target_ref_selector: String, - pub revision: MergeRequestRevision, - pub authenticated_runtime_id: String, - pub authenticated_worker_id: String, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AddRevision { - pub ticket_id: String, - pub expected_current_revision_id: String, - pub revision: MergeRequestRevision, - pub authenticated_runtime_id: String, - pub authenticated_worker_id: String, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RegisterReviewerChildSession { - pub parent_runtime_id: String, - pub parent_worker_id: String, - pub child_session_id: String, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RegisterReviewAttempt { - pub attempt_id: String, - pub ticket_id: String, - pub revision_id: String, - pub parent_assignment_id: String, - pub parent_runtime_id: String, - pub parent_worker_id: String, - pub child_session_id: String, - /// A secret generated by the trusted spawn layer and injected only into the child client. - pub capability_token: String, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubmitReview { - pub ticket_id: String, - pub revision_id: String, - pub capability_token: String, - pub decision: ReviewDecision, - pub body: String, - pub findings: Vec, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CompleteMergeRequest { +pub struct MergeEvent { + pub event_seq: u64, pub operation_id: String, - pub ticket_id: String, - pub expected_revision_id: String, pub target_commit: String, pub source_commit: String, pub result_commit: String, pub strategy: MergeStrategy, - pub resolution: MergeResolution, - pub implementation_assignment_id: String, - pub completion_actor_runtime_id: String, - pub completion_actor_worker_id: String, - pub now: String, + pub resolution: ConflictResolution, + pub merged_by: WorkerIdentity, + pub created_at: DateTime, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CompletionOutcome { - pub operation_id: String, - pub ticket_id: String, - pub revision_id: String, - pub ticket_state: String, - pub replayed: bool, +pub struct LifecycleEvent { + pub event_seq: u64, + pub actor: WorkerIdentity, + #[serde(default)] + pub body: String, + pub created_at: DateTime, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeRequestReadiness { - pub ticket_id: String, +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MergeRequestThreadEvent { + RequestForReview(RequestForReviewEvent), + Review(ReviewEvent), + Merge(MergeEvent), + Reopen(LifecycleEvent), + Close(LifecycleEvent), +} + +impl MergeRequestThreadEvent { + pub fn event_seq(&self) -> u64 { + match self { + Self::RequestForReview(value) => value.event_seq, + Self::Review(value) => value.event_seq, + Self::Merge(value) => value.event_seq, + Self::Reopen(value) | Self::Close(value) => value.event_seq, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MergeRequest { + pub workspace_id: String, pub merge_request_id: String, - pub revision_id: String, - pub target_ref_selector: Option, - pub observed_target_commit: Option, + pub ticket_id: String, + pub repository_id: String, + pub state: MergeRequestState, + pub selector_from: String, + pub selector_to: String, + pub opened_by_worker: WorkerIdentity, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default)] + pub thread: Vec, +} + +impl MergeRequest { + pub fn current_request(&self) -> Option<&RequestForReviewEvent> { + let reopened_at = self.thread.iter().rev().find_map(|event| match event { + MergeRequestThreadEvent::Reopen(value) => Some(value.event_seq), + _ => None, + }); + self.thread.iter().rev().find_map(|event| match event { + MergeRequestThreadEvent::RequestForReview(value) + if reopened_at.is_none_or(|seq| value.event_seq > seq) => + { + Some(value) + } + _ => None, + }) + } + + pub fn current_review(&self) -> Option<&ReviewEvent> { + let request = self.current_request()?; + self.thread.iter().rev().find_map(|event| match event { + MergeRequestThreadEvent::Review(value) + if value.request_event_seq == request.event_seq => + { + Some(value) + } + _ => None, + }) + } +} + +#[derive(Debug, Clone)] +pub struct OpenMergeRequest { + pub merge_request_id: String, + pub ticket_id: String, + pub repository_id: String, + pub selector_from: String, + pub selector_to: String, + pub request: RequestForReview, + pub auth: MergeRequestAuth, + pub now: DateTime, +} + +#[derive(Debug, Clone)] +pub struct RequestMergeRequestReview { + pub ticket_id: String, + pub expected_head_commit: String, + pub request: RequestForReview, + pub auth: MergeRequestAuth, + pub now: DateTime, +} + +#[derive(Debug, Clone)] +pub struct RegisterReviewCapability { + pub ticket_id: String, + pub expected_head_commit: String, + pub child_session_id: String, + pub capability_token: String, + pub auth: MergeRequestAuth, + pub now: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegisteredReviewCapability { + pub capability_token: String, + pub request_event_seq: u64, +} + +#[derive(Debug, Clone)] +pub struct RegisterReviewerChildSession { + pub workspace_id: String, + pub parent_runtime_id: String, + pub parent_worker_id: String, + pub child_session_id: String, + pub reviewer_profile: String, + pub now: DateTime, +} + +#[derive(Debug, Clone)] +pub struct SubmitMergeRequestReview { + pub ticket_id: String, + pub expected_head_commit: String, + pub capability_token: String, + pub decision: ReviewDecision, + pub body: String, + pub findings: Vec, + pub now: DateTime, +} + +#[derive(Debug, Clone)] +pub struct ReadinessCheck { + pub ticket_id: String, + pub expected_head_commit: Option, + pub auth: MergeRequestAuth, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadinessReport { pub ready: bool, - pub review_status: ReviewStatus, pub blockers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option, } -#[derive(Clone, Debug)] -pub struct SqliteMergeRequestStore { - db_path: PathBuf, - workspace_id: String, +#[derive(Debug, Clone)] +pub struct CompleteMergeRequest { + pub ticket_id: String, + pub expected_head_commit: String, + pub operation_id: String, + pub target_commit: String, + pub source_commit: String, + pub result_commit: String, + pub strategy: MergeStrategy, + pub resolution: ConflictResolution, + pub auth: MergeRequestAuth, + pub now: DateTime, } -impl SqliteMergeRequestStore { - pub fn open(db_path: impl Into, workspace_id: impl Into) -> Result { - let store = Self { - db_path: db_path.into(), - workspace_id: workspace_id.into(), - }; - let conn = store.connect()?; - migrate(&conn)?; - Ok(store) - } +#[derive(Debug, Clone)] +pub struct ChangeMergeRequestState { + pub ticket_id: String, + pub body: String, + pub auth: MergeRequestAuth, + pub now: DateTime, +} - pub fn open_verified( - db_path: impl Into, - workspace_id: impl Into, - ) -> Result { - let store = Self { - db_path: db_path.into(), - workspace_id: workspace_id.into(), - }; - verify(&store.connect()?)?; - Ok(store) - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CurrentAssignment { + pub assignment_id: String, + pub ticket_id: String, + pub runtime_id: String, + pub worker_id: String, +} - pub fn db_path(&self) -> &Path { - &self.db_path - } - - pub fn workspace_id(&self) -> &str { - &self.workspace_id - } - - fn connect(&self) -> Result { - let conn = Connection::open(&self.db_path).map_err(db)?; - conn.busy_timeout(Duration::from_secs(5)).map_err(db)?; - conn.pragma_update(None, "foreign_keys", "ON").map_err(db)?; - Ok(conn) - } - - fn write(&self, op: impl FnOnce(&Connection) -> Result) -> Result { - let conn = self.connect()?; - verify(&conn)?; - conn.execute_batch("BEGIN IMMEDIATE").map_err(db)?; - match op(&conn) { - Ok(value) => { - conn.execute_batch("COMMIT").map_err(db)?; - Ok(value) - } - Err(error) => { - let _ = conn.execute_batch("ROLLBACK"); - Err(error) - } - } - } - - pub fn show_for_ticket(&self, ticket_id: &str) -> Result> { - self.show_for_ticket_with_target(ticket_id, None) - } - - pub fn show_for_ticket_with_target( +pub trait AssignmentSource: Send + Sync { + fn current_assignment( &self, + workspace_id: &str, ticket_id: &str, - observed_target_commit: Option<&str>, - ) -> Result> { - nonempty("ticket_id", ticket_id)?; - let conn = self.connect()?; - verify(&conn)?; - let mut mr = load_merge_request(&conn, &self.workspace_id, ticket_id)?; - if let Some(mr) = mr.as_mut() { - apply_target_observation(mr, observed_target_commit); - } - Ok(mr) - } + ) -> Result, String>; +} - pub fn readiness_for_ticket(&self, ticket_id: &str) -> Result { - self.readiness_for_ticket_with_target(ticket_id, None) - } - - pub fn readiness_for_ticket_with_target( +pub trait RepositorySource: Send + Sync { + fn repository_belongs_to_workspace( &self, - ticket_id: &str, - observed_target_commit: Option<&str>, - ) -> Result { - let mr = self - .show_for_ticket_with_target(ticket_id, observed_target_commit)? - .ok_or_else(|| MergeRequestError::NotFound(ticket_id.to_string()))?; - let mut blockers = Vec::new(); - if mr.state != MergeRequestState::Open { - blockers.push(format!("merge request is {}", mr.state.as_str())); - } - match mr.review_status { - ReviewStatus::Pending => { - blockers.push("current source revision has no review result".into()) - } - ReviewStatus::ChangesRequested => { - blockers.push("current source revision has request_changes".into()) - } - ReviewStatus::Approved => {} - } - if mr.target_status != MergeRequestTargetStatus::Known || observed_target_commit.is_none() { - blockers.push("merge request target is unknown or could not be resolved".into()); - } - Ok(MergeRequestReadiness { - ticket_id: ticket_id.to_string(), - merge_request_id: mr.merge_request_id, - revision_id: mr.current_revision.revision_id, - target_ref_selector: mr.target_ref_selector, - observed_target_commit: mr.observed_target_commit, - ready: blockers.is_empty(), - review_status: mr.review_status, - blockers, + workspace_id: &str, + repository_id: &str, + ) -> Result; + + fn is_ancestor( + &self, + workspace_id: &str, + repository_id: &str, + ancestor: &str, + descendant: &str, + ) -> Result; +} + +#[derive(Debug, Error)] +pub enum MergeRequestError { + #[error("merge request not found")] + NotFound, + #[error("merge request conflict: {0}")] + Conflict(String), + #[error("merge request unauthorized: {0}")] + Unauthorized(String), + #[error("merge request is not ready: {0}")] + NotReady(String), + #[error("merge request validation failed: {0}")] + Validation(String), + #[error("merge request operation failed: {0}")] + Operation(String), + #[error("merge request storage is corrupt: {0}")] + Corrupt(String), + #[error("merge request storage error: {0}")] + Storage(#[from] rusqlite::Error), +} + +pub struct MergeRequestStore { + conn: Arc>, + assignments: Arc, + repositories: Arc, +} + +impl MergeRequestStore { + pub fn open( + path: impl AsRef, + assignments: Arc, + repositories: Arc, + ) -> Result { + let conn = Connection::open(path)?; + Self::from_connection(conn, assignments, repositories) + } + + pub fn open_in_memory( + assignments: Arc, + repositories: Arc, + ) -> Result { + Self::from_connection(Connection::open_in_memory()?, assignments, repositories) + } + + fn from_connection( + mut conn: Connection, + assignments: Arc, + repositories: Arc, + ) -> Result { + conn.pragma_update(None, "foreign_keys", "ON")?; + migrate(&mut conn)?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + assignments, + repositories, }) } - pub fn open_merge_request(&self, input: OpenMergeRequest) -> Result { - validate_revision(&input.revision)?; - for (name, value) in [ - ("merge_request_id", input.merge_request_id.as_str()), - ("ticket_id", input.ticket_id.as_str()), - ("repository_id", input.repository_id.as_str()), - ("target_ref_selector", input.target_ref_selector.as_str()), - ("runtime_id", input.authenticated_runtime_id.as_str()), - ("worker_id", input.authenticated_worker_id.as_str()), - ] { - nonempty(name, value)?; + pub fn open_merge_request( + &self, + input: OpenMergeRequest, + ) -> Result { + validate_nonempty("merge_request_id", &input.merge_request_id)?; + validate_nonempty("ticket_id", &input.ticket_id)?; + validate_nonempty("selector_from", &input.selector_from)?; + validate_nonempty("selector_to", &input.selector_to)?; + self.validate_auth(&input.auth, &input.ticket_id, &input.repository_id)?; + validate_request(&input.request)?; + + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + if load_merge_request_tx(&tx, &input.auth.workspace_id, &input.ticket_id)?.is_some() { + return Err(MergeRequestError::Conflict( + "an open merge request already exists for this ticket".into(), + )); } - self.write(|conn| { - validate_current_assignment( - conn, - &self.workspace_id, - &input.ticket_id, - &input.revision.assignment_id, - &input.authenticated_runtime_id, - &input.authenticated_worker_id, - )?; - conn.execute( - "INSERT INTO merge_requests (workspace_id, merge_request_id, repository_id, target_ref_selector, target_status, state, lifecycle_generation, current_revision_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 'known', 'open', 1, ?5, ?6, ?6)", - params![self.workspace_id, input.merge_request_id, input.repository_id, input.target_ref_selector, input.revision.revision_id, input.now], - ).map_err(db)?; - conn.execute( - "INSERT INTO merge_request_ticket_relations (workspace_id,merge_request_id,ticket_id,relation_kind,created_at) VALUES (?1,?2,?3,'implements',?4)", - params![self.workspace_id,input.merge_request_id,input.ticket_id,input.now], - ).map_err(db)?; - insert_revision(conn, &self.workspace_id, &input.merge_request_id, &input.revision)?; - load_merge_request(conn, &self.workspace_id, &input.ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone())) - }) + let now = input.now.to_rfc3339(); + tx.execute( + "INSERT INTO merge_requests ( + workspace_id, merge_request_id, ticket_id, repository_id, state, + selector_from, selector_to, opened_by_runtime_id, opened_by_worker_id, + created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?9, ?9)", + params![ + input.auth.workspace_id, + input.merge_request_id, + input.ticket_id, + input.repository_id, + input.selector_from, + input.selector_to, + input.auth.runtime_id, + input.auth.worker_id, + now, + ], + )?; + append_request_event_tx( + &tx, + &input.auth.workspace_id, + &input.merge_request_id, + &input.auth, + &input.request, + input.now, + )?; + tx.commit()?; + drop(conn); + self.get(&input.auth.workspace_id, &input.ticket_id) } - pub fn add_revision(&self, input: AddRevision) -> Result { - validate_revision(&input.revision)?; - self.write(|conn| { - let current = load_merge_request(conn, &self.workspace_id, &input.ticket_id)? - .ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - ensure_open(¤t)?; - if current.current_revision.revision_id != input.expected_current_revision_id { - return Err(MergeRequestError::StaleRevision { - expected: input.expected_current_revision_id.clone(), - current: current.current_revision.revision_id, - }); - } - validate_current_assignment( - conn, - &self.workspace_id, - &input.ticket_id, - &input.revision.assignment_id, - &input.authenticated_runtime_id, - &input.authenticated_worker_id, - )?; - if input.revision.ordinal != current.current_revision.ordinal + 1 { - return Err(MergeRequestError::RevisionConflict(input.revision.revision_id.clone())); - } - let existing: Option<(String, String)> = conn.query_row( - "SELECT base_commit, head_commit FROM merge_request_revisions WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3", - params![self.workspace_id, current.merge_request_id, input.revision.revision_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ).optional().map_err(db)?; - if let Some(existing) = existing { - if existing == (input.revision.base_commit.clone(), input.revision.head_commit.clone()) { - return Ok(current); - } - return Err(MergeRequestError::RevisionConflict(input.revision.revision_id.clone())); - } - insert_revision(conn, &self.workspace_id, ¤t.merge_request_id, &input.revision)?; - conn.execute( - "UPDATE merge_requests SET current_revision_id=?3, updated_at=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND current_revision_id=?5", - params![self.workspace_id, current.merge_request_id, input.revision.revision_id, input.now, input.expected_current_revision_id], - ).map_err(db)?; - load_merge_request(conn, &self.workspace_id, &input.ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone())) + pub fn request_review( + &self, + input: RequestMergeRequestReview, + ) -> Result { + validate_request(&input.request)?; + let current = self.get(&input.auth.workspace_id, &input.ticket_id)?; + self.validate_auth(&input.auth, &input.ticket_id, ¤t.repository_id)?; + ensure_open(¤t)?; + let current_request = current + .thread + .iter() + .rev() + .find_map(|event| match event { + MergeRequestThreadEvent::RequestForReview(value) => Some(value), + _ => None, + }) + .ok_or_else(|| { + MergeRequestError::Corrupt("open merge request has no review request".into()) + })?; + if current_request.head_commit != input.expected_head_commit { + return Err(MergeRequestError::Conflict( + "expected head commit is stale".into(), + )); + } + + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let event = append_request_event_tx( + &tx, + ¤t.workspace_id, + ¤t.merge_request_id, + &input.auth, + &input.request, + input.now, + )?; + tx.commit()?; + Ok(event) + } + + pub fn register_review_capability( + &self, + input: RegisterReviewCapability, + ) -> Result { + let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; + self.validate_auth(&input.auth, &input.ticket_id, &mr.repository_id)?; + ensure_open(&mr)?; + let request = mr.current_request().ok_or_else(|| { + MergeRequestError::Corrupt("open merge request has no review request".into()) + })?; + if request.head_commit != input.expected_head_commit { + return Err(MergeRequestError::Conflict( + "expected head commit is stale".into(), + )); + } + validate_nonempty("capability_token", &input.capability_token)?; + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let child: Option<(String, String, String)> = tx + .query_row( + "SELECT parent_runtime_id, child_session_id, reviewer_profile + FROM merge_request_reviewer_child_sessions + WHERE workspace_id = ?1 AND child_session_id = ?2 + AND parent_runtime_id = ?3 AND parent_worker_id = ?4 + AND status = 'active'", + params![ + input.auth.workspace_id, + input.child_session_id, + input.auth.runtime_id, + input.auth.worker_id + ], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((reviewer_runtime_id, reviewer_worker_id, reviewer_profile)) = child else { + return Err(MergeRequestError::Unauthorized( + "reviewer child session is missing or no longer active".into(), + )); + }; + validate_reviewer_profile(&reviewer_profile)?; + tx.execute( + "INSERT INTO merge_request_review_capabilities ( + workspace_id, merge_request_id, request_event_seq, capability_token, + issued_by_assignment_id, reviewer_runtime_id, reviewer_worker_id, + reviewer_profile, issued_at, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'issued')", + params![ + mr.workspace_id, + mr.merge_request_id, + request.event_seq, + input.capability_token, + input.auth.assignment_id, + reviewer_runtime_id, + reviewer_worker_id, + reviewer_profile, + input.now.to_rfc3339() + ], + )?; + tx.execute( + "UPDATE merge_request_reviewer_child_sessions SET status = 'consumed' + WHERE workspace_id = ?1 AND child_session_id = ?2 AND status = 'active'", + params![input.auth.workspace_id, input.child_session_id], + )?; + tx.commit()?; + Ok(RegisteredReviewCapability { + capability_token: input.capability_token, + request_event_seq: request.event_seq, }) } pub fn register_reviewer_child_session( &self, input: RegisterReviewerChildSession, - ) -> Result<()> { - nonempty("runtime_id", &input.parent_runtime_id)?; - nonempty("worker_id", &input.parent_worker_id)?; - nonempty("child_session_id", &input.child_session_id)?; - self.write(|conn| { - conn.execute( - "INSERT INTO merge_request_reviewer_child_sessions (workspace_id,child_session_id,parent_runtime_id,parent_worker_id,effective_profile,registered_at) VALUES (?1,?2,?3,?4,'builtin:reviewer',?5)", - params![self.workspace_id,input.child_session_id,input.parent_runtime_id,input.parent_worker_id,input.now], - ).map_err(|_| MergeRequestError::InvalidReviewer)?; - Ok(()) - }) + ) -> Result<(), MergeRequestError> { + validate_reviewer_profile(&input.reviewer_profile)?; + let conn = self.lock_conn()?; + conn.execute( + "INSERT INTO merge_request_reviewer_child_sessions ( + workspace_id, child_session_id, parent_runtime_id, parent_worker_id, + reviewer_profile, registered_at, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active')", + params![ + input.workspace_id, + input.child_session_id, + input.parent_runtime_id, + input.parent_worker_id, + input.reviewer_profile, + input.now.to_rfc3339() + ], + )?; + Ok(()) } - pub fn register_review_attempt(&self, input: RegisterReviewAttempt) -> Result<()> { - for (name, value) in [ - ("attempt_id", input.attempt_id.as_str()), - ("capability_token", input.capability_token.as_str()), - ("child_session_id", input.child_session_id.as_str()), - ] { - nonempty(name, value)?; - } - if input.child_session_id == input.parent_worker_id { - return Err(MergeRequestError::SelfApproval); - } - self.write(|conn| { - let mr = load_merge_request(conn, &self.workspace_id, &input.ticket_id)? - .ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - ensure_open(&mr)?; - if mr.current_revision.revision_id != input.revision_id { - return Err(MergeRequestError::StaleRevision { expected: input.revision_id.clone(), current: mr.current_revision.revision_id }); - } - validate_current_assignment(conn, &self.workspace_id, &input.ticket_id, &input.parent_assignment_id, &input.parent_runtime_id, &input.parent_worker_id)?; - let effective_profile: Option = conn.query_row( - "SELECT effective_profile FROM merge_request_reviewer_child_sessions WHERE workspace_id=?1 AND child_session_id=?2 AND parent_runtime_id=?3 AND parent_worker_id=?4", - params![self.workspace_id,input.child_session_id,input.parent_runtime_id,input.parent_worker_id], - |row| row.get(0), - ).optional().map_err(db)?; - if effective_profile.as_deref() != Some(REVIEWER_PROFILE) { - return Err(MergeRequestError::InvalidReviewer); - } - conn.execute( - "INSERT INTO merge_request_review_attempts (workspace_id, attempt_id, merge_request_id, ticket_id, revision_id, lifecycle_generation, parent_assignment_id, parent_runtime_id, parent_worker_id, child_session_id, child_effective_profile, capability_token_sha256, status, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,'open',?13)", - params![self.workspace_id, input.attempt_id, mr.merge_request_id, input.ticket_id, input.revision_id, mr.lifecycle_generation as i64, input.parent_assignment_id, input.parent_runtime_id, input.parent_worker_id, input.child_session_id, REVIEWER_PROFILE, token_hash(&input.capability_token), input.now], - ).map_err(|_| MergeRequestError::InvalidReviewAttempt)?; - Ok(()) - }) - } - - pub fn revoke_review_attempt( + pub fn submit_review( &self, - attempt_id: &str, - child_session_id: &str, - now: &str, - ) -> Result { - self.write(|conn| { - let changed = conn.execute( - "UPDATE merge_request_review_attempts SET status='revoked', consumed_at=?4 WHERE workspace_id=?1 AND attempt_id=?2 AND child_session_id=?3 AND status='open'", - params![self.workspace_id, attempt_id, child_session_id, now], - ).map_err(db)?; - Ok(changed == 1) - }) - } - - pub fn submit_review(&self, input: SubmitReview) -> Result { - nonempty("capability_token", &input.capability_token)?; - validate_review_input(&input)?; - self.write(|conn| { - let token = token_hash(&input.capability_token); - let attempt: Option<(String,String,String,String,String,String,String,String,i64)> = conn.query_row( - "SELECT attempt_id, merge_request_id, parent_assignment_id, parent_runtime_id, parent_worker_id, child_session_id, child_effective_profile, status, lifecycle_generation FROM merge_request_review_attempts WHERE workspace_id=?1 AND ticket_id=?2 AND revision_id=?3 AND capability_token_sha256=?4", - params![self.workspace_id, input.ticket_id, input.revision_id, token], - |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?,row.get(5)?,row.get(6)?,row.get(7)?,row.get(8)?)), - ).optional().map_err(db)?; - let Some((attempt_id, mr_id, assignment_id, runtime_id, worker_id, child_session_id, effective_profile, status, lifecycle_generation)) = attempt else { - return Err(MergeRequestError::InvalidReviewAttempt); - }; - if status != "open" || effective_profile != REVIEWER_PROFILE || child_session_id == worker_id { - return Err(MergeRequestError::InvalidReviewAttempt); - } - let mr = load_merge_request(conn, &self.workspace_id, &input.ticket_id)? - .ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - if lifecycle_generation != mr.lifecycle_generation as i64 { - return Err(MergeRequestError::InvalidReviewAttempt); - } - ensure_open(&mr)?; - if mr.current_revision.revision_id != input.revision_id { - return Err(MergeRequestError::StaleRevision { expected: input.revision_id.clone(), current: mr.current_revision.revision_id }); - } - validate_current_assignment(conn, &self.workspace_id, &input.ticket_id, &assignment_id, &runtime_id, &worker_id)?; - conn.execute( - "INSERT INTO merge_request_reviews (workspace_id, attempt_id, merge_request_id, revision_id, decision, body, submitted_at) VALUES (?1,?2,?3,?4,?5,?6,?7)", - params![self.workspace_id, attempt_id, mr_id, input.revision_id, input.decision.as_str(), input.body, input.now], - ).map_err(|_| MergeRequestError::InvalidReviewAttempt)?; - for (ordinal, finding) in input.findings.iter().enumerate() { - nonempty("finding.body", &finding.body)?; - conn.execute( - "INSERT INTO merge_request_review_findings (workspace_id, attempt_id, ordinal, severity, code, path, line, body) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", - params![self.workspace_id, attempt_id, ordinal as i64, finding.severity, finding.code, finding.path, finding.line.map(|v| v as i64), finding.body], - ).map_err(db)?; - } - conn.execute( - "UPDATE merge_request_review_attempts SET status='submitted', consumed_at=?3 WHERE workspace_id=?1 AND attempt_id=?2 AND status='open'", - params![self.workspace_id, attempt_id, input.now], - ).map_err(db)?; - load_review(conn, &self.workspace_id, &attempt_id)?.ok_or(MergeRequestError::InvalidReviewAttempt) - }) - } - - pub fn complete(&self, input: CompleteMergeRequest) -> Result { - for (name, value) in [ - ("operation_id", input.operation_id.as_str()), - ("ticket_id", input.ticket_id.as_str()), - ("revision_id", input.expected_revision_id.as_str()), - ("target_commit", input.target_commit.as_str()), - ("source_commit", input.source_commit.as_str()), - ("result_commit", input.result_commit.as_str()), - ( - "implementation_assignment_id", - input.implementation_assignment_id.as_str(), - ), - ( - "completion_actor_runtime_id", - input.completion_actor_runtime_id.as_str(), - ), - ( - "completion_actor_worker_id", - input.completion_actor_worker_id.as_str(), - ), - ] { - nonempty(name, value)?; + input: SubmitMergeRequestReview, + ) -> Result { + if input.body.trim().is_empty() { + return Err(MergeRequestError::Validation( + "review body must not be empty".into(), + )); } - validate_completion_outcome(&input)?; - let fingerprint = completion_fingerprint(&input); - self.write(|conn| { - if let Some((stored, status, state)) = conn.query_row( - "SELECT fingerprint, status, result_ticket_state FROM merge_request_completion_operations WHERE workspace_id=?1 AND operation_id=?2", - params![self.workspace_id, input.operation_id], - |row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?, row.get::<_,Option>(2)?)), - ).optional().map_err(db)? { - if stored != fingerprint { return Err(MergeRequestError::OperationConflict); } - if status == "completed" { - return Ok(CompletionOutcome { operation_id: input.operation_id.clone(), ticket_id: input.ticket_id.clone(), revision_id: input.expected_revision_id.clone(), ticket_state: state.unwrap_or_else(|| "done".into()), replayed: true }); + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let capability: Option<(String, String, i64, String)> = tx + .query_row( + "SELECT c.workspace_id, c.merge_request_id, c.request_event_seq, c.status + FROM merge_request_review_capabilities c + JOIN merge_requests m + ON m.workspace_id = c.workspace_id + AND m.merge_request_id = c.merge_request_id + WHERE c.capability_token = ?1 AND m.ticket_id = ?2", + params![input.capability_token, input.ticket_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + let Some((workspace_id, merge_request_id, request_event_seq, status)) = capability else { + return Err(MergeRequestError::Unauthorized( + "review capability is invalid".into(), + )); + }; + if status != "issued" { + return Err(MergeRequestError::Conflict( + "review capability has already been consumed".into(), + )); + } + let reviewer: Option<(String, String, String)> = tx + .query_row( + "SELECT reviewer_runtime_id, reviewer_worker_id, reviewer_profile + FROM merge_request_review_capabilities + WHERE capability_token = ?1 AND status = 'issued'", + params![input.capability_token], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((reviewer_runtime_id, reviewer_worker_id, reviewer_profile)) = reviewer else { + return Err(MergeRequestError::Unauthorized( + "review source is not the registered reviewer child".into(), + )); + }; + validate_reviewer_profile(&reviewer_profile)?; + let request_payload: String = tx.query_row( + "SELECT payload_json FROM merge_request_thread_events + WHERE workspace_id = ?1 AND merge_request_id = ?2 + AND event_seq = ?3 AND kind = 'request_for_review'", + params![workspace_id, merge_request_id, request_event_seq], + |row| row.get(0), + )?; + let request: RequestForReviewEvent = decode_payload(&request_payload)?; + if request.head_commit != input.expected_head_commit { + return Err(MergeRequestError::Conflict( + "expected head commit is stale".into(), + )); + } + let latest_request_seq: i64 = tx.query_row( + "SELECT MAX(event_seq) FROM merge_request_thread_events + WHERE workspace_id = ?1 AND merge_request_id = ?2 AND kind = 'request_for_review'", + params![workspace_id, merge_request_id], + |row| row.get(0), + )?; + if latest_request_seq != request_event_seq { + return Err(MergeRequestError::Conflict( + "review request has been superseded".into(), + )); + } + let event_seq = next_event_seq_tx(&tx, &workspace_id, &merge_request_id)?; + let event = ReviewEvent { + event_seq, + request_event_seq: request_event_seq as u64, + head_commit: request.head_commit, + reviewer_worker: WorkerIdentity { + runtime_id: reviewer_runtime_id, + worker_id: reviewer_worker_id, + }, + reviewer_profile, + decision: input.decision, + body: input.body, + findings: input.findings, + created_at: input.now, + }; + insert_event_tx( + &tx, + &workspace_id, + &merge_request_id, + "review", + &event, + input.now, + None, + )?; + tx.execute( + "UPDATE merge_request_review_capabilities SET status = 'consumed', consumed_at = ?2 + WHERE capability_token = ?1 AND status = 'issued'", + params![input.capability_token, input.now.to_rfc3339()], + )?; + tx.commit()?; + Ok(event) + } + + pub fn readiness(&self, input: ReadinessCheck) -> Result { + let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; + if mr.repository_id != input.auth.repository_id { + return Err(MergeRequestError::Unauthorized( + "repository does not match merge request".into(), + )); + } + let request = mr.current_request().cloned(); + let review = mr.current_review().cloned(); + let mut blockers = Vec::new(); + if mr.state != MergeRequestState::Open { + blockers.push("merge request is not open".into()); + } + match &request { + None => blockers.push("merge request has no review request".into()), + Some(value) => { + if input + .expected_head_commit + .as_deref() + .is_some_and(|expected| expected != value.head_commit) + { + blockers.push("expected head commit is stale".into()); + } + match &review { + None => blockers.push("current review request has no review result".into()), + Some(review) if review.decision != ReviewDecision::Approve => { + blockers.push("current review requests changes".into()) + } + Some(review) if review.head_commit != value.head_commit => blockers + .push("current review does not match the requested head commit".into()), + Some(_) => {} } - } else { - conn.execute( - "INSERT INTO merge_request_completion_operations (workspace_id, operation_id, ticket_id, revision_id, authority_kind, implementation_assignment_id, completion_actor_runtime_id, completion_actor_worker_id, target_commit, source_commit, result_commit, strategy, resolution, fingerprint, status, created_at, updated_at) VALUES (?1,?2,?3,?4,'workspace_orchestrator',?5,?6,?7,?8,?9,?10,?11,?12,?13,'pending',?14,?14)", - params![self.workspace_id, input.operation_id, input.ticket_id, input.expected_revision_id, input.implementation_assignment_id, input.completion_actor_runtime_id, input.completion_actor_worker_id, input.target_commit, input.source_commit, input.result_commit, input.strategy.as_str(), input.resolution.as_str(), fingerprint, input.now], - ).map_err(db)?; } - let mr = load_merge_request(conn, &self.workspace_id, &input.ticket_id)? - .ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - validate_current_implementation_assignment(conn, &self.workspace_id, &input.ticket_id, &input.implementation_assignment_id)?; - ensure_open(&mr)?; - if mr.current_revision.revision_id != input.expected_revision_id { - return Err(MergeRequestError::StaleRevision { expected: input.expected_revision_id.clone(), current: mr.current_revision.revision_id }); - } - if mr.current_revision.head_commit != input.source_commit { - return Err(MergeRequestError::InvalidMergeOutcome("source commit does not match the current approved revision".into())); - } - if mr.review_status != ReviewStatus::Approved { return Err(MergeRequestError::NotApproved); } - let current_state: String = conn.query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", - params![self.workspace_id, input.ticket_id], |row| row.get(0), - ).optional().map_err(db)?.ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - if current_state != "inprogress" { return Err(MergeRequestError::TicketStateConflict(current_state)); } - let changed = conn.execute( - "UPDATE typed_tickets SET workflow_state='done', workflow_state_explicit=1, updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'", - params![self.workspace_id, input.ticket_id, input.now], - ).map_err(db)?; - if changed != 1 { return Err(MergeRequestError::TicketStateConflict("concurrent_change".into())); } - let merged = conn.execute( - "UPDATE merge_requests SET state='merged', merged_revision_id=?3, merged_target_commit=?4, merged_result_commit=?5, merge_strategy=?6, merge_resolution=?7, merged_by_runtime_id=?8, merged_by_worker_id=?9, merged_at=?10, updated_at=?10 WHERE workspace_id=?1 AND merge_request_id=?2 AND state='open' AND current_revision_id=?3", - params![self.workspace_id, mr.merge_request_id, input.expected_revision_id, input.target_commit, input.result_commit, input.strategy.as_str(), input.resolution.as_str(), input.completion_actor_runtime_id, input.completion_actor_worker_id, input.now], - ).map_err(db)?; - if merged != 1 { return Err(MergeRequestError::OperationConflict); } - append_completion_event(conn, &self.workspace_id, &input)?; - conn.execute( - "UPDATE merge_request_completion_operations SET status='completed', result_ticket_state='done', updated_at=?3 WHERE workspace_id=?1 AND operation_id=?2 AND status='pending'", - params![self.workspace_id, input.operation_id, input.now], - ).map_err(db)?; - Ok(CompletionOutcome { operation_id: input.operation_id, ticket_id: input.ticket_id, revision_id: input.expected_revision_id, ticket_state: "done".into(), replayed: false }) + } + Ok(ReadinessReport { + ready: blockers.is_empty(), + blockers, + request, + review, }) } - pub fn close( - &self, - ticket_id: &str, - expected_revision_id: &str, - now: &str, - ) -> Result { - self.transition_open(ticket_id, expected_revision_id, "closed", now) + pub fn complete(&self, input: CompleteMergeRequest) -> Result { + validate_nonempty("operation_id", &input.operation_id)?; + let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; + self.validate_completion_auth(&input.auth, &input.ticket_id, &mr.repository_id)?; + + if let Some(existing) = mr.thread.iter().find_map(|event| match event { + MergeRequestThreadEvent::Merge(value) if value.operation_id == input.operation_id => { + Some(value) + } + _ => None, + }) { + if merge_matches(existing, &input) { + return Ok(existing.clone()); + } + return Err(MergeRequestError::Conflict( + "operation id was already used with a different completion fingerprint".into(), + )); + } + + let readiness = self.readiness(ReadinessCheck { + ticket_id: input.ticket_id.clone(), + expected_head_commit: Some(input.expected_head_commit.clone()), + auth: input.auth.clone(), + })?; + if !readiness.ready { + return Err(MergeRequestError::NotReady(readiness.blockers.join("; "))); + } + let request = readiness.request.expect("ready request"); + if input.source_commit != request.head_commit + || input.expected_head_commit != request.head_commit + { + return Err(MergeRequestError::Conflict( + "completion source does not match current review request".into(), + )); + } + validate_completion_shape(&input, &request, self.repositories.as_ref(), &mr)?; + + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let current_ticket_state: Option = tx + .query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2", + params![mr.workspace_id, input.ticket_id], + |row| row.get(0), + ) + .optional()?; + match current_ticket_state.as_deref() { + Some("inprogress") => {} + Some(other) => { + return Err(MergeRequestError::Conflict(format!( + "Ticket state `{other}` cannot be completed" + ))); + } + None => return Err(MergeRequestError::NotFound), + } + let changed = tx.execute( + "UPDATE typed_tickets + SET workflow_state = 'done', workflow_state_explicit = 1, updated_at = ?3 + WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'inprogress'", + params![mr.workspace_id, input.ticket_id, input.now.to_rfc3339()], + )?; + if changed != 1 { + return Err(MergeRequestError::Conflict( + "Ticket state changed concurrently".into(), + )); + } + let implementation_assignment_id = input.auth.assignment_id.clone(); + let event_seq = next_event_seq_tx(&tx, &mr.workspace_id, &mr.merge_request_id)?; + let event = MergeEvent { + event_seq, + operation_id: input.operation_id, + target_commit: input.target_commit, + source_commit: input.source_commit, + result_commit: input.result_commit, + strategy: input.strategy, + resolution: input.resolution, + merged_by: WorkerIdentity { + runtime_id: input.auth.runtime_id, + worker_id: input.auth.worker_id, + }, + created_at: input.now, + }; + insert_event_tx( + &tx, + &mr.workspace_id, + &mr.merge_request_id, + "merge", + &event, + input.now, + Some(&event.operation_id), + )?; + tx.execute( + "UPDATE merge_requests SET state = 'merged', updated_at = ?3 + WHERE workspace_id = ?1 AND merge_request_id = ?2 AND state = 'open'", + params![mr.workspace_id, mr.merge_request_id, input.now.to_rfc3339()], + )?; + append_ticket_completion_event_tx(&tx, &mr, &event, &implementation_assignment_id)?; + tx.commit()?; + Ok(event) + } + + pub fn close(&self, input: ChangeMergeRequestState) -> Result { + self.change_state(input, MergeRequestState::Closed, "close") } pub fn reopen( &self, - ticket_id: &str, - expected_revision_id: &str, - now: &str, - ) -> Result { - self.write(|conn| { - let mr = load_merge_request(conn, &self.workspace_id, ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(ticket_id.into()))?; - if mr.state != MergeRequestState::Closed { return Err(MergeRequestError::NotOpen(mr.state.as_str().into())); } - if mr.current_revision.revision_id != expected_revision_id { return Err(MergeRequestError::StaleRevision { expected: expected_revision_id.into(), current: mr.current_revision.revision_id }); } - conn.execute("UPDATE merge_requests SET state='open', lifecycle_generation=lifecycle_generation+1, updated_at=?3 WHERE workspace_id=?1 AND merge_request_id=?2 AND state='closed'", params![self.workspace_id, mr.merge_request_id, now]).map_err(db)?; - load_merge_request(conn, &self.workspace_id, ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(ticket_id.into())) - }) + input: ChangeMergeRequestState, + ) -> Result { + self.change_state(input, MergeRequestState::Open, "reopen") } - fn transition_open( + fn change_state( &self, - ticket_id: &str, - expected_revision_id: &str, - state: &str, - now: &str, - ) -> Result { - self.write(|conn| { - let mr = load_merge_request(conn, &self.workspace_id, ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(ticket_id.into()))?; - ensure_open(&mr)?; - if mr.current_revision.revision_id != expected_revision_id { return Err(MergeRequestError::StaleRevision { expected: expected_revision_id.into(), current: mr.current_revision.revision_id }); } - conn.execute("UPDATE merge_requests SET state=?3, updated_at=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND state='open'", params![self.workspace_id, mr.merge_request_id, state, now]).map_err(db)?; - load_merge_request(conn, &self.workspace_id, ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(ticket_id.into())) - }) - } -} - -pub fn migrate(conn: &Connection) -> Result<()> { - migrate_with_failpoint(conn, false) -} - -fn migrate_with_failpoint( - conn: &Connection, - force_failure_after_migration_ddl: bool, -) -> Result<()> { - let original_foreign_keys: i64 = conn - .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) - .map_err(db)?; - conn.pragma_update(None, "foreign_keys", "OFF") - .map_err(db)?; - - let transaction_result = (|| { - conn.execute_batch("BEGIN IMMEDIATE").map_err(db)?; - let result = migrate_locked(conn, force_failure_after_migration_ddl); - match result { - Ok(()) => { - if let Err(error) = conn.execute_batch("COMMIT").map_err(db) { - let _ = conn.execute_batch("ROLLBACK"); - Err(error) - } else { - Ok(()) - } - } - Err(error) => { - let _ = conn.execute_batch("ROLLBACK"); - Err(error) + input: ChangeMergeRequestState, + target: MergeRequestState, + kind: &str, + ) -> Result { + let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; + self.validate_lifecycle_auth(&input.auth, &mr.repository_id)?; + match (mr.state, target) { + (MergeRequestState::Open, MergeRequestState::Closed) + | (MergeRequestState::Closed, MergeRequestState::Open) + | (MergeRequestState::Merged, MergeRequestState::Open) => {} + _ => { + return Err(MergeRequestError::Conflict( + "illegal merge request state transition".into(), + )); } } - })(); - - let restore_result = conn - .pragma_update(None, "foreign_keys", original_foreign_keys) - .map_err(db); - transaction_result?; - restore_result?; - verify(conn) -} - -fn migrate_locked(conn: &Connection, force_failure_after_migration_ddl: bool) -> Result<()> { - let marker_exists = table_exists(conn, MIGRATION_TABLE)?; - if !marker_exists { - if has_merge_request_domain_tables(conn)? { - return Err(MergeRequestError::Database( - "unsupported unversioned legacy merge request schema; automatic migration requires a fresh database or exact version 8" - .into(), - )); - } - conn.execute_batch(MIGRATION_TABLE_SQL).map_err(db)?; - conn.execute_batch(SCHEMA_V11).map_err(db)?; - verify_schema_shape(conn, SCHEMA_V11, "v11")?; - ensure_foreign_key_integrity(conn)?; - replace_schema_marker(conn, SCHEMA_VERSION)?; - return verify(conn); - } - - let version = schema_version(conn)?; - match version { - SCHEMA_VERSION => { - verify_marker_state(conn, SCHEMA_VERSION)?; - verify(conn) - } - 10 => { - verify_marker_state(conn, 10)?; - if verify_schema_shape(conn, SCHEMA_V11, "v11").is_ok() { - return finish_v11_schema(conn); - } - verify_schema_shape(conn, SCHEMA_V10, "v10").map_err(|_| { - MergeRequestError::Database( - "schema drift at merge request version 10; automatic migration requires the exact released v10 shape or a complete v11 shape for marker repair" - .into(), - ) - })?; - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 10)?; - finish_v11_schema(conn) - } - 9 => { - verify_marker_table_shape(conn)?; - remove_empty_v9_migration_debris(conn)?; - if verify_schema_shape(conn, SCHEMA_V11, "v11").is_ok() { - return finish_v11_schema(conn); - } - if verify_schema_shape(conn, SCHEMA_V10, "v10").is_ok() { - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 9)?; - return finish_v11_schema(conn); - } - verify_schema_shape(conn, SCHEMA_V9, "v9").map_err(|_| { - MergeRequestError::Database( - "schema drift at merge request version 9; automatic migration requires the exact released v9 shape, a complete v10 shape, or a complete v11 shape for marker repair" - .into(), - ) - })?; - migrate_v9_to_v10(conn)?; - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 9)?; - finish_v11_schema(conn) - } - 8 => { - verify_marker_state(conn, 8)?; - if verify_schema_shape(conn, SCHEMA_V11, "v11").is_ok() { - return finish_v11_schema(conn); - } - if verify_schema_shape(conn, SCHEMA_V10, "v10").is_ok() { - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 8)?; - return finish_v11_schema(conn); - } - verify_schema_shape(conn, SCHEMA_V8, "v8").map_err(|_| { - MergeRequestError::Database( - "schema drift at merge request version 8; automatic migration requires the exact v8 shape, a complete v10 shape, or a complete v11 shape for marker repair" - .into(), - ) - })?; - migrate_v8_to_v10(conn)?; - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 8)?; - finish_v11_schema(conn) - } - 0..=7 => Err(MergeRequestError::Database(format!( - "unsupported legacy merge request schema version {version}; automatic migration only supports exact v8, v9, or v10 to v11" - ))), - other => Err(MergeRequestError::Database(format!( - "unsupported merge request schema version {other}; expected version 8, 9, 10, or {SCHEMA_VERSION}" - ))), - } -} - -fn fail_after_migration_if_requested(force_failure: bool, source_version: i64) -> Result<()> { - if force_failure { - return Err(MergeRequestError::Database(format!( - "forced v{source_version} to v11 migration failure after DDL and data copy" - ))); - } - Ok(()) -} - -fn finish_v11_schema(conn: &Connection) -> Result<()> { - verify_schema_shape(conn, SCHEMA_V11, "v11")?; - ensure_foreign_key_integrity(conn)?; - replace_schema_marker(conn, SCHEMA_VERSION)?; - verify(conn) -} - -fn remove_empty_v9_migration_debris(conn: &Connection) -> Result<()> { - const OBSOLETE_TABLE: &str = "merge_request_ticket_links"; - if !table_exists(conn, OBSOLETE_TABLE)? { - return Ok(()); - } - let row_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM merge_request_ticket_links", - [], - |row| row.get(0), - ) - .map_err(db)?; - if row_count != 0 { - return Err(MergeRequestError::Database( - "cannot remove non-empty obsolete v9 merge_request_ticket_links table".into(), - )); - } - conn.execute("DROP TABLE merge_request_ticket_links", []) - .map_err(db)?; - Ok(()) -} - -struct LegacyV9CompletedOutcome { - workspace_id: String, - operation_id: String, - merge_request_id: String, - ticket_id: String, - revision_id: String, - implementation_assignment_id: String, - completion_actor_runtime_id: String, - completion_actor_worker_id: String, - target_commit: String, - source_commit: String, - result_commit: String, - strategy: String, - resolution: String, - completed_at: String, -} - -fn migrate_v9_to_v10(conn: &Connection) -> Result<()> { - let result_review_count: i64 = conn - .query_row( - "SELECT - (SELECT COUNT(*) FROM merge_request_review_attempts WHERE merge_result_id IS NOT NULL) + - (SELECT COUNT(*) FROM merge_request_reviews WHERE merge_result_id IS NOT NULL)", - [], - |row| row.get(0), - ) - .map_err(db)?; - if result_review_count != 0 { - return Err(MergeRequestError::Database( - "cannot automatically migrate v9 merge-result-specific reviews to v10".into(), - )); - } - let pending_operation_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM merge_request_completion_operations WHERE status <> 'completed'", - [], - |row| row.get(0), - ) - .map_err(db)?; - if pending_operation_count != 0 { - return Err(MergeRequestError::Database( - "cannot automatically migrate pending v9 completion operations to v10".into(), - )); - } - let invalid_completed_operation_count: i64 = conn - .query_row( - "SELECT COUNT(*) - FROM merge_request_completion_operations o - WHERE o.status='completed' - AND ( - o.completion_actor_runtime_id IS NULL OR trim(o.completion_actor_runtime_id)='' OR - o.completion_actor_worker_id IS NULL OR trim(o.completion_actor_worker_id)='' OR - (SELECT COUNT(*) - FROM merge_request_merge_results r - JOIN merge_requests mr - ON mr.workspace_id=r.workspace_id - AND mr.merge_request_id=r.merge_request_id - WHERE r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - AND mr.current_revision_id=o.revision_id) <> 1 - )", - [], - |row| row.get(0), - ) - .map_err(db)?; - if invalid_completed_operation_count != 0 { - return Err(MergeRequestError::Database( - "cannot uniquely map completed v9 operations to their current merge result".into(), - )); - } - let duplicate_merge_request_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM ( - SELECT r.workspace_id,r.merge_request_id - FROM merge_request_completion_operations o - JOIN merge_request_merge_results r - ON r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - WHERE o.status='completed' - GROUP BY r.workspace_id,r.merge_request_id - HAVING COUNT(*) <> 1 - )", - [], - |row| row.get(0), - ) - .map_err(db)?; - if duplicate_merge_request_count != 0 { - return Err(MergeRequestError::Database( - "cannot choose one completed v9 outcome for a merge request".into(), - )); - } - let unmapped_merged_request_count: i64 = conn - .query_row( - "SELECT COUNT(*) - FROM merge_requests mr - WHERE mr.state='merged' - AND NOT EXISTS ( - SELECT 1 - FROM merge_request_completion_operations o - JOIN merge_request_merge_results r - ON r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - WHERE o.status='completed' - AND r.workspace_id=mr.workspace_id - AND r.merge_request_id=mr.merge_request_id - )", - [], - |row| row.get(0), - ) - .map_err(db)?; - if unmapped_merged_request_count != 0 { - return Err(MergeRequestError::Database( - "cannot reconstruct final outcome for a merged v9 merge request".into(), - )); - } - let completed_outcomes = { - let mut statement = conn - .prepare( - "SELECT o.workspace_id,o.operation_id,r.merge_request_id,o.ticket_id,o.revision_id, - o.implementation_assignment_id,o.completion_actor_runtime_id,o.completion_actor_worker_id, - r.target_commit,r.source_commit,r.result_commit,r.strategy,r.resolution,o.updated_at - FROM merge_request_completion_operations o - JOIN merge_request_merge_results r - ON r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - WHERE o.status='completed' - ORDER BY o.workspace_id,o.operation_id", - ) - .map_err(db)?; - statement - .query_map([], |row| { - Ok(LegacyV9CompletedOutcome { - workspace_id: row.get(0)?, - operation_id: row.get(1)?, - merge_request_id: row.get(2)?, - ticket_id: row.get(3)?, - revision_id: row.get(4)?, - implementation_assignment_id: row.get(5)?, - completion_actor_runtime_id: row.get(6)?, - completion_actor_worker_id: row.get(7)?, - target_commit: row.get(8)?, - source_commit: row.get(9)?, - result_commit: row.get(10)?, - strategy: row.get(11)?, - resolution: row.get(12)?, - completed_at: row.get(13)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)? - }; - conn.execute_batch( - "CREATE TABLE merge_requests_v10 ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - target_ref_selector TEXT, - target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), - merged_revision_id TEXT, merged_target_commit TEXT, merged_result_commit TEXT, - merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')), - merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')), - merged_by_runtime_id TEXT, merged_by_worker_id TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) - ); - INSERT INTO merge_requests_v10( - workspace_id,merge_request_id,repository_id,state,lifecycle_generation,current_revision_id, - created_at,updated_at,merged_by_account_id,merged_at,target_ref_selector,target_status - ) SELECT workspace_id,merge_request_id,repository_id,state,lifecycle_generation,current_revision_id, - created_at,updated_at,merged_by_account_id,merged_at,target_ref_selector,target_status - FROM merge_requests; - DROP TABLE merge_requests; - ALTER TABLE merge_requests_v10 RENAME TO merge_requests; - - CREATE TABLE merge_request_review_attempts_v10 ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), - FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) - ); - INSERT INTO merge_request_review_attempts_v10( - workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, - parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, - capability_token_sha256,status,created_at,consumed_at - ) SELECT workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, - parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, - capability_token_sha256,status,created_at,consumed_at - FROM merge_request_review_attempts; - DROP TABLE merge_request_review_attempts; - ALTER TABLE merge_request_review_attempts_v10 RENAME TO merge_request_review_attempts; - - CREATE TABLE merge_request_reviews_v10 ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) - ); - INSERT INTO merge_request_reviews_v10( - workspace_id,attempt_id,merge_request_id,revision_id,decision,body,submitted_at - ) SELECT workspace_id,attempt_id,merge_request_id,revision_id,decision,body,submitted_at - FROM merge_request_reviews; - DROP TABLE merge_request_reviews; - ALTER TABLE merge_request_reviews_v10 RENAME TO merge_request_reviews; - - CREATE TABLE merge_request_completion_operations_v10 ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - target_commit TEXT, source_commit TEXT, result_commit TEXT, - strategy TEXT CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), - FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) - REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) - ); - INSERT INTO merge_request_completion_operations_v10( - workspace_id,operation_id,ticket_id,revision_id,authority_kind,implementation_assignment_id, - completion_actor_runtime_id,completion_actor_worker_id,target_commit,source_commit,result_commit, - strategy,resolution,fingerprint,status,result_ticket_state,created_at,updated_at - ) SELECT o.workspace_id,o.operation_id,o.ticket_id,o.revision_id,o.authority_kind,o.implementation_assignment_id, - o.completion_actor_runtime_id,o.completion_actor_worker_id, - r.target_commit,r.source_commit,r.result_commit,r.strategy,r.resolution, - o.fingerprint,o.status,o.result_ticket_state,o.created_at,o.updated_at - FROM merge_request_completion_operations o - JOIN merge_request_merge_results r - ON r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - WHERE o.status='completed'; - DROP TABLE merge_request_completion_operations; - ALTER TABLE merge_request_completion_operations_v10 RENAME TO merge_request_completion_operations; - - DROP TABLE merge_request_merge_results;", - ) - .map_err(db)?; - for outcome in completed_outcomes { - let fingerprint = completion_fingerprint_parts( - &outcome.ticket_id, - &outcome.revision_id, - &outcome.target_commit, - &outcome.source_commit, - &outcome.result_commit, - &outcome.strategy, - &outcome.resolution, - &outcome.implementation_assignment_id, - &outcome.completion_actor_runtime_id, - &outcome.completion_actor_worker_id, - ); - let changed = conn - .execute( - "UPDATE merge_requests - SET state='merged',merged_revision_id=?3,merged_target_commit=?4, - merged_result_commit=?5,merge_strategy=?6,merge_resolution=?7, - merged_by_runtime_id=?8,merged_by_worker_id=?9,merged_at=?10,updated_at=?10 - WHERE workspace_id=?1 AND merge_request_id=?2", - params![ - outcome.workspace_id, - outcome.merge_request_id, - outcome.revision_id, - outcome.target_commit, - outcome.result_commit, - outcome.strategy, - outcome.resolution, - outcome.completion_actor_runtime_id, - outcome.completion_actor_worker_id, - outcome.completed_at, - ], - ) - .map_err(db)?; - if changed != 1 { - return Err(MergeRequestError::Database( - "completed v9 outcome lost its merge request during migration".into(), - )); - } - let changed = conn - .execute( - "UPDATE merge_request_completion_operations - SET fingerprint=?3 - WHERE workspace_id=?1 AND operation_id=?2", - params![outcome.workspace_id, outcome.operation_id, fingerprint], - ) - .map_err(db)?; - if changed != 1 { - return Err(MergeRequestError::Database( - "completed v9 operation was not copied during migration".into(), - )); - } - } - Ok(()) -} - -fn migrate_v8_to_v10(conn: &Connection) -> Result<()> { - conn.execute_batch( - "ALTER TABLE merge_requests ADD COLUMN target_ref_selector TEXT; - ALTER TABLE merge_requests ADD COLUMN target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')); - ALTER TABLE merge_requests ADD COLUMN merged_revision_id TEXT; - ALTER TABLE merge_requests ADD COLUMN merged_target_commit TEXT; - ALTER TABLE merge_requests ADD COLUMN merged_result_commit TEXT; - ALTER TABLE merge_requests ADD COLUMN merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')); - ALTER TABLE merge_requests ADD COLUMN merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')); - ALTER TABLE merge_requests ADD COLUMN merged_by_runtime_id TEXT; - ALTER TABLE merge_requests ADD COLUMN merged_by_worker_id TEXT; - - CREATE TABLE merge_request_revisions_v9 ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - diff_digest TEXT NOT NULL, summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), - UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE - ); - INSERT INTO merge_request_revisions_v9( - workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,diff_digest,summary,assignment_id,created_at - ) SELECT workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,diff_digest,summary,assignment_id,created_at - FROM merge_request_revisions; - DROP TABLE merge_request_revisions; - ALTER TABLE merge_request_revisions_v9 RENAME TO merge_request_revisions; - - CREATE TABLE merge_request_review_attempts_v9 ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), - FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) - ); - INSERT INTO merge_request_review_attempts_v9( - workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, - parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, - capability_token_sha256,status,created_at,consumed_at - ) SELECT workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, - parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, - capability_token_sha256,status,created_at,consumed_at - FROM merge_request_review_attempts; - DROP TABLE merge_request_review_attempts; - ALTER TABLE merge_request_review_attempts_v9 RENAME TO merge_request_review_attempts; - - CREATE TABLE merge_request_completion_operations_v9 ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - target_commit TEXT, source_commit TEXT, result_commit TEXT, - strategy TEXT CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), - FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) - REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) - ); - INSERT INTO merge_request_completion_operations_v9( - workspace_id,operation_id,ticket_id,revision_id,authority_kind,implementation_assignment_id, - completion_actor_runtime_id,completion_actor_worker_id,fingerprint,status,result_ticket_state,created_at,updated_at - ) SELECT workspace_id,operation_id,ticket_id,revision_id,authority_kind,implementation_assignment_id, - completion_actor_runtime_id,completion_actor_worker_id,fingerprint,status,result_ticket_state,created_at,updated_at - FROM merge_request_completion_operations; - DROP TABLE merge_request_completion_operations; - ALTER TABLE merge_request_completion_operations_v9 RENAME TO merge_request_completion_operations;", - ) - .map_err(db) -} - -fn migrate_v10_to_v11(conn: &Connection) -> Result<()> { - conn.execute_batch( - "CREATE TABLE merge_request_revisions_v11 ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), - UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE - ); - INSERT INTO merge_request_revisions_v11( - workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at - ) SELECT workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at - FROM merge_request_revisions; - DROP TABLE merge_request_revisions; - ALTER TABLE merge_request_revisions_v11 RENAME TO merge_request_revisions;", - ) - .map_err(db) -} - -pub fn verify(conn: &Connection) -> Result<()> { - if !table_exists(conn, MIGRATION_TABLE)? { - return Err(MergeRequestError::Database( - "missing merge request schema version marker".into(), - )); - } - let version = schema_version(conn)?; - if version != SCHEMA_VERSION { - return Err(MergeRequestError::Database(format!( - "unsupported merge request schema version {version}; expected {SCHEMA_VERSION}" - ))); - } - verify_marker_state(conn, SCHEMA_VERSION)?; - verify_schema_shape(conn, SCHEMA_V11, "v11") -} - -fn schema_version(conn: &Connection) -> Result { - conn.query_row( - "SELECT COALESCE(MAX(version),0) FROM merge_request_schema_migrations", - [], - |row| row.get(0), - ) - .map_err(db) -} - -fn verify_marker_state(conn: &Connection, expected_version: i64) -> Result<()> { - verify_marker_table_shape(conn)?; - let state: (i64, i64, i64) = conn - .query_row( - "SELECT COUNT(*),COALESCE(MIN(version),0),COALESCE(MAX(version),0) FROM merge_request_schema_migrations", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .map_err(db)?; - if state != (1, expected_version, expected_version) { - return Err(MergeRequestError::Database(format!( - "schema drift: merge request version marker must contain only version {expected_version}" - ))); - } - Ok(()) -} - -fn verify_marker_table_shape(conn: &Connection) -> Result<()> { - let expected = Connection::open_in_memory().map_err(db)?; - expected.execute_batch(MIGRATION_TABLE_SQL).map_err(db)?; - if table_shape(conn, MIGRATION_TABLE)? != table_shape(&expected, MIGRATION_TABLE)? { - return Err(MergeRequestError::Database( - "schema drift: merge request version marker table does not match the latest contract" - .into(), - )); - } - Ok(()) -} - -fn replace_schema_marker(conn: &Connection, version: i64) -> Result<()> { - conn.execute("DELETE FROM merge_request_schema_migrations", []) - .map_err(db)?; - conn.execute( - "INSERT INTO merge_request_schema_migrations(version) VALUES (?1)", - params![version], - ) - .map_err(db)?; - Ok(()) -} - -fn ensure_foreign_key_integrity(conn: &Connection) -> Result<()> { - for table in merge_request_domain_table_names(conn)? { - let quoted = table.replace('\'', "''"); - let mut statement = conn - .prepare(&format!("PRAGMA foreign_key_check('{quoted}')")) - .map_err(db)?; - let mut rows = statement.query([]).map_err(db)?; - if let Some(row) = rows.next().map_err(db)? { - let table: String = row.get(0).map_err(db)?; - let row_id: Option = row.get(1).map_err(db)?; - let parent: String = row.get(2).map_err(db)?; - return Err(MergeRequestError::Database(format!( - "foreign key integrity check failed for table {table}, row {row_id:?}, parent {parent}" - ))); - } - } - Ok(()) -} - -fn merge_request_domain_table_names(conn: &Connection) -> Result> { - let mut statement = conn - .prepare( - "SELECT name FROM sqlite_master - WHERE type='table' - AND name LIKE 'merge_request_%' - AND name <> ?1 - ORDER BY name", - ) - .map_err(db)?; - statement - .query_map(params![MIGRATION_TABLE], |row| row.get::<_, String>(0)) - .map_err(db)? - .collect::, _>>() - .map_err(db) -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct ColumnShape { - cid: i64, - name: String, - data_type: String, - not_null: i64, - default_value: Option, - primary_key: i64, - hidden: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct ForeignKeyShape { - id: i64, - sequence: i64, - parent_table: String, - from_column: String, - to_column: Option, - on_update: String, - on_delete: String, - match_kind: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct IndexColumnShape { - sequence: i64, - column_id: i64, - name: Option, - descending: i64, - collation: Option, - key: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct IndexShape { - unique: i64, - origin: String, - partial: i64, - columns: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct TableShape { - name: String, - columns: Vec, - foreign_keys: Vec, - indexes: Vec, - checks: Vec, -} - -fn verify_schema_shape(conn: &Connection, expected_sql: &str, label: &str) -> Result<()> { - let expected = Connection::open_in_memory().map_err(db)?; - expected.execute_batch(expected_sql).map_err(db)?; - let expected_shape = domain_schema_shape(&expected)?; - let actual_shape = domain_schema_shape(conn)?; - if actual_shape != expected_shape { - let mismatch = expected_shape - .iter() - .zip(actual_shape.iter()) - .find(|(expected, actual)| expected != actual) - .map(|(expected, actual)| { - format!(" expected {}, observed {}", expected.name, actual.name) - }) - .unwrap_or_else(|| { - format!( - " expected {} tables, observed {}", - expected_shape.len(), - actual_shape.len() - ) - }); - return Err(MergeRequestError::Database(format!( - "schema drift: merge request {label} shape mismatch;{mismatch}" - ))); - } - Ok(()) -} - -fn domain_schema_shape(conn: &Connection) -> Result> { - let mut statement = conn - .prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'merge_request_%' AND name <> ?1 ORDER BY name", - ) - .map_err(db)?; - let names = statement - .query_map(params![MIGRATION_TABLE], |row| row.get::<_, String>(0)) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - names - .into_iter() - .map(|name| table_shape(conn, &name)) - .collect() -} - -fn table_shape(conn: &Connection, table: &str) -> Result { - let quoted = table.replace('\'', "''"); - let mut column_statement = conn - .prepare(&format!("PRAGMA table_xinfo('{quoted}')")) - .map_err(db)?; - let columns = column_statement - .query_map([], |row| { - Ok(ColumnShape { - cid: row.get(0)?, - name: row.get(1)?, - data_type: row.get(2)?, - not_null: row.get(3)?, - default_value: row.get(4)?, - primary_key: row.get(5)?, - hidden: row.get(6)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - - let mut foreign_key_statement = conn - .prepare(&format!("PRAGMA foreign_key_list('{quoted}')")) - .map_err(db)?; - let mut foreign_keys = foreign_key_statement - .query_map([], |row| { - Ok(ForeignKeyShape { - id: row.get(0)?, - sequence: row.get(1)?, - parent_table: row.get(2)?, - from_column: row.get(3)?, - to_column: row.get(4)?, - on_update: row.get(5)?, - on_delete: row.get(6)?, - match_kind: row.get(7)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - foreign_keys.sort(); - - let mut index_statement = conn - .prepare(&format!("PRAGMA index_list('{quoted}')")) - .map_err(db)?; - let index_rows = index_statement - .query_map([], |row| { - Ok(( - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, String>(3)?, - row.get::<_, i64>(4)?, - )) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - let mut indexes = Vec::with_capacity(index_rows.len()); - for (index_name, unique, origin, partial) in index_rows { - let index_quoted = index_name.replace('\'', "''"); - let mut columns_statement = conn - .prepare(&format!("PRAGMA index_xinfo('{index_quoted}')")) - .map_err(db)?; - let columns = columns_statement - .query_map([], |row| { - Ok(IndexColumnShape { - sequence: row.get(0)?, - column_id: row.get(1)?, - name: row.get(2)?, - descending: row.get(3)?, - collation: row.get(4)?, - key: row.get(5)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - indexes.push(IndexShape { - unique, - origin, - partial, - columns, - }); - } - indexes.sort(); - let create_sql: String = conn - .query_row( - "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1", - params![table], - |row| row.get(0), - ) - .map_err(db)?; - Ok(TableShape { - name: table.to_string(), - columns, - foreign_keys, - indexes, - checks: extract_check_constraints(&create_sql), - }) -} - -fn extract_check_constraints(sql: &str) -> Vec { - let bytes = sql.as_bytes(); - let lower = sql.to_ascii_lowercase(); - let lower_bytes = lower.as_bytes(); - let mut checks = Vec::new(); - let mut cursor = 0; - while cursor + 5 <= bytes.len() { - let Some(relative) = lower[cursor..].find("check") else { - break; + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let event_seq = next_event_seq_tx(&tx, &mr.workspace_id, &mr.merge_request_id)?; + let event = LifecycleEvent { + event_seq, + actor: WorkerIdentity { + runtime_id: input.auth.runtime_id, + worker_id: input.auth.worker_id, + }, + body: input.body, + created_at: input.now, }; - let start = cursor + relative; - let mut open = start + 5; - while open < bytes.len() && bytes[open].is_ascii_whitespace() { - open += 1; + insert_event_tx( + &tx, + &mr.workspace_id, + &mr.merge_request_id, + kind, + &event, + input.now, + None, + )?; + tx.execute( + "UPDATE merge_requests SET state = ?3, updated_at = ?4 + WHERE workspace_id = ?1 AND merge_request_id = ?2", + params![ + mr.workspace_id, + mr.merge_request_id, + target.as_str(), + input.now.to_rfc3339() + ], + )?; + tx.commit()?; + drop(conn); + self.get(&input.auth.workspace_id, &input.ticket_id) + } + + pub fn get( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result { + let conn = self.lock_conn()?; + load_merge_request(&conn, workspace_id, ticket_id)?.ok_or(MergeRequestError::NotFound) + } + + pub fn list_for_ticket( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result, MergeRequestError> { + let conn = self.lock_conn()?; + let mut stmt = conn.prepare( + "SELECT merge_request_id FROM merge_requests + WHERE workspace_id = ?1 AND ticket_id = ?2 + ORDER BY created_at DESC, merge_request_id DESC", + )?; + let ids = stmt + .query_map(params![workspace_id, ticket_id], |row| { + row.get::<_, String>(0) + })? + .collect::, _>>()?; + ids.into_iter() + .map(|id| { + load_merge_request_by_id(&conn, workspace_id, &id)? + .ok_or(MergeRequestError::NotFound) + }) + .collect() + } + + fn validate_auth( + &self, + auth: &MergeRequestAuth, + ticket_id: &str, + repository_id: &str, + ) -> Result<(), MergeRequestError> { + if auth.repository_id != repository_id { + return Err(MergeRequestError::Unauthorized( + "repository does not match request".into(), + )); } - if open >= bytes.len() || bytes[open] != b'(' { - cursor = start + 5; - continue; + if !self + .repositories + .repository_belongs_to_workspace(&auth.workspace_id, repository_id) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Unauthorized( + "repository does not belong to workspace".into(), + )); } - let mut depth = 0_i32; - let mut quoted = false; - let mut end = open; - while end < bytes.len() { - let byte = bytes[end]; - if byte == b'\'' { - if quoted && end + 1 < bytes.len() && bytes[end + 1] == b'\'' { - end += 2; - continue; - } - quoted = !quoted; - } else if !quoted { - if byte == b'(' { - depth += 1; - } else if byte == b')' { - depth -= 1; - if depth == 0 { - end += 1; - break; - } - } + let assignment = self + .assignments + .current_assignment(&auth.workspace_id, ticket_id) + .map_err(MergeRequestError::Operation)? + .ok_or_else(|| { + MergeRequestError::Unauthorized("ticket has no current assignment".into()) + })?; + if assignment.assignment_id != auth.assignment_id + || assignment.runtime_id != auth.runtime_id + || assignment.worker_id != auth.worker_id + || assignment.ticket_id != ticket_id + { + return Err(MergeRequestError::Unauthorized( + "caller is not the current assigned worker".into(), + )); + } + Ok(()) + } + + fn validate_lifecycle_auth( + &self, + auth: &MergeRequestAuth, + repository_id: &str, + ) -> Result<(), MergeRequestError> { + if auth.repository_id != repository_id { + return Err(MergeRequestError::Unauthorized( + "repository does not match request".into(), + )); + } + if !self + .repositories + .repository_belongs_to_workspace(&auth.workspace_id, repository_id) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Unauthorized( + "repository does not belong to workspace".into(), + )); + } + Ok(()) + } + + fn validate_completion_auth( + &self, + auth: &MergeRequestAuth, + ticket_id: &str, + repository_id: &str, + ) -> Result<(), MergeRequestError> { + if auth.repository_id != repository_id { + return Err(MergeRequestError::Unauthorized( + "repository does not match request".into(), + )); + } + if !self + .repositories + .repository_belongs_to_workspace(&auth.workspace_id, repository_id) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Unauthorized( + "repository does not belong to workspace".into(), + )); + } + let assignment = self + .assignments + .current_assignment(&auth.workspace_id, ticket_id) + .map_err(MergeRequestError::Operation)? + .ok_or_else(|| { + MergeRequestError::Unauthorized("ticket has no current assignment".into()) + })?; + if assignment.assignment_id != auth.assignment_id || assignment.ticket_id != ticket_id { + return Err(MergeRequestError::Unauthorized( + "completion does not match the current assignment".into(), + )); + } + Ok(()) + } + + fn lock_conn(&self) -> Result, MergeRequestError> { + self.conn + .lock() + .map_err(|_| MergeRequestError::Operation("database lock poisoned".into())) + } +} + +fn validate_request(request: &RequestForReview) -> Result<(), MergeRequestError> { + validate_nonempty("base_commit", &request.base_commit)?; + validate_nonempty("head_commit", &request.head_commit)?; + if request + .changed_paths + .iter() + .any(|path| path.trim().is_empty()) + { + return Err(MergeRequestError::Validation( + "changed_paths must not contain empty entries".into(), + )); + } + Ok(()) +} + +fn validate_nonempty(field: &str, value: &str) -> Result<(), MergeRequestError> { + if value.trim().is_empty() { + Err(MergeRequestError::Validation(format!( + "{field} must not be empty" + ))) + } else { + Ok(()) + } +} + +fn validate_reviewer_profile(profile: &str) -> Result<(), MergeRequestError> { + if profile == "builtin:reviewer" { + Ok(()) + } else { + Err(MergeRequestError::Unauthorized( + "review source must use builtin:reviewer".into(), + )) + } +} + +fn ensure_open(mr: &MergeRequest) -> Result<(), MergeRequestError> { + if mr.state == MergeRequestState::Open { + Ok(()) + } else { + Err(MergeRequestError::Conflict( + "merge request is not open".into(), + )) + } +} + +fn merge_matches(existing: &MergeEvent, input: &CompleteMergeRequest) -> bool { + existing.target_commit == input.target_commit + && existing.source_commit == input.source_commit + && existing.result_commit == input.result_commit + && existing.strategy == input.strategy + && existing.resolution == input.resolution +} + +fn validate_completion_shape( + input: &CompleteMergeRequest, + request: &RequestForReviewEvent, + repositories: &dyn RepositorySource, + mr: &MergeRequest, +) -> Result<(), MergeRequestError> { + match input.strategy { + MergeStrategy::FastForward => { + if input.resolution != ConflictResolution::None + || input.target_commit != request.base_commit + || input.result_commit != input.source_commit + { + return Err(MergeRequestError::Validation( + "fast-forward completion must use the review base as target, the source as result, and resolution none".into(), + )); + } + if !repositories + .is_ancestor( + &mr.workspace_id, + &mr.repository_id, + &input.target_commit, + &input.source_commit, + ) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Conflict( + "source commit is not a descendant of target commit".into(), + )); } - end += 1; } - if depth == 0 { - checks.push( - lower_bytes[open..end] - .iter() - .filter(|byte| !byte.is_ascii_whitespace()) - .map(|byte| *byte as char) - .collect(), - ); + MergeStrategy::Merge => { + if input.resolution == ConflictResolution::None + || input.result_commit == input.target_commit + { + return Err(MergeRequestError::Validation( + "merge completion requires an explicit resolution and a new result commit" + .into(), + )); + } + if !repositories + .is_ancestor( + &mr.workspace_id, + &mr.repository_id, + &input.target_commit, + &input.result_commit, + ) + .map_err(MergeRequestError::Operation)? + || !repositories + .is_ancestor( + &mr.workspace_id, + &mr.repository_id, + &input.source_commit, + &input.result_commit, + ) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Conflict( + "merge result must descend from both target and source commits".into(), + )); + } } - cursor = end.max(start + 5); } - checks.sort(); - checks + Ok(()) } -fn has_merge_request_domain_tables(conn: &Connection) -> Result { - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE 'merge_request_%' AND name <> ?1", - params![MIGRATION_TABLE], - |row| row.get(0), - ) - .map_err(db)?; - Ok(count > 0) +fn append_ticket_completion_event_tx( + tx: &Transaction<'_>, + mr: &MergeRequest, + event: &MergeEvent, + implementation_assignment_id: &str, +) -> Result<(), MergeRequestError> { + let event_index: i64 = tx.query_row( + "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events + WHERE workspace_id = ?1 AND ticket_id = ?2", + params![mr.workspace_id, mr.ticket_id], + |row| row.get(0), + )?; + tx.execute( + "INSERT INTO typed_ticket_events ( + workspace_id, ticket_id, event_index, kind, author, at, + from_state, to_state, heading, body + ) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5, + 'inprogress', 'done', 'Merge Request completed', ?6)", + params![ + mr.workspace_id, + mr.ticket_id, + event_index, + format!( + "worker:{}:{}", + event.merged_by.runtime_id, event.merged_by.worker_id + ), + event.created_at.to_rfc3339(), + format!( + "Approved candidate `{}` completed implementation.", + event.source_commit + ), + ], + )?; + let event_seq = event.event_seq.to_string(); + for (key, value) in [ + ("implementation_assignment_id", implementation_assignment_id), + ("merge_request_event_seq", event_seq.as_str()), + ("merge_request_head_commit", event.source_commit.as_str()), + ("operation_id", event.operation_id.as_str()), + ("completion_authority", "workspace_orchestrator"), + ("runtime_id", event.merged_by.runtime_id.as_str()), + ("worker_id", event.merged_by.worker_id.as_str()), + ] { + tx.execute( + "INSERT INTO typed_ticket_event_attributes ( + workspace_id, ticket_id, event_index, key, value + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![mr.workspace_id, mr.ticket_id, event_index, key, value], + )?; + } + Ok(()) } -fn table_exists(conn: &Connection, table: &str) -> Result { - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", - params![table], - |row| row.get::<_, i64>(0), - ) - .map(|value| value != 0) - .map_err(db) +fn append_request_event_tx( + tx: &Transaction<'_>, + workspace_id: &str, + merge_request_id: &str, + auth: &MergeRequestAuth, + request: &RequestForReview, + now: DateTime, +) -> Result { + let event_seq = next_event_seq_tx(tx, workspace_id, merge_request_id)?; + let event = RequestForReviewEvent { + event_seq, + base_commit: request.base_commit.clone(), + head_commit: request.head_commit.clone(), + changed_paths: request.changed_paths.clone(), + summary: request.summary.clone(), + assignment_id: auth.assignment_id.clone(), + requested_by: WorkerIdentity { + runtime_id: auth.runtime_id.clone(), + worker_id: auth.worker_id.clone(), + }, + created_at: now, + }; + insert_event_tx( + tx, + workspace_id, + merge_request_id, + "request_for_review", + &event, + now, + None, + )?; + tx.execute( + "UPDATE merge_requests SET updated_at = ?3 + WHERE workspace_id = ?1 AND merge_request_id = ?2", + params![workspace_id, merge_request_id, now.to_rfc3339()], + )?; + tx.execute( + "UPDATE merge_request_review_capabilities SET status = 'revoked' + WHERE workspace_id = ?1 AND merge_request_id = ?2 AND status = 'issued'", + params![workspace_id, merge_request_id], + )?; + Ok(event) } -#[cfg(test)] -fn column_exists(conn: &Connection, table: &str, column: &str) -> Result { - let mut statement = conn - .prepare(&format!("PRAGMA table_info({table})")) - .map_err(db)?; - let columns = statement - .query_map([], |row| row.get::<_, String>(1)) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - Ok(columns.iter().any(|candidate| candidate == column)) +fn next_event_seq_tx( + tx: &Transaction<'_>, + workspace_id: &str, + merge_request_id: &str, +) -> Result { + let current: i64 = tx.query_row( + "SELECT COALESCE(MAX(event_seq), 0) FROM merge_request_thread_events + WHERE workspace_id = ?1 AND merge_request_id = ?2", + params![workspace_id, merge_request_id], + |row| row.get(0), + )?; + Ok((current + 1) as u64) } -const MIGRATION_TABLE: &str = "merge_request_schema_migrations"; -const MIGRATION_TABLE_SQL: &str = "CREATE TABLE merge_request_schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);"; -const SCHEMA_V8: &str = r#" -CREATE TABLE merge_requests ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) -); -CREATE TABLE merge_request_ticket_relations ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,ticket_id), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revisions ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, head_tree TEXT NOT NULL, diff_digest TEXT NOT NULL, - summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revision_paths ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_reviewer_child_sessions ( - workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, - parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,child_session_id) -); -CREATE TABLE merge_request_review_attempts ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) -); -CREATE TABLE merge_request_reviews ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) -); -CREATE TABLE merge_request_review_findings ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, - code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, PRIMARY KEY(workspace_id,attempt_id,ordinal), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_completion_operations ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) -); -"#; - -const SCHEMA_V9: &str = r#" -CREATE TABLE merge_requests ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, target_ref_selector TEXT, - target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), - state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) -); -CREATE TABLE merge_request_ticket_relations ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,ticket_id), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revisions ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - diff_digest TEXT NOT NULL, summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), - UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revision_paths ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_reviewer_child_sessions ( - workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, - parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,child_session_id) -); -CREATE TABLE merge_request_review_attempts ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, merge_result_id TEXT, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) -); -CREATE TABLE merge_request_reviews ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - merge_result_id TEXT, decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) -); -CREATE TABLE merge_request_review_findings ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, - code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id,ordinal), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_merge_results ( - workspace_id TEXT NOT NULL, merge_result_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, target_commit TEXT NOT NULL, - source_commit TEXT NOT NULL, result_commit TEXT NOT NULL, - strategy TEXT NOT NULL CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT NOT NULL CHECK(resolution IN ('none','clean','conflicts_resolved')), - created_by_runtime_id TEXT NOT NULL, created_by_worker_id TEXT NOT NULL, - created_at TEXT NOT NULL, operation_id TEXT NOT NULL, operation_fingerprint TEXT NOT NULL, - validated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_result_id), UNIQUE(workspace_id,operation_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) -); -CREATE INDEX merge_request_merge_results_current_idx - ON merge_request_merge_results(workspace_id,merge_request_id,revision_id,target_commit,created_at); -CREATE TABLE merge_request_completion_operations ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) -); -"#; - -const SCHEMA_V10: &str = r#" -CREATE TABLE merge_requests ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - target_ref_selector TEXT, - target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), - merged_revision_id TEXT, merged_target_commit TEXT, merged_result_commit TEXT, - merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')), - merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')), - merged_by_runtime_id TEXT, merged_by_worker_id TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) -); -CREATE TABLE merge_request_ticket_relations ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,ticket_id), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revisions ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - diff_digest TEXT NOT NULL, summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revision_paths ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_reviewer_child_sessions ( - workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, - parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,child_session_id) -); -CREATE TABLE merge_request_review_attempts ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), - FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) -); -CREATE TABLE merge_request_reviews ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) -); -CREATE TABLE merge_request_review_findings ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, - code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, PRIMARY KEY(workspace_id,attempt_id,ordinal), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_completion_operations ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - target_commit TEXT, source_commit TEXT, result_commit TEXT, - strategy TEXT CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), - FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) - REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) -); -"#; - -const SCHEMA_V11: &str = r#" -CREATE TABLE merge_requests ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - target_ref_selector TEXT, - target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), - merged_revision_id TEXT, merged_target_commit TEXT, merged_result_commit TEXT, - merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')), - merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')), - merged_by_runtime_id TEXT, merged_by_worker_id TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) -); -CREATE TABLE merge_request_ticket_relations ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,ticket_id), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revisions ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revision_paths ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_reviewer_child_sessions ( - workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, - parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,child_session_id) -); -CREATE TABLE merge_request_review_attempts ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), - FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) -); -CREATE TABLE merge_request_reviews ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) -); -CREATE TABLE merge_request_review_findings ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, - code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, PRIMARY KEY(workspace_id,attempt_id,ordinal), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_completion_operations ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - target_commit TEXT, source_commit TEXT, result_commit TEXT, - strategy TEXT CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), - FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) - REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) -); -"#; - -#[cfg(test)] -mod migration_tests { - use super::*; - - const SUPPORT_SCHEMA: &str = r#" -CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id)); -CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id)); -CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id)); -"#; - - fn fresh_connection() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch(SUPPORT_SCHEMA).unwrap(); - conn - } - - fn exact_v8_connection() -> Connection { - let conn = fresh_connection(); - conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); - conn.execute( - "INSERT INTO merge_request_schema_migrations(version) VALUES(8)", - [], - ) - .unwrap(); - conn.execute_batch(SCHEMA_V8).unwrap(); - conn.execute_batch( - "INSERT INTO repositories VALUES('ws','repo'); - INSERT INTO typed_tickets VALUES('ws','T1','inprogress',1,'t0'); - INSERT INTO ticket_worker_assignments VALUES('ws','T1','A1','R1','W1'); - INSERT INTO merge_requests VALUES('ws','MR1','repo','open',3,'V1','t0','t1',NULL,NULL); - INSERT INTO merge_request_ticket_relations VALUES('ws','MR1','T1','implements','t0'); - INSERT INTO merge_request_revisions VALUES('ws','MR1','V1',1,'base','head','legacy-tree','digest','summary','A1','t0'); - INSERT INTO merge_request_revision_paths VALUES('ws','MR1','V1',0,'src/lib.rs'); - INSERT INTO merge_request_reviewer_child_sessions VALUES('ws','C1','R1','W1','builtin:reviewer','t0'); - INSERT INTO merge_request_review_attempts VALUES('ws','AT1','MR1','T1','V1',3,'A1','R1','W1','C1','builtin:reviewer','token','submitted','t0','t1'); - INSERT INTO merge_request_reviews VALUES('ws','AT1','MR1','V1','approve','approved','t1'); - INSERT INTO merge_request_review_findings VALUES('ws','AT1',0,'warning','C','src/lib.rs',7,'finding'); - INSERT INTO merge_request_completion_operations VALUES('ws','OP1','T1','V1','workspace_orchestrator','A1','R1','W1','fp','pending',NULL,'t0','t1');", - ).unwrap(); - conn - } - - fn exact_v9_connection() -> Connection { - let conn = fresh_connection(); - conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); - conn.execute_batch( - "INSERT INTO merge_request_schema_migrations(version) VALUES(1),(2),(3),(4),(5),(9);", - ) - .unwrap(); - conn.execute_batch(SCHEMA_V9).unwrap(); - conn.execute_batch( - "CREATE TABLE merge_request_ticket_links(obsolete TEXT); - INSERT INTO repositories VALUES('ws','repo'); - INSERT INTO typed_tickets VALUES('ws','T1','done',1,'t2'); - INSERT INTO ticket_worker_assignments VALUES('ws','T1','A1','R1','W1'); - INSERT INTO merge_requests VALUES('ws','MR1','repo','refs/heads/main','known','open',3,'V1','t0','t1',NULL,NULL); - INSERT INTO merge_request_ticket_relations VALUES('ws','MR1','T1','implements','t0'); - INSERT INTO merge_request_revisions VALUES('ws','MR1','V1',1,'base','head','digest','summary','A1','t0'); - INSERT INTO merge_request_revision_paths VALUES('ws','MR1','V1',0,'src/lib.rs'); - INSERT INTO merge_request_reviewer_child_sessions VALUES('ws','C1','R1','W1','builtin:reviewer','t0'); - INSERT INTO merge_request_review_attempts VALUES('ws','AT1','MR1','T1','V1',NULL,3,'A1','R1','W1','C1','builtin:reviewer','token','submitted','t0','t1'); - INSERT INTO merge_request_reviews VALUES('ws','AT1','MR1','V1',NULL,'approve','approved','t1'); - INSERT INTO merge_request_review_findings VALUES('ws','AT1',0,'warning','C','src/lib.rs',7,'finding'); - INSERT INTO merge_request_merge_results VALUES('ws','M1','MR1','T1','V1','base','head','head','fast_forward','none','R1','W1','t1','record-result','result-fp','t1'); - INSERT INTO merge_request_completion_operations VALUES('ws','OP1','T1','V1','workspace_orchestrator','A1','R1','W1','old-fp','completed','done','t0','t2');", - ) - .unwrap(); - conn - } - - fn exact_v10_connection() -> Connection { - let conn = fresh_connection(); - conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); - conn.execute( - "INSERT INTO merge_request_schema_migrations(version,applied_at) VALUES(10,'t0')", - [], - ) - .unwrap(); - conn.execute_batch(SCHEMA_V10).unwrap(); - conn.execute_batch( - "INSERT INTO repositories VALUES('ws','repo'); - INSERT INTO typed_tickets VALUES('ws','T1','inprogress',1,'t0'); - INSERT INTO ticket_worker_assignments VALUES('ws','T1','A1','R1','W1'); - INSERT INTO merge_requests VALUES('ws','MR1','repo','open',1,'V1','t0','t0',NULL,NULL,'refs/heads/main','known',NULL,NULL,NULL,NULL,NULL,NULL,NULL); - INSERT INTO merge_request_ticket_relations VALUES('ws','MR1','T1','implements','t0'); - INSERT INTO merge_request_revisions VALUES('ws','MR1','V1',1,'base','head','digest','summary','A1','t0'); - INSERT INTO merge_request_revision_paths VALUES('ws','MR1','V1',0,'src/lib.rs'); - INSERT INTO merge_request_reviewer_child_sessions VALUES('ws','C1','R1','W1','builtin:reviewer','t0'); - INSERT INTO merge_request_review_attempts VALUES('ws','AT1','MR1','T1','V1',1,'A1','R1','W1','C1','builtin:reviewer','token','submitted','t0','t1'); - INSERT INTO merge_request_reviews VALUES('ws','AT1','MR1','V1','approve','approved','t1');", - ) - .unwrap(); - conn - } - - fn marker_version(conn: &Connection) -> i64 { - conn.query_row( - "SELECT MAX(version) FROM merge_request_schema_migrations", - [], - |row| row.get(0), - ) - .unwrap() - } - - #[test] - fn fresh_database_materializes_final_merge_evidence_contract() { - let conn = fresh_connection(); - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 11); - for column in [ - "target_ref_selector", - "merged_revision_id", - "merged_target_commit", - "merged_result_commit", - "merge_strategy", - "merge_resolution", - "merged_by_runtime_id", - "merged_by_worker_id", - ] { - assert!( - column_exists(&conn, "merge_requests", column).unwrap(), - "missing {column}" - ); - } - assert!(!column_exists(&conn, "merge_request_revisions", "head_tree").unwrap()); - assert!(!column_exists(&conn, "merge_request_revisions", "diff_digest").unwrap()); - assert!(!table_exists(&conn, "merge_request_merge_results").unwrap()); - } - - #[test] - fn exact_v8_migrates_preserving_review_and_operation_evidence() { - let conn = exact_v8_connection(); - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 11); - assert_eq!( - conn.query_row( - "SELECT head_commit FROM merge_request_revisions WHERE revision_id='V1'", - [], - |row| row.get::<_, String>(0) - ) - .unwrap(), - "head" - ); - assert_eq!( - conn.query_row( - "SELECT decision FROM merge_request_reviews WHERE attempt_id='AT1'", - [], - |row| row.get::<_, String>(0) - ) - .unwrap(), - "approve" - ); - assert_eq!( - conn.query_row( - "SELECT target_ref_selector FROM merge_requests WHERE merge_request_id='MR1'", - [], - |row| row.get::<_, Option>(0) - ) - .unwrap(), - None - ); - assert_eq!(conn.query_row("SELECT result_commit FROM merge_request_completion_operations WHERE operation_id='OP1'", [], |row| row.get::<_,Option>(0)).unwrap(), None); - assert_eq!( - conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - }) - .unwrap(), - 0 - ); - } - - #[test] - fn exact_v10_migrates_revision_evidence_without_diff_digest() { - let conn = exact_v10_connection(); - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - - assert_eq!(marker_version(&conn), 11); - assert!(!column_exists(&conn, "merge_request_revisions", "diff_digest").unwrap()); - assert_eq!( - conn.query_row( - "SELECT base_commit,head_commit FROM merge_request_revisions WHERE revision_id='V1'", - [], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .unwrap(), - ("base".to_string(), "head".to_string()) - ); - assert_eq!( - conn.query_row( - "SELECT path FROM merge_request_revision_paths WHERE revision_id='V1'", - [], - |row| row.get::<_, String>(0), - ) - .unwrap(), - "src/lib.rs" - ); - assert_eq!( - conn.query_row( - "SELECT decision FROM merge_request_reviews WHERE attempt_id='AT1'", - [], - |row| row.get::<_, String>(0), - ) - .unwrap(), - "approve" - ); - assert_eq!( - conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - }) - .unwrap(), - 0 - ); - } - - #[test] - fn v10_to_v11_failure_rolls_back_schema_data_and_marker() { - let conn = exact_v10_connection(); - let error = migrate_with_failpoint(&conn, true).unwrap_err(); - assert!( - error - .to_string() - .contains("forced v10 to v11 migration failure") - ); - assert_eq!(marker_version(&conn), 10); - assert!(column_exists(&conn, "merge_request_revisions", "diff_digest").unwrap()); - assert_eq!( - conn.query_row( - "SELECT diff_digest FROM merge_request_revisions WHERE revision_id='V1'", - [], - |row| row.get::<_, String>(0), - ) - .unwrap(), - "digest" - ); - verify_schema_shape(&conn, SCHEMA_V10, "v10 after rollback").unwrap(); - } - - #[test] - fn v8_to_v11_failure_rolls_back_schema_data_and_marker() { - let conn = exact_v8_connection(); - let error = migrate_with_failpoint(&conn, true).unwrap_err(); - assert!( - error - .to_string() - .contains("forced v8 to v11 migration failure") - ); - assert_eq!(marker_version(&conn), 8); - assert!(column_exists(&conn, "merge_request_revisions", "head_tree").unwrap()); - assert!(!column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); - verify_schema_shape(&conn, SCHEMA_V8, "v8 after rollback").unwrap(); - } - - #[test] - fn drifted_v8_fails_closed_without_mutation() { - let conn = exact_v8_connection(); - conn.execute_batch("ALTER TABLE merge_requests ADD COLUMN drift TEXT;") - .unwrap(); - let error = migrate(&conn).unwrap_err(); - assert!( - error - .to_string() - .contains("schema drift at merge request version 8") - ); - assert_eq!(marker_version(&conn), 8); - assert!(!column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); - } - - #[test] - fn unrelated_foreign_key_mismatch_does_not_block_merge_request_migration() { - let conn = exact_v8_connection(); - conn.execute_batch( - "CREATE TABLE unrelated_parent( - left_id TEXT NOT NULL, - right_id TEXT NOT NULL, - PRIMARY KEY(left_id,right_id) - ); - CREATE TABLE unrelated_child( - left_id TEXT REFERENCES unrelated_parent(left_id) - );", - ) - .unwrap(); - let error = conn - .query_row("PRAGMA foreign_key_check", [], |_| Ok(())) - .unwrap_err(); - assert!(error.to_string().contains("foreign key mismatch")); - - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 11); - assert!(column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); - } - - #[test] - fn versions_older_than_v8_are_rejected() { - let conn = fresh_connection(); - conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); - conn.execute( - "INSERT INTO merge_request_schema_migrations(version) VALUES(7)", - [], - ) - .unwrap(); - let error = migrate(&conn).unwrap_err(); - assert!( - error - .to_string() - .contains("only supports exact v8, v9, or v10 to v11") - ); - assert_eq!(marker_version(&conn), 7); - } - - #[test] - fn exact_v9_with_historical_markers_migrates_to_single_v11_marker() { - let conn = exact_v9_connection(); - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 11); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM merge_request_schema_migrations", - [], - |row| row.get::<_, i64>(0) - ) - .unwrap(), - 1 - ); - assert_eq!( - conn.query_row( - "SELECT state FROM merge_requests WHERE merge_request_id='MR1'", - [], - |row| row.get::<_, String>(0) - ) - .unwrap(), - "merged" - ); - assert_eq!( - conn.query_row( - "SELECT merged_revision_id,merged_target_commit,merged_result_commit,merge_strategy,merge_resolution - FROM merge_requests WHERE merge_request_id='MR1'", - [], - |row| Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - )) - ) - .unwrap(), - ( - "V1".into(), - "base".into(), - "head".into(), - "fast_forward".into(), - "none".into() - ) - ); - assert_eq!( - conn.query_row( - "SELECT fingerprint FROM merge_request_completion_operations WHERE operation_id='OP1'", - [], - |row| row.get::<_, String>(0) - ) - .unwrap(), - completion_fingerprint_parts( - "T1", "V1", "base", "head", "head", "fast_forward", "none", "A1", "R1", - "W1" - ) - ); - assert!(!table_exists(&conn, "merge_request_ticket_links").unwrap()); - assert!(!table_exists(&conn, "merge_request_merge_results").unwrap()); - } +fn insert_event_tx( + tx: &Transaction<'_>, + workspace_id: &str, + merge_request_id: &str, + kind: &str, + payload: &T, + now: DateTime, + operation_id: Option<&str>, +) -> Result<(), MergeRequestError> { + let event_seq = serde_json::to_value(payload) + .map_err(|error| MergeRequestError::Operation(error.to_string()))? + .get("event_seq") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| MergeRequestError::Operation("event payload has no event_seq".into()))?; + let payload_json = serde_json::to_string(payload) + .map_err(|error| MergeRequestError::Operation(error.to_string()))?; + tx.execute( + "INSERT INTO merge_request_thread_events ( + workspace_id, merge_request_id, event_seq, kind, payload_json, + operation_id, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + workspace_id, + merge_request_id, + event_seq as i64, + kind, + payload_json, + operation_id, + now.to_rfc3339() + ], + )?; + Ok(()) } fn load_merge_request( conn: &Connection, workspace_id: &str, ticket_id: &str, -) -> Result> { - type Row = ( - String, - String, - String, - Option, - String, - String, - i64, - String, - String, - String, - Option, - Option, - Option, - Option, - Option, - Option, - Option, - Option, - ); - let row: Option = conn.query_row( - "SELECT mr.merge_request_id,rel.ticket_id,mr.repository_id,mr.target_ref_selector,mr.target_status,mr.state,mr.lifecycle_generation,mr.current_revision_id,mr.created_at,mr.updated_at,mr.merged_revision_id,mr.merged_target_commit,mr.merged_result_commit,mr.merge_strategy,mr.merge_resolution,mr.merged_by_runtime_id,mr.merged_by_worker_id,mr.merged_at FROM merge_requests mr JOIN merge_request_ticket_relations rel ON rel.workspace_id=mr.workspace_id AND rel.merge_request_id=mr.merge_request_id WHERE mr.workspace_id=?1 AND rel.ticket_id=?2 AND rel.relation_kind='implements' ORDER BY mr.updated_at DESC,mr.merge_request_id DESC LIMIT 1", - params![workspace_id,ticket_id], - |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?,r.get(8)?,r.get(9)?,r.get(10)?,r.get(11)?,r.get(12)?,r.get(13)?,r.get(14)?,r.get(15)?,r.get(16)?,r.get(17)?)), - ).optional().map_err(db)?; - let Some(( - mr_id, - ticket_id, - repository_id, - target_ref_selector, - target_status, - state, - generation, - revision_id, - created_at, - updated_at, - merged_revision_id, - merged_target_commit, - merged_result_commit, - merge_strategy, - merge_resolution, - merged_by_runtime_id, - merged_by_worker_id, - merged_at, - )) = row - else { - return Ok(None); - }; - let revision = load_revision(conn, workspace_id, &mr_id, &revision_id)?; - let current_review = load_latest_review(conn, workspace_id, &mr_id, &revision_id, generation)?; - let review_status = match current_review.as_ref().map(|review| review.decision) { - Some(ReviewDecision::Approve) => ReviewStatus::Approved, - Some(ReviewDecision::RequestChanges) => ReviewStatus::ChangesRequested, - None => ReviewStatus::Pending, - }; - Ok(Some(MergeRequest { - merge_request_id: mr_id, - workspace_id: workspace_id.into(), - ticket_id, - repository_id, - target_ref_selector, - target_status: MergeRequestTargetStatus::parse(&target_status), - observed_target_commit: None, - state: MergeRequestState::parse(&state), - lifecycle_generation: generation as u64, - current_revision: revision, - review_status, - current_review, - created_at, - updated_at, - merged_revision_id, - merged_target_commit, - merged_result_commit, - merge_strategy: merge_strategy.as_deref().map(MergeStrategy::parse), - merge_resolution: merge_resolution.as_deref().map(MergeResolution::parse), - merged_by_runtime_id, - merged_by_worker_id, - merged_at, - })) -} - -fn load_revision( - conn: &Connection, - workspace_id: &str, - mr_id: &str, - revision_id: &str, -) -> Result { - let mut revision: MergeRequestRevision = conn.query_row( - "SELECT revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at FROM merge_request_revisions WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3", - params![workspace_id,mr_id,revision_id], |r| Ok(MergeRequestRevision { revision_id:r.get(0)?, ordinal:r.get::<_,i64>(1)? as u64, base_commit:r.get(2)?, head_commit:r.get(3)?, changed_paths:Vec::new(), summary:r.get(4)?, assignment_id:r.get(5)?, created_at:r.get(6)? }), - ).map_err(db)?; - let mut statement = conn.prepare("SELECT path FROM merge_request_revision_paths WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3 ORDER BY ordinal").map_err(db)?; - revision.changed_paths = statement - .query_map(params![workspace_id, mr_id, revision_id], |r| r.get(0)) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - Ok(revision) -} - -fn insert_revision( - conn: &Connection, - workspace_id: &str, - mr_id: &str, - revision: &MergeRequestRevision, -) -> Result<()> { - conn.execute("INSERT INTO merge_request_revisions (workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", params![workspace_id,mr_id,revision.revision_id,revision.ordinal as i64,revision.base_commit,revision.head_commit,revision.summary,revision.assignment_id,revision.created_at]).map_err(db)?; - for (ordinal, path) in revision.changed_paths.iter().enumerate() { - conn.execute("INSERT INTO merge_request_revision_paths (workspace_id,merge_request_id,revision_id,ordinal,path) VALUES (?1,?2,?3,?4,?5)", params![workspace_id,mr_id,revision.revision_id,ordinal as i64,path]).map_err(db)?; - } - Ok(()) -} - -fn load_latest_review( - conn: &Connection, - workspace_id: &str, - mr_id: &str, - revision_id: &str, - generation: i64, -) -> Result> { - let attempt: Option = conn.query_row("SELECT r.attempt_id FROM merge_request_reviews r JOIN merge_request_review_attempts a ON a.workspace_id=r.workspace_id AND a.attempt_id=r.attempt_id WHERE r.workspace_id=?1 AND r.merge_request_id=?2 AND r.revision_id=?3 AND a.lifecycle_generation=?4 ORDER BY r.submitted_at DESC, r.attempt_id DESC LIMIT 1", params![workspace_id,mr_id,revision_id,generation], |r| r.get(0)).optional().map_err(db)?; - match attempt { - Some(id) => load_review(conn, workspace_id, &id), - None => Ok(None), - } -} - -fn load_review( - conn: &Connection, - workspace_id: &str, - attempt_id: &str, -) -> Result> { - let row: Option<(String,String,String,String,String,String,String,String,String)> = conn.query_row( - "SELECT r.revision_id,r.decision,r.body,a.parent_assignment_id,a.parent_runtime_id,a.parent_worker_id,a.child_session_id,a.child_effective_profile,r.submitted_at FROM merge_request_reviews r JOIN merge_request_review_attempts a ON a.workspace_id=r.workspace_id AND a.attempt_id=r.attempt_id WHERE r.workspace_id=?1 AND r.attempt_id=?2", - params![workspace_id,attempt_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?,r.get(8)?)), - ).optional().map_err(db)?; - let Some(( - revision_id, - decision, - body, - assignment, - runtime, - worker, - child, - profile, - submitted_at, - )) = row - else { - return Ok(None); - }; - let mut stmt=conn.prepare("SELECT severity,code,path,line,body FROM merge_request_review_findings WHERE workspace_id=?1 AND attempt_id=?2 ORDER BY ordinal").map_err(db)?; - let findings = stmt - .query_map(params![workspace_id, attempt_id], |r| { - Ok(ReviewFinding { - severity: r.get(0)?, - code: r.get(1)?, - path: r.get(2)?, - line: r.get::<_, Option>(3)?.map(|v| v as u64), - body: r.get(4)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - Ok(Some(MergeRequestReview { - attempt_id: attempt_id.into(), - revision_id, - decision: ReviewDecision::parse(&decision), - body, - findings, - parent_assignment_id: assignment, - parent_runtime_id: runtime, - parent_worker_id: worker, - reviewer_child_session_id: child, - reviewer_effective_profile: profile, - submitted_at, - })) -} - -fn validate_current_implementation_assignment( - conn: &Connection, - workspace_id: &str, - ticket_id: &str, - assignment_id: &str, -) -> Result<()> { - let current: Option = conn +) -> Result, MergeRequestError> { + let id: Option = conn .query_row( - "SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2", + "SELECT merge_request_id FROM merge_requests + WHERE workspace_id = ?1 AND ticket_id = ?2 + ORDER BY CASE state WHEN 'open' THEN 0 ELSE 1 END, created_at DESC + LIMIT 1", params![workspace_id, ticket_id], |row| row.get(0), ) - .optional() - .map_err(db)?; - if current.as_deref() != Some(assignment_id) { - return Err(MergeRequestError::AssignmentMismatch); - } - Ok(()) + .optional()?; + id.map(|id| load_merge_request_by_id(conn, workspace_id, &id)) + .transpose() + .map(Option::flatten) } -fn validate_current_assignment( - conn: &Connection, +fn load_merge_request_tx( + tx: &Transaction<'_>, workspace_id: &str, ticket_id: &str, - assignment_id: &str, - runtime_id: &str, - worker_id: &str, -) -> Result<()> { - let valid: Option = conn.query_row("SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND runtime_id=?4 AND worker_id=?5", params![workspace_id,ticket_id,assignment_id,runtime_id,worker_id], |r| r.get(0)).optional().map_err(db)?; - if valid.is_none() { - return Err(MergeRequestError::AssignmentMismatch); - } - Ok(()) +) -> Result, MergeRequestError> { + let id: Option = tx + .query_row( + "SELECT merge_request_id FROM merge_requests + WHERE workspace_id = ?1 AND ticket_id = ?2 AND state = 'open' LIMIT 1", + params![workspace_id, ticket_id], + |row| row.get(0), + ) + .optional()?; + id.map(|id| load_merge_request_by_id(tx, workspace_id, &id)) + .transpose() + .map(Option::flatten) } -fn append_completion_event( +fn load_merge_request_by_id( conn: &Connection, workspace_id: &str, - input: &CompleteMergeRequest, -) -> Result<()> { - let index:i64=conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,input.ticket_id],|r|r.get(0)).map_err(db)?; - conn.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body) VALUES (?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Merge Request completed',?6)",params![workspace_id,input.ticket_id,index,format!("worker:{}:{}",input.completion_actor_runtime_id,input.completion_actor_worker_id),input.now,format!("Approved immutable revision `{}` completed implementation.",input.expected_revision_id)]).map_err(db)?; - for (key, value) in [ - ( - "implementation_assignment_id", - input.implementation_assignment_id.as_str(), - ), - ( - "merge_request_revision_id", - input.expected_revision_id.as_str(), - ), - ("operation_id", input.operation_id.as_str()), - ("completion_authority", "workspace_orchestrator"), - ("runtime_id", input.completion_actor_runtime_id.as_str()), - ("worker_id", input.completion_actor_worker_id.as_str()), - ] { - conn.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES (?1,?2,?3,?4,?5)",params![workspace_id,input.ticket_id,index,key,value]).map_err(db)?; - } - Ok(()) -} - -fn validate_revision(revision: &MergeRequestRevision) -> Result<()> { - for (name, value) in [ - ("revision_id", revision.revision_id.as_str()), - ("base_commit", revision.base_commit.as_str()), - ("head_commit", revision.head_commit.as_str()), - ("assignment_id", revision.assignment_id.as_str()), - ] { - nonempty(name, value)?; - } - if revision.ordinal == 0 { - return Err(MergeRequestError::Empty("revision.ordinal")); - } - if revision.summary.len() > MAX_SUMMARY_BYTES { - return Err(MergeRequestError::TooLarge { - field: "revision.summary", - max: MAX_SUMMARY_BYTES, - }); - } - if revision.changed_paths.len() > MAX_CHANGED_PATHS { - return Err(MergeRequestError::TooLarge { - field: "revision.changed_paths", - max: MAX_CHANGED_PATHS, - }); - } - for path in &revision.changed_paths { - nonempty("changed_path", path)?; - if path.len() > MAX_FIELD_BYTES { - return Err(MergeRequestError::TooLarge { - field: "changed_path", - max: MAX_FIELD_BYTES, - }); - } - if Path::new(path).is_absolute() || path.split('/').any(|p| p == "..") { - return Err(MergeRequestError::Empty("changed_path")); - } - } - Ok(()) -} - -fn validate_completion_outcome(input: &CompleteMergeRequest) -> Result<()> { - match input.strategy { - MergeStrategy::FastForward - if input.result_commit == input.source_commit - && input.resolution == MergeResolution::None => {} - MergeStrategy::Merge if input.resolution != MergeResolution::None => {} - MergeStrategy::FastForward => { - return Err(MergeRequestError::InvalidMergeOutcome( - "fast-forward result must equal the approved source commit and use resolution=none" - .into(), - )); - } - MergeStrategy::Merge => { - return Err(MergeRequestError::InvalidMergeOutcome( - "merge strategy requires clean or conflicts_resolved resolution".into(), - )); - } - } - Ok(()) -} - -fn apply_target_observation(mr: &mut MergeRequest, observed_target_commit: Option<&str>) { - mr.observed_target_commit = observed_target_commit.map(str::to_owned); -} - -fn validate_review_input(input: &SubmitReview) -> Result<()> { - if input.body.len() > MAX_REVIEW_BODY_BYTES { - return Err(MergeRequestError::TooLarge { - field: "review.body", - max: MAX_REVIEW_BODY_BYTES, - }); - } - if input.findings.len() > MAX_FINDINGS { - return Err(MergeRequestError::TooLarge { - field: "review.findings", - max: MAX_FINDINGS, - }); - } - for finding in &input.findings { - nonempty("finding.severity", &finding.severity)?; - nonempty("finding.body", &finding.body)?; - for (field, value) in [ - ("finding.severity", Some(finding.severity.as_str())), - ("finding.code", finding.code.as_deref()), - ("finding.path", finding.path.as_deref()), - ("finding.body", Some(finding.body.as_str())), - ] { - if value.is_some_and(|value| value.len() > MAX_FIELD_BYTES) { - return Err(MergeRequestError::TooLarge { - field, - max: MAX_FIELD_BYTES, - }); - } - } - } - Ok(()) -} - -fn ensure_open(mr: &MergeRequest) -> Result<()> { - if mr.state != MergeRequestState::Open { - Err(MergeRequestError::NotOpen(mr.state.as_str().into())) - } else { - Ok(()) - } -} -fn nonempty(name: &'static str, value: &str) -> Result<()> { - if value.trim().is_empty() { - Err(MergeRequestError::Empty(name)) - } else { - Ok(()) - } -} -fn token_hash(token: &str) -> String { - Sha256::digest(token.as_bytes()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} -fn completion_fingerprint(input: &CompleteMergeRequest) -> String { - completion_fingerprint_parts( - &input.ticket_id, - &input.expected_revision_id, - &input.target_commit, - &input.source_commit, - &input.result_commit, - input.strategy.as_str(), - input.resolution.as_str(), - &input.implementation_assignment_id, - &input.completion_actor_runtime_id, - &input.completion_actor_worker_id, - ) -} - -#[allow(clippy::too_many_arguments)] -fn completion_fingerprint_parts( - ticket_id: &str, - revision_id: &str, - target_commit: &str, - source_commit: &str, - result_commit: &str, - strategy: &str, - resolution: &str, - implementation_assignment_id: &str, - completion_actor_runtime_id: &str, - completion_actor_worker_id: &str, -) -> String { - token_hash(&format!( - "workspace_orchestrator\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}", + merge_request_id: &str, +) -> Result, MergeRequestError> { + let row: Option<( + String, + String, + String, + String, + String, + String, + String, + String, + String, + String, + )> = conn + .query_row( + "SELECT ticket_id, repository_id, state, selector_from, selector_to, + opened_by_runtime_id, opened_by_worker_id, created_at, updated_at, + merge_request_id + FROM merge_requests + WHERE workspace_id = ?1 AND merge_request_id = ?2", + params![workspace_id, merge_request_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + )) + }, + ) + .optional()?; + let Some(( ticket_id, + repository_id, + state, + selector_from, + selector_to, + opened_runtime, + opened_worker, + created_at, + updated_at, + id, + )) = row + else { + return Ok(None); + }; + let mut stmt = conn.prepare( + "SELECT kind, payload_json FROM merge_request_thread_events + WHERE workspace_id = ?1 AND merge_request_id = ?2 ORDER BY event_seq", + )?; + let rows = stmt + .query_map(params![workspace_id, merge_request_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + let mut thread = Vec::with_capacity(rows.len()); + for (kind, payload) in rows { + thread.push(match kind.as_str() { + "request_for_review" => { + MergeRequestThreadEvent::RequestForReview(decode_payload(&payload)?) + } + "review" => MergeRequestThreadEvent::Review(decode_payload(&payload)?), + "merge" => MergeRequestThreadEvent::Merge(decode_payload(&payload)?), + "reopen" => MergeRequestThreadEvent::Reopen(decode_payload(&payload)?), + "close" => MergeRequestThreadEvent::Close(decode_payload(&payload)?), + other => { + return Err(MergeRequestError::Corrupt(format!( + "unknown merge request thread event kind `{other}`" + ))); + } + }); + } + Ok(Some(MergeRequest { + workspace_id: workspace_id.to_string(), + merge_request_id: id, + ticket_id, + repository_id, + state: MergeRequestState::parse(&state)?, + selector_from, + selector_to, + opened_by_worker: WorkerIdentity { + runtime_id: opened_runtime, + worker_id: opened_worker, + }, + created_at: parse_timestamp(&created_at)?, + updated_at: parse_timestamp(&updated_at)?, + thread, + })) +} + +fn decode_payload Deserialize<'de>>(value: &str) -> Result { + serde_json::from_str(value).map_err(|error| MergeRequestError::Corrupt(error.to_string())) +} + +fn parse_timestamp(value: &str) -> Result, MergeRequestError> { + DateTime::parse_from_rfc3339(value) + .map(|value| value.with_timezone(&Utc)) + .map_err(|error| MergeRequestError::Corrupt(error.to_string())) +} + +pub fn migrate(conn: &Connection) -> Result<(), MergeRequestError> { + let version = schema_version(conn)?; + match version { + None => create_latest_schema(conn), + Some(SCHEMA_VERSION) => verify_latest_schema(conn), + Some(PREVIOUS_SCHEMA_VERSION) => migrate_v11_to_v12(conn), + Some(other) => Err(MergeRequestError::Operation(format!( + "unsupported merge request schema version {other}; expected {PREVIOUS_SCHEMA_VERSION} or {SCHEMA_VERSION}" + ))), + } +} + +fn schema_version(conn: &Connection) -> Result, MergeRequestError> { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'merge_request_schema')", + [], + |row| row.get(0), + )?; + if !exists { + return Ok(None); + } + conn.query_row( + "SELECT version FROM merge_request_schema WHERE singleton = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +fn create_latest_schema(conn: &Connection) -> Result<(), MergeRequestError> { + let tx = conn.unchecked_transaction()?; + create_v12_tables(&tx)?; + tx.execute( + "INSERT INTO merge_request_schema (singleton, version) VALUES (1, ?1)", + params![SCHEMA_VERSION], + )?; + foreign_key_check(&tx)?; + tx.commit()?; + Ok(()) +} + +fn migrate_v11_to_v12(conn: &Connection) -> Result<(), MergeRequestError> { + let tx = conn.unchecked_transaction()?; + let incompatible: Option = tx + .query_row( + "SELECT mr.merge_request_id + FROM merge_requests mr + LEFT JOIN merge_request_revisions r + ON r.workspace_id = mr.workspace_id + AND r.revision_id = mr.current_revision_id + WHERE r.revision_id IS NULL + LIMIT 1", + [], + |row| row.get(0), + ) + .optional()?; + if let Some(id) = incompatible { + return Err(MergeRequestError::Operation(format!( + "cannot migrate merge request `{id}`: current revision is missing" + ))); + } + + tx.execute_batch( + "ALTER TABLE merge_requests RENAME TO merge_requests_v11; + ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11; + ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11; + ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11; + ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11; + ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;", + )?; + create_v12_domain_tables(&tx)?; + tx.execute( + "INSERT INTO merge_requests ( + workspace_id, merge_request_id, ticket_id, repository_id, state, + selector_from, selector_to, opened_by_runtime_id, opened_by_worker_id, + created_at, updated_at + ) + SELECT mr.workspace_id, mr.merge_request_id, mr.ticket_id, mr.repository_id, + CASE mr.state WHEN 'draft' THEN 'open' ELSE mr.state END, + r.head_commit, mr.target_ref_selector, + mr.opened_by_worker_runtime_id, mr.opened_by_worker_id, + mr.created_at, mr.updated_at + FROM merge_requests_v11 mr + JOIN merge_request_revisions_v11 r + ON r.workspace_id = mr.workspace_id AND r.revision_id = mr.current_revision_id", + [], + )?; + + migrate_v11_events(&tx)?; + tx.execute_batch( + "DROP TABLE merge_request_reviewer_child_sessions_v11; + DROP TABLE merge_request_completion_operations_v11; + DROP TABLE merge_request_reviews_v11; + DROP TABLE merge_request_review_attempts_v11; + DROP TABLE merge_request_revisions_v11; + DROP TABLE merge_requests_v11; + UPDATE merge_request_schema SET version = 12 WHERE singleton = 1;", + )?; + foreign_key_check(&tx)?; + tx.commit()?; + Ok(()) +} + +fn migrate_v11_events(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { + let mut stmt = tx.prepare( + "SELECT r.workspace_id, r.merge_request_id, r.revision_id, r.base_commit, r.head_commit, + r.changed_paths_json, r.summary, r.assignment_id, + r.coder_worker_runtime_id, r.coder_worker_id, r.created_at + FROM merge_request_revisions_v11 r + ORDER BY r.workspace_id, r.merge_request_id, r.created_at, r.revision_id", + )?; + let revisions = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + row.get::<_, String>(10)?, + )) + })? + .collect::, _>>()?; + drop(stmt); + for ( + workspace_id, + mr_id, revision_id, - target_commit, - source_commit, - result_commit, + base, + head, + paths_json, + summary, + assignment, + runtime, + worker, + created_at, + ) in revisions + { + let event_seq = next_event_seq_tx(tx, &workspace_id, &mr_id)?; + let event = RequestForReviewEvent { + event_seq, + base_commit: base, + head_commit: head, + changed_paths: serde_json::from_str(&paths_json).map_err(|error| { + MergeRequestError::Operation(format!( + "cannot migrate revision `{revision_id}` changed paths: {error}" + )) + })?, + summary, + assignment_id: assignment, + requested_by: WorkerIdentity { + runtime_id: runtime, + worker_id: worker, + }, + created_at: parse_timestamp(&created_at)?, + }; + insert_event_tx( + tx, + &workspace_id, + &mr_id, + "request_for_review", + &event, + event.created_at, + None, + )?; + + let reviews = { + let mut reviews_stmt = tx.prepare( + "SELECT reviewer_worker_runtime_id, reviewer_worker_id, reviewer_profile, + decision, body, findings_json, created_at + FROM merge_request_reviews_v11 + WHERE workspace_id = ?1 AND revision_id = ?2 + ORDER BY created_at, review_id", + )?; + reviews_stmt + .query_map(params![workspace_id, revision_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + )) + })? + .collect::, _>>()? + }; + for ( + review_runtime, + review_worker, + profile, + decision, + body, + findings_json, + review_created, + ) in reviews + { + let review_seq = next_event_seq_tx(tx, &workspace_id, &mr_id)?; + let review = ReviewEvent { + event_seq: review_seq, + request_event_seq: event_seq, + head_commit: event.head_commit.clone(), + reviewer_worker: WorkerIdentity { + runtime_id: review_runtime, + worker_id: review_worker, + }, + reviewer_profile: profile, + decision: match decision.as_str() { + "approve" => ReviewDecision::Approve, + "request_changes" => ReviewDecision::RequestChanges, + other => { + return Err(MergeRequestError::Operation(format!( + "cannot migrate unknown review decision `{other}`" + ))); + } + }, + body, + findings: serde_json::from_str(&findings_json).map_err(|error| { + MergeRequestError::Operation(format!("cannot migrate review findings: {error}")) + })?, + created_at: parse_timestamp(&review_created)?, + }; + insert_event_tx( + tx, + &workspace_id, + &mr_id, + "review", + &review, + review.created_at, + None, + )?; + } + } + + let mut completion_stmt = tx.prepare( + "SELECT workspace_id, merge_request_id, operation_id, target_commit, source_commit, + result_commit, strategy, resolution, requested_by_runtime_id, + requested_by_worker_id, completed_at + FROM merge_request_completion_operations_v11 + WHERE status = 'succeeded' + ORDER BY completed_at, operation_id", + )?; + let completions = completion_stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + row.get::<_, Option>(10)?, + )) + })? + .collect::, _>>()?; + drop(completion_stmt); + for ( + workspace_id, + mr_id, + operation_id, + target, + source, + result, strategy, resolution, - implementation_assignment_id, - completion_actor_runtime_id, - completion_actor_worker_id - )) + runtime, + worker, + completed_at, + ) in completions + { + let created_at = parse_timestamp(completed_at.as_deref().ok_or_else(|| { + MergeRequestError::Operation(format!( + "cannot migrate completion `{operation_id}` without completed_at" + )) + })?)?; + let event = MergeEvent { + event_seq: next_event_seq_tx(tx, &workspace_id, &mr_id)?, + operation_id, + target_commit: target, + source_commit: source, + result_commit: result, + strategy: match strategy.as_str() { + "fast_forward" => MergeStrategy::FastForward, + "merge" => MergeStrategy::Merge, + other => { + return Err(MergeRequestError::Operation(format!( + "cannot migrate unknown merge strategy `{other}`" + ))); + } + }, + resolution: match resolution.as_str() { + "none" => ConflictResolution::None, + "clean" => ConflictResolution::Clean, + "conflicts_resolved" => ConflictResolution::ConflictsResolved, + other => { + return Err(MergeRequestError::Operation(format!( + "cannot migrate unknown conflict resolution `{other}`" + ))); + } + }, + merged_by: WorkerIdentity { + runtime_id: runtime, + worker_id: worker, + }, + created_at, + }; + insert_event_tx( + tx, + &workspace_id, + &mr_id, + "merge", + &event, + created_at, + Some(&event.operation_id), + )?; + } + Ok(()) } -fn db(error: rusqlite::Error) -> MergeRequestError { - MergeRequestError::Database(error.to_string()) + +fn create_v12_tables(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { + tx.execute_batch( + "CREATE TABLE merge_request_schema ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + version INTEGER NOT NULL + );", + )?; + create_v12_domain_tables(tx) +} + +fn create_v12_domain_tables(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { + tx.execute_batch( + "CREATE TABLE merge_requests ( + workspace_id TEXT NOT NULL, + merge_request_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('open', 'merged', 'closed')), + selector_from TEXT NOT NULL CHECK (length(trim(selector_from)) > 0), + selector_to TEXT NOT NULL CHECK (length(trim(selector_to)) > 0), + opened_by_runtime_id TEXT NOT NULL, + opened_by_worker_id TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, merge_request_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX merge_requests_one_open_per_ticket + ON merge_requests(workspace_id, ticket_id) WHERE state = 'open'; + CREATE INDEX merge_requests_ticket_history + ON merge_requests(workspace_id, ticket_id, created_at DESC); + + CREATE TABLE merge_request_thread_events ( + workspace_id TEXT NOT NULL, + merge_request_id TEXT NOT NULL, + event_seq INTEGER NOT NULL CHECK (event_seq > 0), + kind TEXT NOT NULL CHECK (kind IN ('request_for_review', 'review', 'merge', 'reopen', 'close')), + payload_json TEXT NOT NULL, + operation_id TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, merge_request_id, event_seq), + FOREIGN KEY (workspace_id, merge_request_id) + REFERENCES merge_requests(workspace_id, merge_request_id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX merge_request_merge_operation_ids + ON merge_request_thread_events(workspace_id, operation_id) + WHERE operation_id IS NOT NULL; + + CREATE TABLE merge_request_review_capabilities ( + workspace_id TEXT NOT NULL, + merge_request_id TEXT NOT NULL, + request_event_seq INTEGER NOT NULL, + capability_token TEXT PRIMARY KEY, + issued_by_assignment_id TEXT NOT NULL, + reviewer_runtime_id TEXT NOT NULL, + reviewer_worker_id TEXT NOT NULL, + reviewer_profile TEXT NOT NULL, + issued_at TEXT NOT NULL, + consumed_at TEXT, + status TEXT NOT NULL CHECK (status IN ('issued', 'consumed', 'revoked')), + FOREIGN KEY (workspace_id, merge_request_id, request_event_seq) + REFERENCES merge_request_thread_events(workspace_id, merge_request_id, event_seq) + ON DELETE CASCADE + ); + CREATE TABLE merge_request_reviewer_child_sessions ( + workspace_id TEXT NOT NULL, + child_session_id TEXT NOT NULL, + parent_runtime_id TEXT NOT NULL, + parent_worker_id TEXT NOT NULL, + reviewer_profile TEXT NOT NULL, + registered_at TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'consumed')), + PRIMARY KEY (workspace_id, child_session_id) + );", + )?; + Ok(()) +} + +fn verify_latest_schema(conn: &Connection) -> Result<(), MergeRequestError> { + for table in [ + "merge_requests", + "merge_request_thread_events", + "merge_request_review_capabilities", + "merge_request_reviewer_child_sessions", + ] { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + params![table], + |row| row.get(0), + )?; + if !exists { + return Err(MergeRequestError::Corrupt(format!( + "schema v{SCHEMA_VERSION} is missing table `{table}`" + ))); + } + } + foreign_key_check(conn) +} + +fn foreign_key_check(conn: &Connection) -> Result<(), MergeRequestError> { + let violation: Option<(String, i64)> = conn + .query_row("PRAGMA foreign_key_check", [], |row| { + Ok((row.get(0)?, row.get(1)?)) + }) + .optional()?; + if let Some((table, rowid)) = violation { + return Err(MergeRequestError::Corrupt(format!( + "foreign key violation in `{table}` row {rowid}" + ))); + } + Ok(()) } diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index ba795b1e..8b5654d5 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -1,297 +1,407 @@ -use merge_request::*; -use rusqlite::{Connection, params}; -use std::sync::{Arc, Barrier}; -use std::thread; -use tempfile::TempDir; +use std::sync::{Arc, Mutex}; -fn setup() -> (TempDir, SqliteMergeRequestStore) { +use chrono::{TimeZone, Utc}; +use merge_request::{ + AssignmentSource, CompleteMergeRequest, ConflictResolution, CurrentAssignment, FindingSeverity, + MergeRequestAuth, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, MergeStrategy, + OpenMergeRequest, ReadinessCheck, RegisterReviewCapability, RegisterReviewerChildSession, + RepositorySource, RequestForReview, RequestMergeRequestReview, ReviewDecision, ReviewFinding, + SubmitMergeRequestReview, +}; +use rusqlite::{Connection, params}; + +#[derive(Clone)] +struct Assignments { + current: Arc>, +} + +impl AssignmentSource for Assignments { + fn current_assignment( + &self, + _workspace_id: &str, + _ticket_id: &str, + ) -> Result, String> { + Ok(Some(self.current.lock().unwrap().clone())) + } +} + +struct Repositories; + +impl RepositorySource for Repositories { + fn repository_belongs_to_workspace( + &self, + workspace_id: &str, + repository_id: &str, + ) -> Result { + Ok(workspace_id == "W" && repository_id == "R") + } + + fn is_ancestor( + &self, + _workspace_id: &str, + _repository_id: &str, + ancestor: &str, + descendant: &str, + ) -> Result { + Ok(matches!( + (ancestor, descendant), + ("base", "head-1") | ("base", "head-2") + )) + } +} + +fn now(second: u32) -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, second) + .single() + .unwrap() +} + +fn fixture() -> (tempfile::TempDir, MergeRequestStore, Assignments) { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("server.db"); let conn = Connection::open(&path).unwrap(); - conn.execute_batch(r#" - PRAGMA foreign_keys=ON; - CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id)); - CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id)); - CREATE TABLE typed_ticket_events(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,kind TEXT NOT NULL,author TEXT,at TEXT,status TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index)); - CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,key TEXT NOT NULL,value TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,event_index,key)); - CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id)); - CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id)); - "#).unwrap(); - for ws in ["ws-a", "ws-b"] { - conn.execute("INSERT INTO repositories VALUES(?1,'repo')", params![ws]) - .unwrap(); - conn.execute( - "INSERT INTO typed_tickets VALUES(?1,'T1','inprogress',1,'t0')", - params![ws], - ) - .unwrap(); - conn.execute( - "INSERT INTO ticket_worker_assignments VALUES(?1,'T1','A1','R1','W1')", - params![ws], - ) - .unwrap(); - conn.execute( - "INSERT INTO ticket_current_worker_assignments VALUES(?1,'T1','A1','R1','W1')", - params![ws], - ) - .unwrap(); - } + conn.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE workspaces (workspace_id TEXT PRIMARY KEY); + CREATE TABLE repositories ( + workspace_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, repository_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) + ); + CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id) + ); + CREATE TABLE typed_ticket_events ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, + kind TEXT NOT NULL, author TEXT NOT NULL, at TEXT NOT NULL, + from_state TEXT, to_state TEXT, heading TEXT, body TEXT, + PRIMARY KEY (workspace_id, ticket_id, event_index) + ); + CREATE TABLE typed_ticket_event_attributes ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, + key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, key) + ); + INSERT INTO workspaces VALUES ('W'); + INSERT INTO repositories VALUES ('W', 'R'); + INSERT INTO typed_tickets VALUES ('W', 'T', 'inprogress', 1, '2026-07-26T12:00:00Z');", + ) + .unwrap(); drop(conn); - let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap(); - (dir, store) + let assignments = Assignments { + current: Arc::new(Mutex::new(CurrentAssignment { + assignment_id: "A1".into(), + ticket_id: "T".into(), + runtime_id: "runtime".into(), + worker_id: "coder".into(), + })), + }; + let store = + MergeRequestStore::open(&path, Arc::new(assignments.clone()), Arc::new(Repositories)) + .unwrap(); + (dir, store, assignments) } -fn revision(id: &str, ordinal: u64, head: &str) -> MergeRequestRevision { - MergeRequestRevision { - revision_id: id.into(), - ordinal, - base_commit: "base".into(), - head_commit: head.into(), - changed_paths: vec!["src/lib.rs".into()], - summary: format!("revision {id}"), - assignment_id: "A1".into(), - created_at: format!("t{ordinal}"), +fn auth(assignment_id: &str) -> MergeRequestAuth { + MergeRequestAuth { + workspace_id: "W".into(), + repository_id: "R".into(), + runtime_id: "runtime".into(), + worker_id: "coder".into(), + assignment_id: assignment_id.into(), } } -fn open(store: &SqliteMergeRequestStore) { +fn open(store: &MergeRequestStore) { store .open_merge_request(OpenMergeRequest { - merge_request_id: "MR1".into(), - ticket_id: "T1".into(), - repository_id: "repo".into(), - target_ref_selector: "refs/heads/develop".into(), - revision: revision("V1", 1, "head"), - authenticated_runtime_id: "R1".into(), - authenticated_worker_id: "W1".into(), - now: "t1".into(), + merge_request_id: "MR".into(), + ticket_id: "T".into(), + repository_id: "R".into(), + selector_from: "work/t-feature".into(), + selector_to: "develop".into(), + request: RequestForReview { + base_commit: "base".into(), + head_commit: "head-1".into(), + changed_paths: vec!["src/lib.rs".into()], + summary: "first candidate".into(), + }, + auth: auth("A1"), + now: now(1), }) .unwrap(); } -fn attempt(store: &SqliteMergeRequestStore, revision: &str, token: &str) { - let child = format!("child-{revision}"); +fn approve(store: &MergeRequestStore, expected_head_commit: &str, token: &str) { store .register_reviewer_child_session(RegisterReviewerChildSession { - parent_runtime_id: "R1".into(), - parent_worker_id: "W1".into(), - child_session_id: child.clone(), - now: "t2".into(), + workspace_id: "W".into(), + parent_runtime_id: "runtime".into(), + parent_worker_id: "coder".into(), + child_session_id: format!("child-{token}"), + reviewer_profile: "builtin:reviewer".into(), + now: now(2), }) .unwrap(); store - .register_review_attempt(RegisterReviewAttempt { - attempt_id: format!("attempt-{revision}"), - ticket_id: "T1".into(), - revision_id: revision.into(), - parent_assignment_id: "A1".into(), - parent_runtime_id: "R1".into(), - parent_worker_id: "W1".into(), - child_session_id: child, + .register_review_capability(RegisterReviewCapability { + ticket_id: "T".into(), + expected_head_commit: expected_head_commit.into(), + child_session_id: format!("child-{token}"), capability_token: token.into(), - now: "t2".into(), + auth: auth("A1"), + now: now(3), }) .unwrap(); -} - -fn approve(store: &SqliteMergeRequestStore, revision: &str, token: &str) { - attempt(store, revision, token); store - .submit_review(SubmitReview { - ticket_id: "T1".into(), - revision_id: revision.into(), + .submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + expected_head_commit: expected_head_commit.into(), capability_token: token.into(), decision: ReviewDecision::Approve, - body: "approved".into(), - findings: vec![], - now: "t3".into(), + body: "approved independently".into(), + findings: vec![ReviewFinding { + severity: FindingSeverity::Note, + path: None, + line: None, + message: "looks good".into(), + }], + now: now(4), }) .unwrap(); } -fn completion(operation_id: &str) -> CompleteMergeRequest { - CompleteMergeRequest { - operation_id: operation_id.into(), - ticket_id: "T1".into(), - expected_revision_id: "V1".into(), - target_commit: "base".into(), - source_commit: "head".into(), - result_commit: "head".into(), - strategy: MergeStrategy::FastForward, - resolution: MergeResolution::None, - implementation_assignment_id: "A1".into(), - completion_actor_runtime_id: "OR".into(), - completion_actor_worker_id: "OW".into(), - now: "t4".into(), - } -} - #[test] -fn target_movement_does_not_invalidate_source_revision_approval() { - let (_dir, store) = setup(); +fn thread_drives_review_readiness_and_completion_without_public_revision_identity() { + let (_dir, store, _assignments) = fixture(); open(&store); - approve(&store, "V1", "token-v1"); - for target in ["base", "advanced-target"] { - let readiness = store - .readiness_for_ticket_with_target("T1", Some(target)) - .unwrap(); + approve(&store, "head-1", "token-1"); + + let readiness = store + .readiness(ReadinessCheck { + ticket_id: "T".into(), + expected_head_commit: Some("head-1".into()), + auth: auth("A1"), + }) + .unwrap(); + assert!(readiness.ready, "{:?}", readiness.blockers); + + let merged = store + .complete(CompleteMergeRequest { + ticket_id: "T".into(), + expected_head_commit: "head-1".into(), + operation_id: "op-1".into(), + target_commit: "base".into(), + source_commit: "head-1".into(), + result_commit: "head-1".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth("A1"), + now: now(5), + }) + .unwrap(); + assert_eq!(merged.result_commit, "head-1"); + + let mr = store.get("W", "T").unwrap(); + assert_eq!(mr.state, MergeRequestState::Merged); + assert!(matches!( + mr.thread.last(), + Some(MergeRequestThreadEvent::Merge(_)) + )); + assert_eq!(mr.selector_from, "work/t-feature"); + assert_eq!(mr.selector_to, "develop"); + let json = serde_json::to_string(&mr).unwrap(); + for forbidden in [ + "revision_id", + "current_revision", + "attempt_id", + "review_attempt", + "head_tree", + "diff_digest", + "merged_revision_id", + ] { assert!( - readiness.ready, - "target movement must not invalidate source approval" + !json.contains(forbidden), + "unexpected `{forbidden}` in {json}" ); - assert_eq!(readiness.review_status, ReviewStatus::Approved); - assert_eq!(readiness.observed_target_commit.as_deref(), Some(target)); } +} + +#[test] +fn new_review_request_invalidates_prior_approval_and_fences_stale_capability() { + let (_dir, store, _assignments) = fixture(); + open(&store); + approve(&store, "head-1", "token-1"); store - .add_revision(AddRevision { - ticket_id: "T1".into(), - expected_current_revision_id: "V1".into(), - revision: revision("V2", 2, "head2"), - authenticated_runtime_id: "R1".into(), - authenticated_worker_id: "W1".into(), - now: "t5".into(), + .request_review(RequestMergeRequestReview { + ticket_id: "T".into(), + expected_head_commit: "head-1".into(), + request: RequestForReview { + base_commit: "base".into(), + head_commit: "head-2".into(), + changed_paths: vec!["src/lib.rs".into(), "tests/store.rs".into()], + summary: "address review".into(), + }, + auth: auth("A1"), + now: now(6), }) .unwrap(); - assert_eq!( - store.readiness_for_ticket("T1").unwrap().review_status, - ReviewStatus::Pending + + let readiness = store + .readiness(ReadinessCheck { + ticket_id: "T".into(), + expected_head_commit: Some("head-2".into()), + auth: auth("A1"), + }) + .unwrap(); + assert!(!readiness.ready); + assert!( + readiness + .blockers + .iter() + .any(|value| value.contains("no review result")) + ); + assert!( + store + .submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + expected_head_commit: "head-1".into(), + capability_token: "token-1".into(), + decision: ReviewDecision::Approve, + body: "stale".into(), + findings: vec![], + now: now(7), + }) + .is_err() ); } #[test] -fn completion_records_one_final_merge_outcome_and_replays_idempotently() { - let (_dir, store) = setup(); +fn assignment_change_rejects_candidate_mutation() { + let (_dir, store, assignments) = fixture(); open(&store); - approve(&store, "V1", "token-v1"); - let first = store.complete(completion("OP1")).unwrap(); - assert!(!first.replayed); - assert_eq!(first.ticket_state, "done"); - let merged = store.show_for_ticket("T1").unwrap().unwrap(); - assert_eq!(merged.state, MergeRequestState::Merged); - assert_eq!(merged.merged_revision_id.as_deref(), Some("V1")); - assert_eq!(merged.merged_target_commit.as_deref(), Some("base")); - assert_eq!(merged.merged_result_commit.as_deref(), Some("head")); - assert_eq!(merged.merge_strategy, Some(MergeStrategy::FastForward)); - assert_eq!(merged.merge_resolution, Some(MergeResolution::None)); - assert_eq!(merged.merged_by_runtime_id.as_deref(), Some("OR")); - assert_eq!(merged.merged_by_worker_id.as_deref(), Some("OW")); - assert!(store.complete(completion("OP1")).unwrap().replayed); - let mut conflicting = completion("OP1"); - conflicting.target_commit = "other".into(); - assert!(matches!( - store.complete(conflicting), - Err(MergeRequestError::OperationConflict) - )); + *assignments.current.lock().unwrap() = CurrentAssignment { + assignment_id: "A2".into(), + ticket_id: "T".into(), + runtime_id: "runtime".into(), + worker_id: "other".into(), + }; + let error = store + .request_review(RequestMergeRequestReview { + ticket_id: "T".into(), + expected_head_commit: "head-1".into(), + request: RequestForReview { + base_commit: "base".into(), + head_commit: "head-2".into(), + changed_paths: vec![], + summary: String::new(), + }, + auth: auth("A1"), + now: now(6), + }) + .unwrap_err(); + assert!(error.to_string().contains("current assigned worker")); } #[test] -fn completion_rejects_invalid_or_non_current_source_outcomes_without_side_effects() { - let (_dir, store) = setup(); - open(&store); - approve(&store, "V1", "token-v1"); - let mut invalid_ff = completion("bad-ff"); - invalid_ff.result_commit = "different".into(); - assert!(matches!( - store.complete(invalid_ff), - Err(MergeRequestError::InvalidMergeOutcome(_)) - )); - let mut invalid_merge = completion("bad-merge"); - invalid_merge.strategy = MergeStrategy::Merge; - assert!(matches!( - store.complete(invalid_merge), - Err(MergeRequestError::InvalidMergeOutcome(_)) - )); - let mut wrong_source = completion("wrong-source"); - wrong_source.source_commit = "not-approved".into(); - wrong_source.result_commit = "not-approved".into(); - assert!(matches!( - store.complete(wrong_source), - Err(MergeRequestError::InvalidMergeOutcome(_)) - )); +fn v11_migration_builds_thread_events_and_removes_revision_tables() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "PRAGMA foreign_keys = OFF; + CREATE TABLE workspaces (workspace_id TEXT PRIMARY KEY); + CREATE TABLE repositories ( + workspace_id TEXT NOT NULL, repository_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, repository_id) + ); + INSERT INTO workspaces VALUES ('W'); + INSERT INTO repositories VALUES ('W', 'R'); + CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id) + ); + INSERT INTO typed_tickets VALUES ('W', 'T'); + CREATE TABLE merge_request_schema (singleton INTEGER PRIMARY KEY, version INTEGER NOT NULL); + INSERT INTO merge_request_schema VALUES (1, 11); + CREATE TABLE merge_requests ( + workspace_id TEXT, merge_request_id TEXT, ticket_id TEXT, repository_id TEXT, + state TEXT, target_ref_selector TEXT, current_revision_id TEXT, + opened_by_worker_runtime_id TEXT, opened_by_worker_id TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE merge_request_revisions ( + workspace_id TEXT, revision_id TEXT, merge_request_id TEXT, base_commit TEXT, + head_commit TEXT, changed_paths_json TEXT, summary TEXT, assignment_id TEXT, + coder_worker_runtime_id TEXT, coder_worker_id TEXT, created_at TEXT + ); + CREATE TABLE merge_request_review_attempts (attempt_id TEXT); + CREATE TABLE merge_request_reviews ( + workspace_id TEXT, review_id TEXT, revision_id TEXT, + reviewer_worker_runtime_id TEXT, reviewer_worker_id TEXT, reviewer_profile TEXT, + decision TEXT, body TEXT, findings_json TEXT, created_at TEXT + ); + CREATE TABLE merge_request_completion_operations ( + workspace_id TEXT, merge_request_id TEXT, operation_id TEXT, target_commit TEXT, + source_commit TEXT, result_commit TEXT, strategy TEXT, resolution TEXT, + requested_by_runtime_id TEXT, requested_by_worker_id TEXT, completed_at TEXT, status TEXT + ); + CREATE TABLE merge_request_reviewer_child_sessions (child_session_id TEXT); + INSERT INTO merge_requests VALUES ( + 'W', 'MR', 'T', 'R', 'open', 'develop', 'REV', + 'runtime', 'coder', '2026-07-26T12:00:00Z', '2026-07-26T12:00:00Z' + ); + INSERT INTO merge_request_revisions VALUES ( + 'W', 'REV', 'MR', 'base', 'head-1', '[\"src/lib.rs\"]', 'legacy', 'A1', + 'runtime', 'coder', '2026-07-26T12:00:00Z' + ); + INSERT INTO merge_request_reviews VALUES ( + 'W', 'REVIEW', 'REV', 'runtime', 'child', 'builtin:reviewer', + 'approve', 'approved', '[]', '2026-07-26T12:00:01Z' + );", + ) + .unwrap(); + + merge_request::migrate(&conn).unwrap(); assert_eq!( - store.show_for_ticket("T1").unwrap().unwrap().state, - MergeRequestState::Open + conn.query_row("SELECT version FROM merge_request_schema", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 12 + ); + assert_eq!( + conn.query_row("SELECT selector_from FROM merge_requests", [], |row| row + .get::<_, String>( + 0 + )) + .unwrap(), + "head-1" ); - let conn = Connection::open(store.db_path()).unwrap(); assert_eq!( conn.query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id='ws-a' AND ticket_id='T1'", + "SELECT COUNT(*) FROM merge_request_thread_events", [], - |row| row.get::<_, String>(0) + |row| row.get::<_, i64>(0) ) .unwrap(), - "inprogress" + 2 ); -} - -#[test] -fn concurrent_completion_converges_on_one_operation() { - let (_dir, store) = setup(); - open(&store); - approve(&store, "V1", "token-v1"); - let path = store.db_path().to_path_buf(); - let barrier = Arc::new(Barrier::new(3)); - let mut handles = Vec::new(); - for _ in 0..2 { - let path = path.clone(); - let barrier = barrier.clone(); - handles.push(thread::spawn(move || { - let store = SqliteMergeRequestStore::open_verified(path, "ws-a").unwrap(); - barrier.wait(); - store.complete(completion("OP-concurrent")) - })); + for removed in [ + "merge_request_revisions", + "merge_request_review_attempts", + "merge_request_reviews", + "merge_request_completion_operations", + ] { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + params![removed], + |row| row.get(0), + ) + .unwrap(); + assert!(!exists, "legacy table `{removed}` still exists"); } - barrier.wait(); - let outcomes: Vec<_> = handles - .into_iter() - .map(|handle| handle.join().unwrap().unwrap()) - .collect(); - assert_eq!( - outcomes.iter().filter(|outcome| !outcome.replayed).count(), - 1 - ); - assert_eq!( - outcomes.iter().filter(|outcome| outcome.replayed).count(), - 1 - ); -} - -#[test] -fn reviewer_attempt_is_bound_to_direct_child_and_current_assignment() { - let (_dir, store) = setup(); - open(&store); - store - .register_reviewer_child_session(RegisterReviewerChildSession { - parent_runtime_id: "R1".into(), - parent_worker_id: "W1".into(), - child_session_id: "child".into(), - now: "t2".into(), - }) - .unwrap(); - store - .register_review_attempt(RegisterReviewAttempt { - attempt_id: "attempt".into(), - ticket_id: "T1".into(), - revision_id: "V1".into(), - parent_assignment_id: "A1".into(), - parent_runtime_id: "R1".into(), - parent_worker_id: "W1".into(), - child_session_id: "child".into(), - capability_token: "token".into(), - now: "t2".into(), - }) - .unwrap(); - let wrong_token = store.submit_review(SubmitReview { - ticket_id: "T1".into(), - revision_id: "V1".into(), - capability_token: "wrong".into(), - decision: ReviewDecision::Approve, - body: "approved".into(), - findings: vec![], - now: "t3".into(), - }); - assert!(matches!( - wrong_token, - Err(MergeRequestError::InvalidReviewAttempt) - )); }