diff --git a/.yoi/workflow/ticket-intake-workflow.md b/.yoi/workflow/ticket-intake-workflow.md index f2f044e5..4965e32f 100644 --- a/.yoi/workflow/ticket-intake-workflow.md +++ b/.yoi/workflow/ticket-intake-workflow.md @@ -70,11 +70,10 @@ Intake は以下を行う。 利用可能なら、以下の typed Ticket tools を使う。 -- `TicketList`: 既存 Ticket の一覧・重複確認。 -- `TicketShow`: 関連 Ticket の詳細確認。 +- `QueryTicket`: 既存 Ticket の一覧・重複確認。 +- `ShowTicket`: 関連 Ticket の詳細確認。 - `TicketCreate`: 合意済み Ticket の作成。 - `TicketComment`: 既存 Ticket refinement / decision / plan の記録。 -- `TicketDoctor`: 必要に応じた整合性確認。 Intake は `MergeRequest*`, `TicketWorkflowState`, `TicketClose` を通常使わない。review authority は assigned Coder が起動した read-only direct-child Reviewer の immutable Merge Request attempt に属し、completion / merge / close は各guarded workflowの責務である。 @@ -96,7 +95,7 @@ Ticket tools が利用できない環境では、勝手に file write で代替 ### 2. 既存 Ticket を確認する -`TicketList` / `TicketShow` で duplicate / related work を探す。 +`QueryTicket` / `ShowTicket` で duplicate / related work を探す。 確認観点: diff --git a/.yoi/workflow/ticket-orchestrator-routing.md b/.yoi/workflow/ticket-orchestrator-routing.md index e48e2bf4..df574793 100644 --- a/.yoi/workflow/ticket-orchestrator-routing.md +++ b/.yoi/workflow/ticket-orchestrator-routing.md @@ -36,8 +36,8 @@ TicketCreate / TicketComment Orchestrator は以下を行う。 -- Ticket を `TicketShow` で読む。 -- 必要に応じて関連 Ticket を `TicketList` / `TicketShow` で確認する。 +- Ticket を `ShowTicket` で読む。 +- 必要に応じて関連 Ticket を `QueryTicket` / `ShowTicket` で確認する。 - Ticket body / thread / artifacts / resolution / review / implementation report を読む。 - Ticket が Objective context と結びついている場合は、Objective を medium-term goal / motivation / strategy / success criteria / decision context として読む。ただし Objective context は判断背景であり、Ticket body/thread/artifacts や explicit Ticket relations / OrchestrationPlan records を読む代替ではない。 - repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。 @@ -73,21 +73,21 @@ Orchestrator は以下を行う。 利用可能なら、以下を使う。 -- `TicketList`: routing 候補や関連 Ticket の確認。 -- `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata と derived inverse/blocker view を確認。 +- `QueryTicket`: routing 候補、関連 Ticket、project-level forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) と derived blocker summary を bounded filter/projection で確認する。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない。 +- `ShowTicket`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata、linked Objective、assignment、implementation/review evidence を確認する。 - `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。 - `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。 -- `TicketRelationQuery`: project-level の forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) を読む。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない。 +- `TicketDependencyCheck`: queue/acceptance 直前の typed dependency readiness guard に使う。 +- `TicketRelationRecord` / `TicketRelationRemove`: ユーザー合意済みの durable project relation を明示的に更新する場合だけ使う。 - `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。 - `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。 - `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。 -- `TicketDoctor`: routing 前後の整合性確認。 `TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。 ## Queued acceptance contract -- `queued -> inprogress` acceptance の直前に `TicketShow` / `TicketRelationQuery` の relation blockers を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。 +- `queued -> inprogress` acceptance の直前に `ShowTicket` / `QueryTicket` の relation blocker projection を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。 - Relation metadata は project-level constraint であり、OrchestrationPlan は runtime ordering/capacity decision である。relation を OrchestrationPlan で代替しないし、OrchestrationPlan を durable dependency authority として扱わない。 `state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録、risk domain に応じた bounded project context を読んで、次のどちらかを行う。 @@ -100,7 +100,7 @@ Orchestrator は以下を行う。 Parallel acceptance pass: -- 明示的な queue review 中に複数の queued Ticket が見える場合、Orchestrator は最初の1件の完了待ちを default にしない。各 Ticket について Ticket body/thread/artifacts、TicketRelationQuery、TicketOrchestrationPlanQuery、workspace/worktree dirty state、visible Pods、既存 branches、conflict/dependency notes を確認する。 +- 明示的な queue review 中に複数の queued Ticket が見える場合、Orchestrator は最初の1件の完了待ちを default にしない。各 Ticket について Ticket body/thread/artifacts、QueryTicket の relation/blocker projection、TicketOrchestrationPlanQuery、workspace/worktree dirty state、visible Pods、既存 branches、conflict/dependency notes を確認する。 - 追加で開始してよいのは、blocking relation/dependency がなく、`do_not_parallelize` または applicable conflict record がなく、source/write surfaces が disjoint または conflict risk が小さく機械的で、coder/reviewer follow-up capacity があり、acceptance basis となる Ticket thread/plan/workspace records を side effect 前に記録・commit でき、別 worktree/branch/scope を切れる Ticket だけである。 - capacity が見えるのに queued Ticket を idle にする場合は、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかの bounded reason を記録する。 - この pass は scheduler、background runner、resource graph solver、automatic queue drain loop ではない。unqueued Ticket を開始せず、各 Ticket の `queued -> inprogress` acceptance を個別に記録する。 @@ -269,8 +269,8 @@ Action: ### 1. 状態確認 - `git state --short --branch` -- `TicketShow ` -- 関連 Ticket の `TicketList` / `TicketShow` +- `ShowTicket ` +- 関連 Ticket の `QueryTicket` / `ShowTicket` - 必要に応じて docs/code/workflow/history - 必要に応じて visible Pods / worktrees / branches diff --git a/Cargo.lock b/Cargo.lock index e2cebe1b..4d76aaca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2533,11 +2533,13 @@ dependencies = [ name = "merge-request" version = "0.1.0" dependencies = [ + "chrono", "rusqlite", "serde", - "sha2 0.11.0", + "serde_json", "tempfile", "thiserror 2.0.18", + "uuid", ] [[package]] diff --git a/crates/merge-request/Cargo.toml b/crates/merge-request/Cargo.toml index d5865a9c..7c0b5664 100644 --- a/crates/merge-request/Cargo.toml +++ b/crates/merge-request/Cargo.toml @@ -5,10 +5,12 @@ 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 +uuid = { workspace = true, features = ["v7"] } [dev-dependencies] tempfile.workspace = true diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 04c2b1bb..ca8821be 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -1,2829 +1,1395 @@ -//! 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 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 std::{ + path::Path, + sync::{Arc, Mutex}, +}; use thiserror::Error; +use uuid::Uuid; -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; +const MAX_BODY_BYTES: usize = 16 * 1024; #[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", - } - } - - fn parse(value: &str) -> Self { - match value { - "draft" => Self::Draft, - "closed" => Self::Closed, - "merged" => Self::Merged, - _ => Self::Open, + fn parse(v: &str) -> Result { + match v { + "draft" | "open" => Ok(Self::Open), + "merged" => Ok(Self::Merged), + "closed" => Ok(Self::Closed), + _ => Err(MergeRequestError::Corrupt(format!("unknown state `{v}`"))), } } } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { Approve, 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, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewFinding { + pub severity: FindingSeverity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, + pub body: String, +} +#[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, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MergeRequestTargetStatus { - Known, - Unknown, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewRequestedEvent { + pub event_id: String, + pub sequence: u64, + pub subject_ref: String, + pub requested_by: WorkerIdentity, + pub reviewer: WorkerIdentity, + pub created_at: DateTime, } - -impl MergeRequestTargetStatus { - fn parse(value: &str) -> Self { - match value { - "known" => Self::Known, - _ => Self::Unknown, - } - } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewEvent { + pub event_id: String, + pub sequence: u64, + pub request_event_id: String, + pub subject_ref: String, + pub decision: ReviewDecision, + pub body: String, + pub findings: Vec, + pub reviewer: WorkerIdentity, + pub created_at: DateTime, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewRevokedEvent { + pub event_id: String, + pub sequence: u64, + pub review_event_id: String, + pub subject_ref: String, + pub reason: String, + pub revoked_by: WorkerIdentity, + pub created_at: DateTime, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewCancelledEvent { + pub event_id: String, + pub sequence: u64, + pub request_event_id: String, + pub subject_ref: String, + pub reason: String, + pub created_at: DateTime, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommentEvent { + pub event_id: String, + pub sequence: u64, + pub body: String, + pub author: WorkerIdentity, + pub created_at: DateTime, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MergeStrategy { FastForward, 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 { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MergeEvent { + pub event_id: String, + pub sequence: u64, + pub operation_id: String, + pub approval_event_id: String, + pub approved_source_ref: String, + pub target_ref_before: String, + pub target_ref_after: String, + pub strategy: MergeStrategy, + pub resolution: ConflictResolution, + pub merged_by: WorkerIdentity, + pub created_at: DateTime, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MergeRequestThreadEvent { + ReviewRequested(ReviewRequestedEvent), + Review(ReviewEvent), + ReviewRevoked(ReviewRevokedEvent), + ReviewCancelled(ReviewCancelledEvent), + Comment(CommentEvent), + Merge(MergeEvent), +} +impl MergeRequestThreadEvent { + pub fn sequence(&self) -> u64 { match self { - Self::None => "none", - Self::Clean => "clean", - Self::ConflictsResolved => "conflicts_resolved", + Self::ReviewRequested(v) => v.sequence, + Self::Review(v) => v.sequence, + Self::ReviewRevoked(v) => v.sequence, + Self::ReviewCancelled(v) => v.sequence, + Self::Comment(v) => v.sequence, + Self::Merge(v) => v.sequence, } } - - fn parse(value: &str) -> Self { - match value { - "none" => Self::None, - "conflicts_resolved" => Self::ConflictsResolved, - _ => Self::Clean, + fn bound_bodies(&mut self) { + match self { + Self::Review(value) => { + truncate_body(&mut value.body); + for finding in &mut value.findings { + truncate_body(&mut finding.body); + } + } + Self::ReviewRevoked(value) => truncate_body(&mut value.reason), + Self::ReviewCancelled(value) => truncate_body(&mut value.reason), + Self::Comment(value) => truncate_body(&mut value.body), + Self::ReviewRequested(_) | Self::Merge(_) => {} } } } -#[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 merge_request_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, + pub selector_from: Option, + pub selector_to: String, + pub ticket_ids: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default)] + pub thread: Vec, +} +impl MergeRequest { + pub fn effective_review(&self, subject: &str) -> Option<&ReviewEvent> { + self.thread.iter().rev().find_map(|e|match e{MergeRequestThreadEvent::Review(v)if v.subject_ref==subject&&!self.thread.iter().any(|x|matches!(x,MergeRequestThreadEvent::ReviewRevoked(r)if r.review_event_id==v.event_id))=>Some(v),_=>None}) + } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] 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, + pub selector_from: String, + pub selector_to: String, + pub summary: String, + pub auth: MergeRequestAuth, + pub now: DateTime, } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AddRevision { +#[derive(Debug, Clone)] +pub struct RequestMergeRequestReview { 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 subject_ref: 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, + pub auth: MergeRequestAuth, + pub now: DateTime, } - #[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubmitReview { +pub struct RequestedMergeRequestReview { + pub request_event: ReviewRequestedEvent, +} +#[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 revision_id: String, + pub current_subject_ref: String, pub capability_token: String, pub decision: ReviewDecision, pub body: String, pub findings: Vec, - pub now: String, + pub now: DateTime, } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CompleteMergeRequest { - pub operation_id: String, +#[derive(Debug, Clone)] +pub struct RevokeMergeRequestReview { 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 review_event_id: String, + pub reason: String, + pub auth: MergeRequestAuth, + pub now: DateTime, +} +#[derive(Debug, Clone)] +pub struct RepairSelectorFrom { + pub workspace_id: String, + pub ticket_id: String, + pub selector_from: String, + pub resolved_subject_ref: String, + pub repaired_by: WorkerIdentity, + pub reason: String, + pub now: DateTime, +} +#[derive(Debug, Clone)] +pub struct ReadinessCheck { + pub ticket_id: String, + pub current_subject_ref: Option, + pub auth: MergeRequestAuth, } - #[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, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeRequestReadiness { - pub ticket_id: String, - pub merge_request_id: String, - pub revision_id: String, - pub target_ref_selector: Option, - pub observed_target_commit: Option, +pub struct ReadinessReport { pub ready: bool, - pub review_status: ReviewStatus, pub blockers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option, +} +#[derive(Debug, Clone)] +pub struct CompleteMergeRequest { + pub ticket_id: String, + pub operation_id: String, + pub approval_event_id: String, + pub current_subject_ref: String, + pub target_ref_before: String, + pub target_ref_after: String, + pub strategy: MergeStrategy, + pub resolution: ConflictResolution, + pub auth: MergeRequestAuth, + pub now: DateTime, +} +#[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 trait AssignmentSource: Send + Sync { + fn current_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result, String>; +} +pub trait RepositorySource: Send + Sync { + fn repository_belongs_to_workspace( + &self, + workspace_id: &str, + repository_id: &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), } -#[derive(Clone, Debug)] -pub struct SqliteMergeRequestStore { - db_path: PathBuf, - workspace_id: String, +pub struct MergeRequestStore { + conn: Arc>, + assignments: Arc, + repositories: Arc, } - -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()?; +impl MergeRequestStore { + pub fn open( + path: impl AsRef, + assignments: Arc, + repositories: Arc, + ) -> Result { + let conn = Connection::open(path)?; + conn.pragma_update(None, "foreign_keys", "ON")?; migrate(&conn)?; - Ok(store) - } - - 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) - } - - 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( - &self, - 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) - } - - 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( - &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, + 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()), + pub fn open_merge_request( + &self, + i: OpenMergeRequest, + ) -> Result { + bounded_body("summary", &i.summary)?; + for (n, v) in [ + ("merge_request_id", i.merge_request_id.as_str()), + ("ticket_id", &i.ticket_id), + ("repository_id", &i.repository_id), + ("selector_from", &i.selector_from), + ("selector_to", &i.selector_to), ] { - nonempty(name, value)?; + nonempty(n, v)? } - 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())) - }) + if i.selector_from == i.selector_to { + return Err(MergeRequestError::Validation( + "selectors must differ".into(), + )); + } + self.assigned(&i.auth, &i.ticket_id, &i.repository_id)?; + let mut c = self.lock()?; + let t = c.transaction()?; + let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?; + if conflict { + return Err(MergeRequestError::Conflict( + "Ticket already has an open Merge Request".into(), + )); + } + let now = i.now.to_rfc3339(); + t.execute( + "INSERT INTO merge_requests VALUES(?1,?2,?3,'open',?4,?5,?6,?6)", + params![ + i.auth.workspace_id, + i.merge_request_id, + i.repository_id, + i.selector_from, + i.selector_to, + now + ], + )?; + t.execute( + "INSERT INTO merge_request_ticket_relations VALUES(?1,?2,?3,'implements',?4)", + params![i.auth.workspace_id, i.merge_request_id, i.ticket_id, now], + )?; + if !i.summary.trim().is_empty() { + let e = CommentEvent { + event_id: Uuid::now_v7().to_string(), + sequence: 1, + body: i.summary, + author: WorkerIdentity { + runtime_id: i.auth.runtime_id, + worker_id: i.auth.worker_id, + }, + created_at: i.now, + }; + insert_event( + &t, + &i.auth.workspace_id, + &i.merge_request_id, + "comment", + &e, + i.now, + None, + )? + } + t.commit()?; + drop(c); + self.get(&i.auth.workspace_id, &i.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 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(()) - }) - } - - 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( - &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)?; - } - 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 }); - } - } 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 }) - }) - } - - 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 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())) - }) - } - - fn transition_open( - &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) - } - } - })(); - - 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(), + i: RegisterReviewerChildSession, + ) -> Result<(), MergeRequestError> { + if i.reviewer_profile != "builtin:reviewer" { + return Err(MergeRequestError::Unauthorized( + "reviewer profile mismatch".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); + self.lock()?.execute( + "INSERT INTO merge_request_reviewer_child_sessions VALUES(?1,?2,?3,?4,?5,?6,'active')", + params![ + i.workspace_id, + i.child_session_id, + i.parent_runtime_id, + i.parent_worker_id, + i.reviewer_profile, + i.now.to_rfc3339() + ], + )?; + Ok(()) } - - 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(), + pub fn request_review( + &self, + i: RequestMergeRequestReview, + ) -> Result { + nonempty("subject_ref", &i.subject_ref)?; + let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?; + if mr.state != MergeRequestState::Open || mr.selector_from.is_none() { + return Err(MergeRequestError::Conflict( + "Merge Request is not reviewable".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(), + let mut c = self.lock()?; + let t = c.transaction()?; + let child:Option<(String,String,String)>=t.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![i.auth.workspace_id,i.child_session_id,i.auth.runtime_id,i.auth.worker_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?))).optional()?; + let Some((rr, rw, p)) = child else { + return Err(MergeRequestError::Unauthorized( + "reviewer child attestation missing".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 start = cursor + relative; - let mut open = start + 5; - while open < bytes.len() && bytes[open].is_ascii_whitespace() { - open += 1; + if p != "builtin:reviewer" { + return Err(MergeRequestError::Unauthorized( + "reviewer profile mismatch".into(), + )); } - if open >= bytes.len() || bytes[open] != b'(' { - cursor = start + 5; - continue; + let e = ReviewRequestedEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + subject_ref: i.subject_ref, + requested_by: WorkerIdentity { + runtime_id: i.auth.runtime_id, + worker_id: i.auth.worker_id, + }, + reviewer: WorkerIdentity { + runtime_id: rr.clone(), + worker_id: rw.clone(), + }, + created_at: i.now, + }; + insert_event( + &t, + &mr.workspace_id, + &mr.merge_request_id, + "review_requested", + &e, + i.now, + None, + )?; + t.execute("INSERT INTO merge_request_review_grants VALUES(?1,?2,?3,?4,?5,?6,?7,?8,NULL,NULL,'issued')",params![mr.workspace_id,mr.merge_request_id,e.event_id,e.subject_ref,rr,rw,i.capability_token,i.now.to_rfc3339()])?; + t.execute("UPDATE merge_request_reviewer_child_sessions SET status='consumed' WHERE workspace_id=?1 AND child_session_id=?2",params![mr.workspace_id,i.child_session_id])?; + t.commit()?; + Ok(RequestedMergeRequestReview { request_event: e }) + } + pub fn submit_review( + &self, + i: SubmitMergeRequestReview, + ) -> Result { + bounded_body("review body", &i.body)?; + for finding in &i.findings { + bounded_body("review finding", &finding.body)?; } - 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 mut c = self.lock()?; + let t = c.transaction()?; + let g: Option<(String, String, String, String, String, String)> = t + .query_row( + "SELECT g.workspace_id,g.merge_request_id,g.request_event_id,g.subject_ref, + g.reviewer_runtime_id,g.reviewer_worker_id + FROM merge_request_review_grants g + JOIN merge_request_ticket_relations rel + ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id + JOIN merge_requests mr + ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id + WHERE g.capability_token=?1 AND rel.ticket_id=?2 + AND g.status='issued' AND mr.state='open'", + params![i.capability_token, i.ticket_id], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + )) + }, + ) + .optional()?; + let Some((ws, mr, req, subject, rr, rw)) = g else { + return Err(MergeRequestError::Unauthorized( + "review grant invalid".into(), + )); + }; + if subject != i.current_subject_ref { + let e = ReviewCancelledEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &ws, &mr)?, + request_event_id: req, + subject_ref: subject, + reason: "selector_from moved before submission".into(), + created_at: i.now, + }; + insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?; + t.execute("UPDATE merge_request_review_grants SET status='revoked',revoked_at=?2 WHERE capability_token=?1",params![i.capability_token,i.now.to_rfc3339()])?; + t.commit()?; + return Err(MergeRequestError::Conflict( + "selector_from moved; review cancelled".into(), + )); + } + let e = ReviewEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &ws, &mr)?, + request_event_id: req, + subject_ref: subject, + decision: i.decision, + body: i.body, + findings: i.findings, + reviewer: WorkerIdentity { + runtime_id: rr, + worker_id: rw, + }, + created_at: i.now, + }; + insert_event(&t, &ws, &mr, "review", &e, i.now, None)?; + t.execute("UPDATE merge_request_review_grants SET status='consumed',consumed_at=?2 WHERE capability_token=?1",params![i.capability_token,i.now.to_rfc3339()])?; + t.commit()?; + Ok(e) + } + pub fn revoke_review( + &self, + i: RevokeMergeRequestReview, + ) -> Result { + bounded_body("revocation reason", &i.reason)?; + let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?; + let r = mr + .thread + .iter() + .find_map(|x| match x { + MergeRequestThreadEvent::Review(v) if v.event_id == i.review_event_id => Some(v), + _ => None, + }) + .ok_or(MergeRequestError::NotFound)?; + if mr.thread.iter().any(|x|matches!(x,MergeRequestThreadEvent::ReviewRevoked(v)if v.review_event_id==r.event_id)){return Err(MergeRequestError::Conflict("already revoked".into()))} + let mut c = self.lock()?; + let t = c.transaction()?; + let e = ReviewRevokedEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + review_event_id: r.event_id.clone(), + subject_ref: r.subject_ref.clone(), + reason: i.reason, + revoked_by: WorkerIdentity { + runtime_id: i.auth.runtime_id, + worker_id: i.auth.worker_id, + }, + created_at: i.now, + }; + insert_event( + &t, + &mr.workspace_id, + &mr.merge_request_id, + "review_revoked", + &e, + i.now, + None, + )?; + t.commit()?; + Ok(e) + } + pub fn readiness(&self, i: ReadinessCheck) -> Result { + let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + let review = i + .current_subject_ref + .as_deref() + .and_then(|s| mr.effective_review(s)) + .cloned(); + let mut b = vec![]; + if mr.state != MergeRequestState::Open { + b.push("Merge Request is not open".into()) + } + if mr.selector_from.is_none() { + b.push("selector_from requires repair".into()) + } + match (&i.current_subject_ref, &review) { + (None, _) => b.push("selector_from could not be resolved".into()), + (Some(_), None) => b.push("current source ref has no valid review".into()), + (_, Some(r)) if r.decision == ReviewDecision::RequestChanges => { + b.push("current source ref requests changes".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(), - ); - } - cursor = end.max(start + 5); + Ok(ReadinessReport { + ready: b.is_empty(), + blockers: b, + subject_ref: i.current_subject_ref, + review, + }) + } + pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> { + let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?; + if let Some(existing) = mr.thread.iter().find_map(|event| match event { + MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => { + Some(value) + } + _ => None, + }) { + if existing.approval_event_id == i.approval_event_id + && existing.target_ref_before == i.target_ref_before + && existing.target_ref_after == i.target_ref_after + { + return Ok(()); + } + return Err(MergeRequestError::Conflict( + "operation fingerprint mismatch".into(), + )); + } + if mr.state != MergeRequestState::Open { + return Err(MergeRequestError::Conflict( + "Merge Request is not open".into(), + )); + } + let review = mr + .effective_review(&i.current_subject_ref) + .filter(|review| review.event_id == i.approval_event_id) + .ok_or_else(|| { + MergeRequestError::NotReady( + "approval is not the current effective review for the source ref".into(), + ) + })?; + if review.decision != ReviewDecision::Approve { + return Err(MergeRequestError::NotReady( + "current effective review does not approve the source ref".into(), + )); + } + if i.target_ref_before == i.target_ref_after { + return Err(MergeRequestError::Validation( + "target ref did not change".into(), + )); + } + let state: Option = self + .lock()? + .query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + params![mr.workspace_id, i.ticket_id], + |row| row.get(0), + ) + .optional()?; + if state.as_deref() != Some("inprogress") { + return Err(MergeRequestError::Conflict( + "Ticket must be inprogress".into(), + )); + } + Ok(()) } - checks.sort(); - checks -} -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], + pub fn complete(&self, i: CompleteMergeRequest) -> Result { + self.validate_completion(&i)?; + let mut connection = self.lock()?; + let transaction = connection.transaction()?; + let mr = load_mr_for_ticket(&transaction, &i.auth.workspace_id, &i.ticket_id)? + .ok_or(MergeRequestError::NotFound)?; + + if let Some(existing) = mr.thread.iter().find_map(|event| match event { + MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => { + Some(value) + } + _ => None, + }) { + if existing.approval_event_id == i.approval_event_id + && existing.target_ref_before == i.target_ref_before + && existing.target_ref_after == i.target_ref_after + { + return Ok(existing.clone()); + } + return Err(MergeRequestError::Conflict( + "operation fingerprint mismatch".into(), + )); + } + if mr.state != MergeRequestState::Open { + return Err(MergeRequestError::Conflict( + "Merge Request is not open".into(), + )); + } + let assignment_is_current: bool = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM ticket_current_worker_assignments + WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 + )", + params![i.auth.workspace_id, i.ticket_id, i.auth.assignment_id], |row| row.get(0), - ) - .map_err(db)?; - Ok(count > 0) -} - -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) -} - -#[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)) -} - -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}" - ); + )?; + if !assignment_is_current { + return Err(MergeRequestError::Unauthorized( + "completion assignment changed before commit".into(), + )); } - 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()); + let review = mr + .effective_review(&i.current_subject_ref) + .filter(|review| review.event_id == i.approval_event_id) + .ok_or_else(|| { + MergeRequestError::NotReady("approval changed before completion commit".into()) + })?; + if review.decision != ReviewDecision::Approve { + return Err(MergeRequestError::NotReady( + "current effective review does not approve the source ref".into(), + )); + } + let state: Option = transaction + .query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + params![mr.workspace_id, i.ticket_id], + |row| row.get(0), + ) + .optional()?; + if state.as_deref() != Some("inprogress") { + return Err(MergeRequestError::Conflict( + "Ticket must be inprogress".into(), + )); + } + transaction.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, i.ticket_id, i.now.to_rfc3339()], + )?; + let issued_grants = { + let mut statement = transaction.prepare( + "SELECT request_event_id,subject_ref,capability_token + FROM merge_request_review_grants + WHERE workspace_id=?1 AND merge_request_id=?2 AND status='issued' + ORDER BY issued_at,request_event_id", + )?; + statement + .query_map(params![mr.workspace_id, mr.merge_request_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::, _>>()? + }; + for (request_event_id, subject_ref, capability_token) in issued_grants { + let cancelled = ReviewCancelledEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&transaction, &mr.workspace_id, &mr.merge_request_id)?, + request_event_id, + subject_ref, + reason: "Merge Request completed before review submission".into(), + created_at: i.now, + }; + insert_event( + &transaction, + &mr.workspace_id, + &mr.merge_request_id, + "review_cancelled", + &cancelled, + i.now, + None, + )?; + transaction.execute( + "UPDATE merge_request_review_grants + SET status='revoked',revoked_at=?2 + WHERE capability_token=?1 AND status='issued'", + params![capability_token, i.now.to_rfc3339()], + )?; + } + let event = MergeEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&transaction, &mr.workspace_id, &mr.merge_request_id)?, + operation_id: i.operation_id, + approval_event_id: i.approval_event_id, + approved_source_ref: review.subject_ref.clone(), + target_ref_before: i.target_ref_before, + target_ref_after: i.target_ref_after, + strategy: i.strategy, + resolution: i.resolution, + merged_by: WorkerIdentity { + runtime_id: i.auth.runtime_id, + worker_id: i.auth.worker_id, + }, + created_at: i.now, + }; + insert_event( + &transaction, + &mr.workspace_id, + &mr.merge_request_id, + "merge", + &event, + i.now, + Some(&event.operation_id), + )?; + transaction.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, i.now.to_rfc3339()], + )?; + ticket_event(&transaction, &mr, &event, &i.auth.assignment_id)?; + transaction.commit()?; + Ok(event) } - #[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 - ); + pub fn repair_selector_from( + &self, + i: RepairSelectorFrom, + ) -> Result { + nonempty("selector_from", &i.selector_from)?; + nonempty("resolved_subject_ref", &i.resolved_subject_ref)?; + let mr = self.get(&i.workspace_id, &i.ticket_id)?; + if mr.selector_from.is_some() { + return Err(MergeRequestError::Conflict( + "selector_from is immutable after it is set".into(), + )); + } + let approved = mr + .effective_review(&i.resolved_subject_ref) + .is_some_and(|review| review.decision == ReviewDecision::Approve); + if !approved { + return Err(MergeRequestError::NotReady( + "selector repair must resolve to an approved thread subject".into(), + )); + } + let mut c = self.lock()?; + let t = c.transaction()?; + let changed=t.execute("UPDATE merge_requests SET selector_from=?3,updated_at=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND selector_from IS NULL",params![mr.workspace_id,mr.merge_request_id,i.selector_from,i.now.to_rfc3339()])?; + if changed != 1 { + return Err(MergeRequestError::Conflict( + "selector_from repair raced with another update".into(), + )); + } + let e = CommentEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + body: format!("selector_from repaired: {}", i.reason), + author: i.repaired_by, + created_at: i.now, + }; + insert_event( + &t, + &mr.workspace_id, + &mr.merge_request_id, + "comment", + &e, + i.now, + None, + )?; + t.commit()?; + drop(c); + self.get(&i.workspace_id, &i.ticket_id) } - - #[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 - ); + pub fn get(&self, ws: &str, ticket: &str) -> Result { + let c = self.lock()?; + let id:Option=c.query_row("SELECT rel.merge_request_id FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 ORDER BY CASE mr.state WHEN 'open' THEN 0 ELSE 1 END,mr.created_at DESC LIMIT 1",params![ws,ticket],|r|r.get(0)).optional()?; + match id { + Some(id) => load_mr(&c, ws, &id)?.ok_or(MergeRequestError::NotFound), + None => Err(MergeRequestError::NotFound), + } } - - #[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(); + pub fn thread_page( + &self, + ws: &str, + ticket: &str, + after: Option, + limit: usize, + ) -> Result, MergeRequestError> { + let mr = self.get(ws, ticket)?; + let c = self.lock()?; + load_thread(&c, ws, &mr.merge_request_id, after, limit.clamp(1, 200)) } - - #[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(); + fn assigned(&self, a: &MergeRequestAuth, t: &str, r: &str) -> Result<(), MergeRequestError> { + self.repo(a, r)?; + let x = self + .assignments + .current_assignment(&a.workspace_id, t) + .map_err(MergeRequestError::Operation)? + .ok_or_else(|| MergeRequestError::Unauthorized("no assignment".into()))?; + if x.assignment_id != a.assignment_id + || x.runtime_id != a.runtime_id + || x.worker_id != a.worker_id + { + return Err(MergeRequestError::Unauthorized( + "not current assigned Worker".into(), + )); + } + Ok(()) } - - #[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()); + fn completion_auth( + &self, + a: &MergeRequestAuth, + t: &str, + r: &str, + ) -> Result<(), MergeRequestError> { + self.repo(a, r)?; + let x = self + .assignments + .current_assignment(&a.workspace_id, t) + .map_err(MergeRequestError::Operation)? + .ok_or_else(|| MergeRequestError::Unauthorized("no assignment".into()))?; + if x.assignment_id != a.assignment_id { + return Err(MergeRequestError::Unauthorized("stale assignment".into())); + } + Ok(()) } - - #[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()); + fn repo(&self, a: &MergeRequestAuth, r: &str) -> Result<(), MergeRequestError> { + if a.repository_id != r + || !self + .repositories + .repository_belongs_to_workspace(&a.workspace_id, r) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Unauthorized( + "repository scope mismatch".into(), + )); + } + Ok(()) } - - #[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 lock(&self) -> Result, MergeRequestError> { + self.conn + .lock() + .map_err(|_| MergeRequestError::Operation("database lock poisoned".into())) } } -fn load_merge_request( - conn: &Connection, +fn truncate_body(value: &mut String) { + if value.len() <= MAX_BODY_BYTES { + return; + } + const MARKER: &str = "\n[truncated]"; + let limit = MAX_BODY_BYTES.saturating_sub(MARKER.len()); + let boundary = value + .char_indices() + .map(|(index, _)| index) + .take_while(|index| *index <= limit) + .last() + .unwrap_or(0); + value.truncate(boundary); + value.push_str(MARKER); +} + +fn bounded_body(name: &str, value: &str) -> Result<(), MergeRequestError> { + if value.len() > MAX_BODY_BYTES { + Err(MergeRequestError::Validation(format!( + "{name} exceeds {MAX_BODY_BYTES} bytes" + ))) + } else { + Ok(()) + } +} + +fn nonempty(n: &str, v: &str) -> Result<(), MergeRequestError> { + if v.trim().is_empty() { + Err(MergeRequestError::Validation(format!( + "{n} must not be empty" + ))) + } else { + Ok(()) + } +} +fn next_seq(t: &Transaction<'_>, w: &str, m: &str) -> Result { + Ok(t.query_row("SELECT COALESCE(MAX(sequence),0)+1 FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2",params![w,m],|r|r.get::<_,i64>(0))? as u64) +} +fn insert_event( + t: &Transaction<'_>, + w: &str, + m: &str, + k: &str, + e: &T, + at: DateTime, + op: Option<&str>, +) -> Result<(), MergeRequestError> { + let v = serde_json::to_value(e).map_err(|x| MergeRequestError::Operation(x.to_string()))?; + let id = v["event_id"] + .as_str() + .ok_or_else(|| MergeRequestError::Operation("event_id missing".into()))?; + let seq = v["sequence"] + .as_u64() + .ok_or_else(|| MergeRequestError::Operation("sequence missing".into()))?; + t.execute( + "INSERT INTO merge_request_thread_events VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + w, + m, + id, + seq as i64, + k, + serde_json::to_string(e).map_err(|x| MergeRequestError::Operation(x.to_string()))?, + op, + at.to_rfc3339() + ], + )?; + Ok(()) +} +fn load_mr_for_ticket( + connection: &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), +) -> Result, MergeRequestError> { + let merge_request_id: Option = connection + .query_row( + "SELECT rel.merge_request_id + FROM merge_request_ticket_relations rel + JOIN merge_requests mr + ON mr.workspace_id=rel.workspace_id + AND mr.merge_request_id=rel.merge_request_id + WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 + ORDER BY CASE mr.state WHEN 'open' THEN 0 ELSE 1 END,mr.created_at DESC + LIMIT 1", + params![workspace_id, ticket_id], + |row| row.get(0), + ) + .optional()?; + match merge_request_id { + Some(id) => load_mr(connection, 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 { +fn load_mr(c: &Connection, w: &str, m: &str) -> Result, MergeRequestError> { + let row:Option<(String,String,Option,String,String,String)>=c.query_row("SELECT repository_id,state,selector_from,selector_to,created_at,updated_at FROM merge_requests WHERE workspace_id=?1 AND merge_request_id=?2",params![w,m],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))).optional()?; + let Some((repo, state, from, to, created, updated)) = 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, + let mut s=c.prepare("SELECT ticket_id FROM merge_request_ticket_relations WHERE workspace_id=?1 AND merge_request_id=?2 ORDER BY ticket_id")?; + let tickets = s + .query_map(params![w, m], |r| r.get(0))? + .collect::, _>>()?; + Ok(Some(MergeRequest { + workspace_id: w.into(), + merge_request_id: m.into(), + repository_id: repo, + state: MergeRequestState::parse(&state)?, + selector_from: from, + selector_to: to, + ticket_ids: tickets, + created_at: time(&created)?, + updated_at: time(&updated)?, + thread: load_thread(c, w, m, None, i64::MAX as usize)?, })) } - -fn validate_current_implementation_assignment( - conn: &Connection, - workspace_id: &str, - ticket_id: &str, - assignment_id: &str, -) -> Result<()> { - let current: Option = conn - .query_row( - "SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2", - 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(()) -} - -fn validate_current_assignment( - conn: &Connection, - 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(()) -} - -fn append_completion_event( - 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}")) +fn load_thread( + c: &Connection, + w: &str, + m: &str, + after: Option, + limit: usize, +) -> Result, MergeRequestError> { + let mut s=c.prepare("SELECT kind,payload_json FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND sequence>?3 ORDER BY sequence LIMIT ?4")?; + let rows = s + .query_map( + params![w, m, after.unwrap_or(0) as i64, limit as i64], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + )? + .collect::, _>>()?; + rows.into_iter() + .map(|(k, j)| { + let mut event = match k.as_str() { + "review_requested" => MergeRequestThreadEvent::ReviewRequested(json(&j)?), + "review" => MergeRequestThreadEvent::Review(json(&j)?), + "review_revoked" => MergeRequestThreadEvent::ReviewRevoked(json(&j)?), + "review_cancelled" => MergeRequestThreadEvent::ReviewCancelled(json(&j)?), + "comment" => MergeRequestThreadEvent::Comment(json(&j)?), + "merge" => MergeRequestThreadEvent::Merge(json(&j)?), + _ => return Err(MergeRequestError::Corrupt(format!("unknown event `{k}`"))), + }; + event.bound_bodies(); + Ok(event) + }) .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, - ) +fn json Deserialize<'a>>(v: &str) -> Result { + serde_json::from_str(v).map_err(|e| MergeRequestError::Corrupt(e.to_string())) +} +fn time(v: &str) -> Result, MergeRequestError> { + DateTime::parse_from_rfc3339(v) + .map(|x| x.with_timezone(&Utc)) + .map_err(|e| MergeRequestError::Corrupt(e.to_string())) +} +fn ticket_event( + t: &Transaction<'_>, + mr: &MergeRequest, + e: &MergeEvent, + a: &str, +) -> Result<(), MergeRequestError> { + let ticket = &mr.ticket_ids[0]; + let n:i64=t.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,ticket],|r|r.get(0))?; + t.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,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Approved source ref `{}` completed implementation.",e.approved_source_ref)])?; + for (k, v) in [ + ("implementation_assignment_id", a), + ("approval_event_id", &e.approval_event_id), + ("approved_source_ref", &e.approved_source_ref), + ("operation_id", &e.operation_id), + ] { + t.execute( + "INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)", + params![mr.workspace_id, ticket, n, k, v], + )?; + } + Ok(()) } -#[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{}", - ticket_id, - revision_id, - target_commit, - source_commit, - result_commit, +pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> { + match schema_version(c)? { + None => fresh(c), + Some(SCHEMA_VERSION) => verify(c), + Some(PREVIOUS_SCHEMA_VERSION) => from_v11(c), + Some(v) => Err(MergeRequestError::Operation(format!( + "unsupported schema {v}" + ))), + } +} +fn schema_version(c: &Connection) -> Result, MergeRequestError> { + let e:bool=c.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",[],|r|r.get(0))?; + if !e { + return Ok(None); + } + c.query_row( + "SELECT version FROM merge_request_schema WHERE singleton=1", + [], + |r| r.get(0), + ) + .optional() + .map_err(Into::into) +} +fn fresh(c: &Connection) -> Result<(), MergeRequestError> { + let t = c.unchecked_transaction()?; + tables(&t, true)?; + t.execute("INSERT INTO merge_request_schema VALUES(1,12)", [])?; + fk(&t)?; + t.commit()?; + Ok(()) +} +fn tables(t: &Transaction<'_>, marker: bool) -> Result<(), MergeRequestError> { + if marker { + t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")? + } + t.execute_batch("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('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,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_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_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,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 from_v11(c: &Connection) -> Result<(), MergeRequestError> { + let t = c.unchecked_transaction()?; + t.execute_batch("ALTER TABLE merge_requests RENAME TO merge_requests_v11;ALTER TABLE merge_request_ticket_relations RENAME TO merge_request_ticket_relations_v11;ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11;ALTER TABLE merge_request_revision_paths RENAME TO merge_request_revision_paths_v11;ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_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_review_findings RENAME TO merge_request_review_findings_v11;ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11;")?; + tables(&t, false)?; + t.execute("INSERT INTO merge_requests SELECT workspace_id,merge_request_id,repository_id,CASE state WHEN 'draft'THEN'open'ELSE state END,NULL,target_ref_selector,created_at,updated_at FROM merge_requests_v11",[])?; + t.execute("INSERT INTO merge_request_ticket_relations SELECT * FROM merge_request_ticket_relations_v11",[])?; + migrate_events(&t)?; + t.execute_batch("DROP TABLE merge_request_review_findings_v11;DROP TABLE merge_request_reviews_v11;DROP TABLE merge_request_review_attempts_v11;DROP TABLE merge_request_reviewer_child_sessions_v11;DROP TABLE merge_request_revision_paths_v11;DROP TABLE merge_request_revisions_v11;DROP TABLE merge_request_completion_operations_v11;DROP TABLE merge_request_ticket_relations_v11;DROP TABLE merge_requests_v11;UPDATE merge_request_schema SET version=12 WHERE singleton=1;")?; + fk(&t)?; + t.commit()?; + Ok(()) +} +fn migrate_events(t: &Transaction<'_>) -> Result<(), MergeRequestError> { + let attempts = { + let mut s=t.prepare("SELECT a.workspace_id,a.attempt_id,a.merge_request_id,a.parent_runtime_id,a.parent_worker_id,a.child_session_id,a.status,a.created_at,a.consumed_at,r.head_commit FROM merge_request_review_attempts_v11 a JOIN merge_request_revisions_v11 r ON r.workspace_id=a.workspace_id AND r.merge_request_id=a.merge_request_id AND r.revision_id=a.revision_id ORDER BY a.created_at")?; + s.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(5)?, + r.get::<_, String>(6)?, + r.get::<_, String>(7)?, + r.get::<_, Option>(8)?, + r.get::<_, String>(9)?, + )) + })? + .collect::, _>>()? + }; + for (ws, a, mr, pr, pw, child, status, created, consumed, subject) in attempts { + let req = ReviewRequestedEvent { + event_id: format!("migrated-request-{a}"), + sequence: next_seq(t, &ws, &mr)?, + subject_ref: subject.clone(), + requested_by: WorkerIdentity { + runtime_id: pr.clone(), + worker_id: pw, + }, + reviewer: WorkerIdentity { + runtime_id: pr, + worker_id: child, + }, + created_at: time(&created)?, + }; + insert_event(t, &ws, &mr, "review_requested", &req, req.created_at, None)?; + if status == "submitted" { + let(row_dec,row_body,row_at):(String,String,String)=t.query_row("SELECT decision,body,submitted_at FROM merge_request_reviews_v11 WHERE workspace_id=?1 AND attempt_id=?2",params![ws,a],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?; + let findings = { + let mut s=t.prepare("SELECT severity,code,path,line,body FROM merge_request_review_findings_v11 WHERE workspace_id=?1 AND attempt_id=?2 ORDER BY ordinal")?; + s.query_map(params![ws, a], |r| { + Ok(ReviewFinding { + severity: match r.get::<_, String>(0)?.as_str() { + "blocker" => FindingSeverity::Blocker, + "major" => FindingSeverity::Major, + "minor" => FindingSeverity::Minor, + _ => FindingSeverity::Note, + }, + code: r.get(1)?, + path: r.get(2)?, + line: r.get(3)?, + body: r.get(4)?, + }) + })? + .collect::, _>>()? + }; + let rev = ReviewEvent { + event_id: format!("migrated-review-{a}"), + sequence: next_seq(t, &ws, &mr)?, + request_event_id: req.event_id, + subject_ref: subject, + decision: if row_dec == "approve" { + ReviewDecision::Approve + } else { + ReviewDecision::RequestChanges + }, + body: row_body, + findings, + reviewer: req.reviewer, + created_at: time(&row_at)?, + }; + insert_event(t, &ws, &mr, "review", &rev, rev.created_at, None)? + } else { + let at = consumed.as_deref().unwrap_or(&created); + let e = ReviewCancelledEvent { + event_id: format!("migrated-cancel-{a}"), + sequence: next_seq(t, &ws, &mr)?, + request_event_id: req.event_id, + subject_ref: subject, + reason: format!( + "legacy `{status}` review request cancelled because its capability cannot be migrated" + ), + created_at: time(at)?, + }; + insert_event(t, &ws, &mr, "review_cancelled", &e, e.created_at, None)? + } + } + let completed = { + let mut q=t.prepare("SELECT c.workspace_id,c.operation_id,c.ticket_id,c.target_commit,c.source_commit,c.result_commit,c.strategy,c.resolution,c.completion_actor_runtime_id,c.completion_actor_worker_id,c.updated_at,rel.merge_request_id FROM merge_request_completion_operations_v11 c JOIN merge_request_ticket_relations_v11 rel ON rel.workspace_id=c.workspace_id AND rel.ticket_id=c.ticket_id WHERE c.status='completed' ORDER BY c.updated_at")?; + q.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, Option>(6)?, + r.get::<_, Option>(7)?, + r.get::<_, Option>(8)?, + r.get::<_, Option>(9)?, + r.get::<_, String>(10)?, + r.get::<_, String>(11)?, + )) + })? + .collect::, _>>()? + }; + for ( + ws, + op, + _ticket, + target, + source, + result, strategy, resolution, - implementation_assignment_id, - completion_actor_runtime_id, - completion_actor_worker_id - )) + runtime, + worker, + updated, + mr, + ) in completed + { + let subject = source.ok_or_else(|| { + MergeRequestError::Operation(format!("completed operation {op} lacks source evidence")) + })?; + let approval:Option=t.query_row("SELECT event_id FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND kind='review' AND json_extract(payload_json,'$.subject_ref')=?3 AND json_extract(payload_json,'$.decision')='approve' ORDER BY sequence DESC LIMIT 1",params![ws,mr,subject],|r|r.get(0)).optional()?; + let approval = approval.ok_or_else(|| { + MergeRequestError::Operation(format!( + "completed operation {op} lacks approval evidence" + )) + })?; + let e = MergeEvent { + event_id: format!("migrated-merge-{op}"), + sequence: next_seq(t, &ws, &mr)?, + operation_id: op, + approval_event_id: approval, + approved_source_ref: subject, + target_ref_before: target.ok_or_else(|| { + MergeRequestError::Operation("completed operation lacks target evidence".into()) + })?, + target_ref_after: result.ok_or_else(|| { + MergeRequestError::Operation("completed operation lacks result evidence".into()) + })?, + strategy: if strategy.as_deref() == Some("merge") { + MergeStrategy::Merge + } else { + MergeStrategy::FastForward + }, + resolution: match resolution.as_deref() { + Some("clean") => ConflictResolution::Clean, + Some("conflicts_resolved") => ConflictResolution::ConflictsResolved, + _ => ConflictResolution::None, + }, + merged_by: WorkerIdentity { + runtime_id: runtime.unwrap_or_else(|| "legacy".into()), + worker_id: worker.unwrap_or_else(|| "legacy".into()), + }, + created_at: time(&updated)?, + }; + insert_event( + t, + &ws, + &mr, + "merge", + &e, + e.created_at, + Some(&e.operation_id), + )?; + } + Ok(()) } -fn db(error: rusqlite::Error) -> MergeRequestError { - MergeRequestError::Database(error.to_string()) +fn verify(c: &Connection) -> Result<(), MergeRequestError> { + for n in [ + "merge_requests", + "merge_request_ticket_relations", + "merge_request_thread_events", + "merge_request_review_grants", + ] { + let e: bool = c.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table'AND name=?1)", + params![n], + |r| r.get(0), + )?; + if !e { + return Err(MergeRequestError::Corrupt(format!("missing `{n}`"))); + } + } + fk(c) +} +fn fk(c: &Connection) -> Result<(), MergeRequestError> { + let v: Option<(String, i64)> = c + .query_row("PRAGMA foreign_key_check", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .optional()?; + if let Some((t, r)) = v { + return Err(MergeRequestError::Corrupt(format!( + "foreign key violation {t}:{r}" + ))); + } + Ok(()) } diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index ba795b1e..33968cad 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -1,297 +1,386 @@ +use chrono::{TimeZone, Utc}; use merge_request::*; -use rusqlite::{Connection, params}; -use std::sync::{Arc, Barrier}; -use std::thread; -use tempfile::TempDir; - -fn setup() -> (TempDir, SqliteMergeRequestStore) { - 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(); - } - drop(conn); - let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap(); - (dir, store) -} - -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}"), +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; +#[derive(Clone)] +struct Assignments(Arc>); +impl AssignmentSource for Assignments { + fn current_assignment(&self, _: &str, _: &str) -> Result, String> { + Ok(Some(self.0.lock().unwrap().clone())) } } - -fn open(store: &SqliteMergeRequestStore) { - 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(), - }) - .unwrap(); -} - -fn attempt(store: &SqliteMergeRequestStore, revision: &str, token: &str) { - let child = format!("child-{revision}"); - store - .register_reviewer_child_session(RegisterReviewerChildSession { - parent_runtime_id: "R1".into(), - parent_worker_id: "W1".into(), - child_session_id: child.clone(), - now: "t2".into(), - }) - .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, - capability_token: token.into(), - now: "t2".into(), - }) - .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(), - capability_token: token.into(), - decision: ReviewDecision::Approve, - body: "approved".into(), - findings: vec![], - now: "t3".into(), - }) - .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(), +struct Repositories; +impl RepositorySource for Repositories { + fn repository_belongs_to_workspace(&self, w: &str, r: &str) -> Result { + Ok(w == "W" && r == "R") } } - -#[test] -fn target_movement_does_not_invalidate_source_revision_approval() { - let (_dir, store) = setup(); - 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(); - assert!( - readiness.ready, - "target movement must not invalidate source approval" - ); - assert_eq!(readiness.review_status, ReviewStatus::Approved); - assert_eq!(readiness.observed_target_commit.as_deref(), Some(target)); +fn at(s: u32) -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, s) + .single() + .unwrap() +} +fn auth() -> MergeRequestAuth { + MergeRequestAuth { + workspace_id: "W".into(), + repository_id: "R".into(), + runtime_id: "runtime".into(), + worker_id: "coder".into(), + assignment_id: "A".into(), } - 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(), - }) - .unwrap(); - assert_eq!( - store.readiness_for_ticket("T1").unwrap().review_status, - ReviewStatus::Pending - ); } - -#[test] -fn completion_records_one_final_merge_outcome_and_replays_idempotently() { - let (_dir, store) = setup(); - 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) - )); +fn fixture() -> (tempfile::TempDir, MergeRequestStore) { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("db"); + let c = Connection::open(&p).unwrap(); + c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at 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,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap(); + drop(c); + let a = Assignments(Arc::new(Mutex::new(CurrentAssignment { + assignment_id: "A".into(), + ticket_id: "T".into(), + runtime_id: "runtime".into(), + worker_id: "coder".into(), + }))); + let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap(); + (d, s) } - -#[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(_)) - )); - assert_eq!( - store.show_for_ticket("T1").unwrap().unwrap().state, - MergeRequestState::Open - ); - 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'", - [], - |row| row.get::<_, String>(0) - ) - .unwrap(), - "inprogress" - ); +fn open(s: &MergeRequestStore) { + s.open_merge_request(OpenMergeRequest { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + repository_id: "R".into(), + selector_from: "work/t".into(), + selector_to: "develop".into(), + summary: "summary".into(), + auth: auth(), + now: at(1), + }) + .unwrap(); } - -#[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")) - })); - } - 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 - ); +fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequestedEvent { + s.register_reviewer_child_session(RegisterReviewerChildSession { + 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: at(2), + }) + .unwrap(); + s.request_review(RequestMergeRequestReview { + ticket_id: "T".into(), + subject_ref: subject.into(), + child_session_id: format!("child-{token}"), + capability_token: token.into(), + auth: auth(), + now: at(3), + }) + .unwrap() + .request_event } - -#[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(), +fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent { + request(s, subject, token); + s.submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + current_subject_ref: subject.into(), + capability_token: token.into(), decision: ReviewDecision::Approve, body: "approved".into(), findings: vec![], - now: "t3".into(), + now: at(4), + }) + .unwrap() +} +#[test] +fn selectors_thread_and_completion_have_no_revision_or_commit_api() { + let (_d, s) = fixture(); + open(&s); + let review = approve(&s, "opaque-source-ref", "token"); + let ready = s + .readiness(ReadinessCheck { + ticket_id: "T".into(), + current_subject_ref: Some("opaque-source-ref".into()), + auth: auth(), + }) + .unwrap(); + assert!(ready.ready); + let merged = s + .complete(CompleteMergeRequest { + ticket_id: "T".into(), + operation_id: "op".into(), + approval_event_id: review.event_id, + current_subject_ref: "opaque-source-ref".into(), + target_ref_before: "old-target-ref".into(), + target_ref_after: "new-target-ref".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(5), + }) + .unwrap(); + assert_eq!(merged.approved_source_ref, "opaque-source-ref"); + let mr = s.get("W", "T").unwrap(); + assert_eq!(mr.selector_from.as_deref(), Some("work/t")); + assert_eq!(mr.state, MergeRequestState::Merged); + let json = serde_json::to_string(&mr).unwrap(); + for banned in [ + "revision_id", + "attempt_id", + "base_commit", + "head_commit", + "source_commit", + "result_commit", + "current_revision", + ] { + assert!(!json.contains(banned), "{banned} in {json}") + } +} +#[test] +fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_returns() { + let (_d, s) = fixture(); + open(&s); + let approved = approve(&s, "source-a", "one"); + request(&s, "source-b", "two"); + assert!( + s.submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + current_subject_ref: "source-c".into(), + capability_token: "two".into(), + decision: ReviewDecision::Approve, + body: "stale".into(), + findings: vec![], + now: at(6) + }) + .is_err() + ); + let mr = s.get("W", "T").unwrap(); + assert!( + mr.thread + .iter() + .any(|e| matches!(e, MergeRequestThreadEvent::ReviewCancelled(_))) + ); + assert_eq!( + mr.effective_review("source-a").map(|r| &r.event_id), + Some(&approved.event_id) + ); +} +#[test] +fn review_revocation_invalidates_readiness() { + let (_d, s) = fixture(); + open(&s); + let review = approve(&s, "source", "one"); + s.revoke_review(RevokeMergeRequestReview { + ticket_id: "T".into(), + review_event_id: review.event_id, + reason: "bad evidence".into(), + auth: auth(), + now: at(7), + }) + .unwrap(); + let r = s + .readiness(ReadinessCheck { + ticket_id: "T".into(), + current_subject_ref: Some("source".into()), + auth: auth(), + }) + .unwrap(); + assert!(!r.ready); +} + +#[test] +fn v11_migration_preserves_review_events_and_requires_selector_repair() { + let c = Connection::open_in_memory().unwrap(); + c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY,version INTEGER);INSERT INTO merge_request_schema VALUES(1,11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');INSERT INTO merge_request_review_attempts VALUES('W','PENDING','MR','T','V',1,'A','runtime','coder','pending-child','builtin:reviewer','pending-token','registered','2026-07-26T12:00:02Z',NULL);").unwrap(); + merge_request::migrate(&c).unwrap(); + let selector: Option = c + .query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0)) + .unwrap(); + assert!(selector.is_none()); + let kinds: String = c + .query_row( + "SELECT group_concat(kind,',') FROM merge_request_thread_events ORDER BY sequence", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + kinds, + "review_requested,review,review_requested,review_cancelled" + ); + let old: bool = c + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='merge_request_revisions')", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(!old); +} + +#[test] +fn authority_reads_full_thread_while_public_pages_remain_bounded() { + let (_d, store) = fixture(); + open(&store); + for index in 0..55 { + approve(&store, "same-subject", &format!("token-{index}")); + } + let mr = store.get("W", "T").unwrap(); + assert!(mr.thread.len() > 100); + assert_eq!( + mr.effective_review("same-subject").unwrap().decision, + ReviewDecision::Approve + ); + assert_eq!(store.thread_page("W", "T", None, 20).unwrap().len(), 20); + assert_eq!( + store.thread_page("W", "T", Some(100), 20).unwrap().len(), + 11 + ); +} + +#[test] +fn completion_rejects_superseded_approval_for_same_subject() { + let (_d, store) = fixture(); + open(&store); + let old_approval = approve(&store, "subject", "approval"); + request(&store, "subject", "changes"); + store + .submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + current_subject_ref: "subject".into(), + capability_token: "changes".into(), + decision: ReviewDecision::RequestChanges, + body: "changes required".into(), + findings: vec![], + now: at(5), + }) + .unwrap(); + let result = store.complete(CompleteMergeRequest { + ticket_id: "T".into(), + operation_id: "op".into(), + approval_event_id: old_approval.event_id, + current_subject_ref: "subject".into(), + target_ref_before: "before".into(), + target_ref_after: "after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(6), }); - assert!(matches!( - wrong_token, - Err(MergeRequestError::InvalidReviewAttempt) - )); + assert!(matches!(result, Err(MergeRequestError::NotReady(_)))); +} + +#[test] +fn completion_cancels_outstanding_grants_and_late_submit_fails() { + let (_d, store) = fixture(); + open(&store); + let approval = approve(&store, "subject", "approval"); + request(&store, "other-subject", "pending"); + store + .complete(CompleteMergeRequest { + ticket_id: "T".into(), + operation_id: "op".into(), + approval_event_id: approval.event_id, + current_subject_ref: "subject".into(), + target_ref_before: "before".into(), + target_ref_after: "after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(6), + }) + .unwrap(); + let late = store.submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + current_subject_ref: "other-subject".into(), + capability_token: "pending".into(), + decision: ReviewDecision::Approve, + body: "too late".into(), + findings: vec![], + now: at(7), + }); + assert!(matches!(late, Err(MergeRequestError::Unauthorized(_)))); + let mr = store.get("W", "T").unwrap(); + assert!(mr.thread.iter().any(|event| matches!(event, + MergeRequestThreadEvent::ReviewCancelled(value) + if value.reason.contains("completed before review submission")))); +} + +#[test] +fn selector_repair_requires_and_accepts_an_approved_resolved_subject() { + let (dir, store) = fixture(); + open(&store); + approve(&store, "approved-subject", "approval"); + Connection::open(dir.path().join("db")).unwrap() + .execute("UPDATE merge_requests SET selector_from=NULL WHERE workspace_id='W' AND merge_request_id='MR'", []) + .unwrap(); + let repaired = store + .repair_selector_from(RepairSelectorFrom { + workspace_id: "W".into(), + ticket_id: "T".into(), + selector_from: "restored-work".into(), + resolved_subject_ref: "approved-subject".into(), + repaired_by: WorkerIdentity { + runtime_id: "browser".into(), + worker_id: "user".into(), + }, + reason: "confirmed migrated source".into(), + now: at(8), + }) + .unwrap(); + assert_eq!(repaired.selector_from.as_deref(), Some("restored-work")); +} + +#[test] +fn selector_repair_rejects_unapproved_resolved_subject() { + let (dir, store) = fixture(); + open(&store); + approve(&store, "approved-subject", "approval"); + Connection::open(dir.path().join("db")).unwrap() + .execute("UPDATE merge_requests SET selector_from=NULL WHERE workspace_id='W' AND merge_request_id='MR'", []) + .unwrap(); + let result = store.repair_selector_from(RepairSelectorFrom { + workspace_id: "W".into(), + ticket_id: "T".into(), + selector_from: "wrong-work".into(), + resolved_subject_ref: "different-subject".into(), + repaired_by: WorkerIdentity { + runtime_id: "browser".into(), + worker_id: "user".into(), + }, + reason: "wrong candidate".into(), + now: at(8), + }); + assert!(matches!(result, Err(MergeRequestError::NotReady(_)))); +} + +#[test] +fn transactional_completion_rejects_assignment_changed_in_control_plane_db() { + let (dir, store) = fixture(); + open(&store); + let approval = approve(&store, "subject", "approval"); + Connection::open(dir.path().join("db")).unwrap() + .execute("UPDATE ticket_current_worker_assignments SET assignment_id='B' WHERE workspace_id='W' AND ticket_id='T'", []) + .unwrap(); + let result = store.complete(CompleteMergeRequest { + ticket_id: "T".into(), + operation_id: "op".into(), + approval_event_id: approval.event_id, + current_subject_ref: "subject".into(), + target_ref_before: "before".into(), + target_ref_after: "after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(9), + }); + assert!(matches!(result, Err(MergeRequestError::Unauthorized(_)))); + let state: String = Connection::open(dir.path().join("db")) + .unwrap() + .query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(state, "inprogress"); } diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 157144e5..410f7556 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -36,8 +36,8 @@ const MAX_DIAGNOSTIC_LIMIT: usize = 500; pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [ "TicketCreate", "TicketEditItem", - "TicketList", - "TicketShow", + "QueryTicket", + "ShowTicket", "TicketComment", "TicketPlan", "TicketDecision", @@ -51,8 +51,8 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [ ]; pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [ - "TicketList", - "TicketShow", + "QueryTicket", + "ShowTicket", "TicketDependencyCheck", "TicketDoctor", ]; @@ -71,8 +71,8 @@ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = pub const TICKET_TOOL_NAMES: [&str; 19] = [ "TicketCreate", "TicketEditItem", - "TicketList", - "TicketShow", + "QueryTicket", + "ShowTicket", "TicketComment", "TicketPlan", "TicketDecision", @@ -91,8 +91,8 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [ ]; pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [ - "TicketList", - "TicketShow", + "QueryTicket", + "ShowTicket", "TicketDependencyCheck", "TicketDoctor", "TicketRelationQuery", @@ -121,13 +121,12 @@ backend assigns the id and writes the local Ticket file layout under the configu const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \ This updates the current item title/body and appends an audited item_edit thread event. Intended for \ User/Companion authoring surfaces, not Orchestrator implementation control."; -const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \ -lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \ -state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \ -routing, closing, planning, or implementation decisions."; -const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \ -typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \ -artifact metadata."; +const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \ +overview. The local backend supports workflow-state selection; Workspace-backed Workers replace this \ +definition with the richer authoritative text/event/evidence/relation/Objective/time/attention query."; +const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \ +Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and artifact \ +metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection."; const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown."; const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown."; const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown."; @@ -169,8 +168,8 @@ fn base_tool_description(name: &str) -> &'static str { match name { "TicketCreate" => CREATE_DESCRIPTION, "TicketEditItem" => EDIT_ITEM_DESCRIPTION, - "TicketList" => LIST_DESCRIPTION, - "TicketShow" => SHOW_DESCRIPTION, + "QueryTicket" => LIST_DESCRIPTION, + "ShowTicket" => SHOW_DESCRIPTION, "TicketComment" => COMMENT_DESCRIPTION, "TicketPlan" => PLAN_DESCRIPTION, "TicketDecision" => DECISION_DESCRIPTION, @@ -464,7 +463,7 @@ impl TicketWorkflowStateParam { #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] -enum TicketListStateParam { +enum QueryTicketStateParam { Active, Planning, Ready, @@ -475,7 +474,7 @@ enum TicketListStateParam { All, } -impl TicketListStateParam { +impl QueryTicketStateParam { fn as_list_state(self) -> Option { match self { Self::Planning => Some(TicketListState::Planning), @@ -490,10 +489,10 @@ impl TicketListStateParam { } #[derive(Debug, Deserialize, schemars::JsonSchema)] -struct TicketListParams { +struct QueryTicketParams { /// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets. #[serde(default)] - state: Option, + state: Option, /// Explicit workflow-state filter list. Cannot be combined with `state`. #[serde(default)] states: Option>, @@ -502,28 +501,28 @@ struct TicketListParams { limit: Option, } -impl TicketListParams { +impl QueryTicketParams { fn into_query(self) -> Result<(crate::TicketListQuery, String, Option), TicketError> { let query = if let Some(states) = self.states { if self.state.is_some() { return Err(TicketError::Conflict( - "TicketList accepts either `state` or `states`, not both".to_string(), + "QueryTicket accepts either `state` or `states`, not both".to_string(), )); } if states.is_empty() { return Err(TicketError::Conflict( - "TicketList `states` must include at least one workflow state".to_string(), + "QueryTicket `states` must include at least one workflow state".to_string(), )); } crate::TicketListQuery::states(states.into_iter().map(|state| state.into_list_state())) } else { - match self.state.unwrap_or(TicketListStateParam::Active) { - TicketListStateParam::Active => crate::TicketListQuery::active(), - TicketListStateParam::All => crate::TicketListQuery::all(), + match self.state.unwrap_or(QueryTicketStateParam::Active) { + QueryTicketStateParam::Active => crate::TicketListQuery::active(), + QueryTicketStateParam::All => crate::TicketListQuery::all(), state => crate::TicketListQuery::state( state .as_list_state() - .expect("workflow state list param maps to TicketListState"), + .expect("workflow state list param maps to QueryTicketState"), ), } }; @@ -533,7 +532,7 @@ impl TicketListParams { } #[derive(Debug, Deserialize, schemars::JsonSchema)] -struct TicketShowParams { +struct ShowTicketParams { /// Ticket id. Exactly one of `id` or `query` must be provided. #[serde(default)] id: Option, @@ -768,17 +767,17 @@ struct TicketRefOutput { } #[derive(Debug, Serialize)] -struct TicketListOutput { +struct QueryTicketOutput { state_filter: String, count: usize, returned: usize, truncated: bool, limit: usize, - tickets: Vec, + tickets: Vec, } #[derive(Debug, Serialize)] -struct TicketListTicketOutput { +struct QueryTicketTicketOutput { id: String, title: String, state: String, @@ -808,12 +807,12 @@ struct TicketEditItemTool { } #[derive(Clone)] -struct TicketListTool { +struct QueryTicketTool { backend: TicketToolBackend, } #[derive(Clone)] -struct TicketShowTool { +struct ShowTicketTool { backend: TicketToolBackend, } @@ -976,28 +975,28 @@ impl Tool for TicketEditItemTool { } #[async_trait] -impl Tool for TicketListTool { +impl Tool for QueryTicketTool { async fn execute( &self, input_json: &str, _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { - let params: TicketListParams = parse_input("TicketList", input_json)?; + let params: QueryTicketParams = parse_input("QueryTicket", input_json)?; let (filter, state_filter, params_limit) = params .into_query() - .map_err(|error| backend_error("TicketList", error))?; + .map_err(|error| backend_error("QueryTicket", error))?; let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); let tickets = self .backend .list(filter) - .map_err(|error| backend_error("TicketList", error))?; + .map_err(|error| backend_error("QueryTicket", error))?; let count = tickets.len(); let returned_tickets: Vec<_> = tickets .into_iter() .take(limit) .map(ticket_summary_json) .collect(); - let output = TicketListOutput { + let output = QueryTicketOutput { state_filter: state_filter.to_string(), count, returned: returned_tickets.len(), @@ -1017,13 +1016,13 @@ impl Tool for TicketListTool { } #[async_trait] -impl Tool for TicketShowTool { +impl Tool for ShowTicketTool { async fn execute( &self, input_json: &str, _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { - let params: TicketShowParams = parse_input("TicketShow", input_json)?; + let params: ShowTicketParams = parse_input("ShowTicket", input_json)?; let query = id_or_query(params.id, params.query)?; let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT); let artifact_limit = bounded( @@ -1039,7 +1038,7 @@ impl Tool for TicketShowTool { let ticket = self .backend .show(query) - .map_err(|error| backend_error("TicketShow", error))?; + .map_err(|error| backend_error("ShowTicket", error))?; let summary = format!( "Ticket {} state {}", ticket.meta.id, @@ -1484,9 +1483,9 @@ fn id_or_query(id: Option, query: Option) -> Result TicketListTicketOutput { +fn ticket_summary_json(ticket: TicketSummary) -> QueryTicketTicketOutput { let hints = ticket_list_hints(&ticket); - TicketListTicketOutput { + QueryTicketTicketOutput { id: ticket.id, title: truncate_inline(ticket.title.as_str(), LIST_TITLE_MAX_CHARS), state: ticket.workflow_state.as_str().to_string(), @@ -1722,8 +1721,8 @@ fn input_schema(name: &str) -> Value { match name { "TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)), "TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)), - "TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)), - "TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)), + "QueryTicket" => serde_json::to_value(schemars::schema_for!(QueryTicketParams)), + "ShowTicket" => serde_json::to_value(schemars::schema_for!(ShowTicketParams)), "TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => { serde_json::to_value(schemars::schema_for!(TicketThreadEventParams)) } @@ -1769,8 +1768,8 @@ macro_rules! impl_from_backend { impl_from_backend!(TicketCreateTool); impl_from_backend!(TicketEditItemTool); -impl_from_backend!(TicketListTool); -impl_from_backend!(TicketShowTool); +impl_from_backend!(QueryTicketTool); +impl_from_backend!(ShowTicketTool); impl_from_backend!(TicketCommentTool); impl_from_backend!(TicketPlanTool); impl_from_backend!(TicketDecisionTool); @@ -1793,8 +1792,8 @@ pub fn ticket_tools(backend: impl Into) -> Vec("TicketCreate", backend.clone()), tool_definition::("TicketEditItem", backend.clone()), - tool_definition::("TicketList", backend.clone()), - tool_definition::("TicketShow", backend.clone()), + tool_definition::("QueryTicket", backend.clone()), + tool_definition::("ShowTicket", backend.clone()), tool_definition::("TicketComment", backend.clone()), tool_definition::("TicketPlan", backend.clone()), tool_definition::("TicketDecision", backend.clone()), @@ -1861,8 +1860,8 @@ mod tests { assert_eq!( TICKET_READ_ONLY_TOOL_NAMES, [ - "TicketList", - "TicketShow", + "QueryTicket", + "ShowTicket", "TicketDependencyCheck", "TicketDoctor", "TicketRelationQuery", @@ -1941,8 +1940,8 @@ mod tests { let temp = TempDir::new().unwrap(); let backend = backend(&temp); let create = tool_by_name(backend.clone(), "TicketCreate"); - let list = tool_by_name(backend.clone(), "TicketList"); - let show = tool_by_name(backend.clone(), "TicketShow"); + let list = tool_by_name(backend.clone(), "QueryTicket"); + let show = tool_by_name(backend.clone(), "ShowTicket"); let doctor = tool_by_name(backend.clone(), "TicketDoctor"); let created = create @@ -2004,7 +2003,7 @@ mod tests { async fn ticket_list_tool_truncates_long_titles_and_hints() { let temp = TempDir::new().unwrap(); let backend = backend(&temp); - let list = tool_by_name(backend.clone(), "TicketList"); + let list = tool_by_name(backend.clone(), "QueryTicket"); let mut ticket = NewTicket::new(format!( "Long Title {}", "x".repeat(LIST_TITLE_MAX_CHARS + 40) @@ -2032,7 +2031,7 @@ mod tests { async fn ticket_list_tool_default_and_max_limits_are_bounded() { let temp = TempDir::new().unwrap(); let backend = backend(&temp); - let list = tool_by_name(backend.clone(), "TicketList"); + let list = tool_by_name(backend.clone(), "QueryTicket"); for index in 0..(MAX_LIST_LIMIT + 5) { backend .create(NewTicket::new(format!("Ticket {index:03}"))) @@ -2083,7 +2082,7 @@ mod tests { async fn ticket_list_tool_caps_all_and_closed_default_listing() { let temp = TempDir::new().unwrap(); let backend = backend(&temp); - let list = tool_by_name(backend.clone(), "TicketList"); + let list = tool_by_name(backend.clone(), "QueryTicket"); for index in 0..(DEFAULT_LIST_LIMIT + 3) { let mut ticket = NewTicket::new(format!("Closed Ticket {index:03}")); ticket.workflow_state = Some(TicketWorkflowState::Closed); @@ -2141,7 +2140,7 @@ mod tests { async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() { let temp = TempDir::new().unwrap(); let backend = backend(&temp); - let list = tool_by_name(backend.clone(), "TicketList"); + let list = tool_by_name(backend.clone(), "QueryTicket"); let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap(); let mut ready_input = NewTicket::new("Ready Ticket"); ready_input.workflow_state = Some(TicketWorkflowState::Ready); @@ -2188,7 +2187,7 @@ mod tests { async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() { let temp = TempDir::new().unwrap(); let backend = backend(&temp); - let list = tool_by_name(backend.clone(), "TicketList"); + let list = tool_by_name(backend.clone(), "QueryTicket"); let close = tool_by_name(backend.clone(), "TicketClose"); let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST"; let thread_secret = "THREAD_SECRET_DO_NOT_LIST"; @@ -2325,7 +2324,7 @@ mod tests { let record = tool_by_name(backend.clone(), "TicketRelationRecord"); let remove = tool_by_name(backend.clone(), "TicketRelationRemove"); let query = tool_by_name(backend.clone(), "TicketRelationQuery"); - let show = tool_by_name(backend.clone(), "TicketShow"); + let show = tool_by_name(backend.clone(), "ShowTicket"); let recorded = record .execute( @@ -2785,7 +2784,7 @@ mod tests { #[tokio::test] async fn ticket_show_requires_exactly_one_identifier() { let temp = TempDir::new().unwrap(); - let show = tool_by_name(backend(&temp), "TicketShow"); + let show = tool_by_name(backend(&temp), "ShowTicket"); let error = show .execute( &json!({ "id": "a", "query": "b" }).to_string(), diff --git a/crates/tui/src/dashboard/mod.rs b/crates/tui/src/dashboard/mod.rs index 17c76502..d1803727 100644 --- a/crates/tui/src/dashboard/mod.rs +++ b/crates/tui/src/dashboard/mod.rs @@ -2045,7 +2045,7 @@ impl DashboardApp { TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); context.ticket = Some(TicketRef::id(ticket_id.clone())); context.user_instruction = Some(format!( - "Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read TicketShow body/thread/artifacts before making routing or requirements decisions." + "Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions." )); let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) { Ok(store) => store, @@ -3925,7 +3925,7 @@ fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str) fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String { format!( - "Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read TicketShow body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}" + "Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}" ) } diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index 78dbd339..b2dd5c57 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -11,7 +11,6 @@ pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[ "MergeRequestShow", "MergeRequestReadinessCheck", "MergeRequestOpen", - "MergeRequestAddRevision", "MergeRequestComplete", ]; pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit"; @@ -20,7 +19,6 @@ enum Kind { Show, Readiness, Open, - AddRevision, Complete, Review, } @@ -29,7 +27,6 @@ struct MergeRequestTool { client: Arc, kind: Kind, } - #[derive(Debug, Deserialize, JsonSchema)] struct ShowInput { ticket: String, @@ -38,23 +35,8 @@ struct ShowInput { struct OpenInput { ticket: String, repository_id: String, - revision_id: String, - base_commit: String, - head_commit: String, - #[serde(default)] - changed_paths: Vec, - #[serde(default)] - summary: String, -} -#[derive(Debug, Deserialize, JsonSchema)] -struct AddRevisionInput { - ticket: String, - expected_current_revision_id: String, - revision_id: String, - base_commit: String, - head_commit: String, - #[serde(default)] - changed_paths: Vec, + selector_from: String, + selector_to: String, #[serde(default)] summary: String, } @@ -62,10 +44,9 @@ struct AddRevisionInput { struct CompleteInput { ticket: String, operation_id: String, - expected_revision_id: String, - target_commit: String, - source_commit: String, - result_commit: String, + approval_event_id: String, + target_ref_before: String, + target_ref_after: String, strategy: MergeStrategyInput, resolution: MergeResolutionInput, } @@ -104,63 +85,48 @@ struct ReviewFindingInput { #[serde(default)] path: Option, #[serde(default)] - line: Option, + line: Option, body: String, } - impl Kind { fn name(self) -> &'static str { match self { Self::Show => "MergeRequestShow", Self::Readiness => "MergeRequestReadinessCheck", Self::Open => "MergeRequestOpen", - Self::AddRevision => "MergeRequestAddRevision", Self::Complete => "MergeRequestComplete", Self::Review => "MergeRequestReviewSubmit", } } - fn description(self) -> &'static str { - description(self.name()).unwrap_or("Merge Request operation.") - } fn schema(self) -> serde_json::Value { match self { Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)), Self::Open => json!(schemars::schema_for!(OpenInput)), - Self::AddRevision => json!(schemars::schema_for!(AddRevisionInput)), Self::Complete => json!(schemars::schema_for!(CompleteInput)), Self::Review => json!(schemars::schema_for!(ReviewInput)), } } } - #[async_trait] impl Tool for MergeRequestTool { - async fn execute( - &self, - input: &str, - _context: ToolExecutionContext, - ) -> Result { - let workspace_id = self.client.workspace_id().ok_or_else(|| { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { + let ws = self.client.workspace_id().ok_or_else(|| { ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into()) })?; let (method, path, body) = match self.kind { - Kind::Show => { - let v: ShowInput = parse(input)?; - nonempty(&v.ticket)?; - ( - WorkspaceRequestMethod::Get, - format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket), - None, - ) - } - Kind::Readiness => { + Kind::Show | Kind::Readiness => { let v: ShowInput = parse(input)?; nonempty(&v.ticket)?; ( WorkspaceRequestMethod::Get, format!( - "/api/w/{workspace_id}/tickets/{}/merge-request/readiness", - v.ticket + "/api/w/{ws}/tickets/{}/merge-request{}", + v.ticket, + if matches!(self.kind, Kind::Readiness) { + "/readiness" + } else { + "" + } ), None, ) @@ -170,62 +136,35 @@ impl Tool for MergeRequestTool { nonempty(&v.ticket)?; ( WorkspaceRequestMethod::Post, - format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket), + format!("/api/w/{ws}/tickets/{}/merge-request", v.ticket), Some( - json!({"repository_id":v.repository_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"changed_paths":v.changed_paths,"summary":v.summary}), - ), - ) - } - Kind::AddRevision => { - let v: AddRevisionInput = parse(input)?; - nonempty(&v.ticket)?; - ( - WorkspaceRequestMethod::Post, - format!( - "/api/w/{workspace_id}/tickets/{}/merge-request/revisions", - v.ticket - ), - Some( - json!({"expected_current_revision_id":v.expected_current_revision_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"changed_paths":v.changed_paths,"summary":v.summary}), + json!({"repository_id":v.repository_id,"selector_from":v.selector_from,"selector_to":v.selector_to,"summary":v.summary}), ), ) } Kind::Complete => { let v: CompleteInput = parse(input)?; nonempty(&v.ticket)?; - let strategy = match v.strategy { - MergeStrategyInput::FastForward => "fast_forward", - MergeStrategyInput::Merge => "merge", - }; - let resolution = match v.resolution { - MergeResolutionInput::None => "none", - MergeResolutionInput::Clean => "clean", - MergeResolutionInput::ConflictsResolved => "conflicts_resolved", - }; ( WorkspaceRequestMethod::Post, - format!( - "/api/w/{workspace_id}/tickets/{}/merge-request/complete", - v.ticket - ), + format!("/api/w/{ws}/tickets/{}/merge-request/complete", v.ticket), Some( - json!({"operation_id":v.operation_id,"expected_revision_id":v.expected_revision_id,"target_commit":v.target_commit,"source_commit":v.source_commit,"result_commit":v.result_commit,"strategy":strategy,"resolution":resolution}), + json!({"operation_id":v.operation_id,"approval_event_id":v.approval_event_id,"target_ref_before":v.target_ref_before,"target_ref_after":v.target_ref_after,"strategy":match v.strategy{MergeStrategyInput::FastForward=>"fast_forward",MergeStrategyInput::Merge=>"merge"},"resolution":match v.resolution{MergeResolutionInput::None=>"none",MergeResolutionInput::Clean=>"clean",MergeResolutionInput::ConflictsResolved=>"conflicts_resolved"}}), ), ) } Kind::Review => { let v: ReviewInput = parse(input)?; - let context = self.client.reviewer_attempt_context().ok_or_else(|| { + let ctx = self.client.reviewer_context().ok_or_else(|| { ToolError::ExecutionFailed( - "MergeRequestReviewSubmit is available only to an attested Reviewer child" - .into(), + "Review submit requires injected Reviewer capability".into(), ) })?; ( WorkspaceRequestMethod::Post, format!( - "/api/w/{workspace_id}/tickets/{}/merge-request/reviews", - context.ticket_id + "/api/w/{ws}/tickets/{}/merge-request/reviews", + ctx.ticket_id ), Some( json!({"decision":match v.decision{ReviewDecisionInput::Approve=>"approve",ReviewDecisionInput::RequestChanges=>"request_changes"},"body":v.body,"findings":v.findings.into_iter().map(|f|json!({"severity":f.severity,"code":f.code,"path":f.path,"line":f.line,"body":f.body})).collect::>() }), @@ -233,32 +172,32 @@ impl Tool for MergeRequestTool { ) } }; - let request = match body { - Some(body) => WorkspaceRequest::json(method, path, body.to_string()), + let req = match body { + Some(v) => WorkspaceRequest::json(method, path, v.to_string()), None => WorkspaceRequest::get(path), }; - let response = self + let res = self .client - .execute(request) + .execute(req) .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; - if !response.is_success() { + if !res.is_success() { return Err(ToolError::ExecutionFailed(format!( "Merge Request API returned HTTP {}: {}", - response.status, response.body + res.status, res.body ))); } Ok(ToolOutput { - summary: self.kind.name().to_string(), - content: Some(response.body), - attachments: Vec::new(), + summary: self.kind.name().into(), + content: Some(res.body), + attachments: vec![], }) } } -fn parse(value: &str) -> Result { - serde_json::from_str(value).map_err(|e| ToolError::InvalidArgument(e.to_string())) +fn parse(v: &str) -> Result { + serde_json::from_str(v).map_err(|e| ToolError::InvalidArgument(e.to_string())) } -fn nonempty(value: &str) -> Result<(), ToolError> { - if value.trim().is_empty() { +fn nonempty(v: &str) -> Result<(), ToolError> { + if v.trim().is_empty() { Err(ToolError::InvalidArgument( "ticket must not be empty".into(), )) @@ -268,74 +207,75 @@ fn nonempty(value: &str) -> Result<(), ToolError> { } fn definition(client: Arc, kind: Kind) -> ToolDefinition { Arc::new(move || { - let meta = ToolMeta::new(kind.name()) - .description(kind.description()) - .input_schema(kind.schema()); - let tool: Arc = Arc::new(MergeRequestTool { - client: client.clone(), - kind, - }); - (meta, tool) + ( + ToolMeta::new(kind.name()) + .description(description(kind.name()).unwrap_or("Merge Request operation.")) + .input_schema(kind.schema()), + Arc::new(MergeRequestTool { + client: client.clone(), + kind, + }) as Arc, + ) }) } -pub fn common_tools(client: Arc) -> Vec { +pub fn common_tools(c: Arc) -> Vec { vec![ - definition(client.clone(), Kind::Show), - definition(client.clone(), Kind::Readiness), - definition(client.clone(), Kind::Open), - definition(client.clone(), Kind::AddRevision), - definition(client, Kind::Complete), + definition(c.clone(), Kind::Show), + definition(c.clone(), Kind::Readiness), + definition(c.clone(), Kind::Open), + definition(c, Kind::Complete), ] } -pub fn reviewer_tools(client: Arc) -> Vec { - if client.reviewer_attempt_context().is_some() { +pub fn reviewer_tools(c: Arc) -> Vec { + if c.reviewer_context().is_some() { vec![ - definition(client.clone(), Kind::Show), - definition(client, Kind::Review), + definition(c.clone(), Kind::Show), + definition(c, Kind::Review), ] } else { - Vec::new() + vec![] } } -pub fn description(name: &str) -> Option<&'static str> { - match name { - "MergeRequestShow" => Some( - "Read the authoritative Merge Request, immutable current revision, and structured review status.", - ), +pub fn description(n: &str) -> Option<&'static str> { + match n { + "MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."), "MergeRequestReadinessCheck" => { - Some("Check derived merge readiness for the current immutable revision.") + Some("Resolve current provider refs and derive readiness from valid review events.") } "MergeRequestOpen" => { - Some("Open an immutable Merge Request revision for the current assigned Coder.") - } - "MergeRequestAddRevision" => { - Some("Append an immutable revision; prior approval cannot carry to the new revision.") + Some("Open a Merge Request with immutable source and target selectors.") } "MergeRequestComplete" => { - Some("CAS-complete an approved revision with operation-id replay and crash fencing.") + Some("Complete using an approved review event and final target-ref evidence.") + } + "MergeRequestReviewSubmit" => { + Some("Submit the injected Reviewer capability result for its captured subject ref.") } - "MergeRequestReviewSubmit" => Some( - "Submit the attested direct-child Reviewer result bound to its immutable revision.", - ), _ => None, } } - #[cfg(test)] mod tests { use super::*; - #[test] - fn merge_request_tool_contract_omits_redundant_revision_evidence_and_candidate_result_tool() { - let open = serde_json::to_string(&schemars::schema_for!(OpenInput)).unwrap(); - let add = serde_json::to_string(&schemars::schema_for!(AddRevisionInput)).unwrap(); - let complete = serde_json::to_string(&schemars::schema_for!(CompleteInput)).unwrap(); - assert!(!open.contains("head_tree")); - assert!(!add.contains("head_tree")); - assert!(!open.contains("diff_digest")); - assert!(!add.contains("diff_digest")); - assert!(complete.contains("result_commit")); - assert!(complete.contains("conflicts_resolved")); - assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRecordMergeResult")); + fn schemas_hide_revision_and_commit_authority() { + let schemas = [ + schemars::schema_for!(OpenInput), + schemars::schema_for!(CompleteInput), + ]; + for s in schemas { + let j = serde_json::to_string(&s).unwrap(); + for banned in [ + "revision_id", + "attempt_id", + "base_commit", + "head_commit", + "source_commit", + "result_commit", + ] { + assert!(!j.contains(banned), "{banned} in {j}") + } + } + assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRequestReview")); } } diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index db5d86a9..a65bacfc 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -26,36 +26,45 @@ impl WorkspaceHttpObjectiveBackend { Self { client } } - async fn list(&self, input: ObjectiveListInput) -> Result { - let mut url = format!( - "/api/w/{}/objectives", + async fn list(&self, input: QueryObjectiveInput) -> Result { + let url = format!( + "/api/w/{}/objectives/query", self.client.workspace_id().unwrap_or_default() ); - if let Some(limit) = input.limit { - url.push_str(&format!("?limit={}", limit.min(1000))); - } - let response = get_json::(self.client.as_ref(), &url) - .await - .map_err(backend_error)?; - let count = response.items.len(); + let response = send_json::( + self.client.as_ref(), + reqwest::Method::POST, + &url, + &input, + ) + .await + .map_err(backend_error)?; Ok(ToolOutput { - summary: format!("Listed {count} objective(s)"), + summary: "Queried Objectives".to_string(), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), - attachments: Vec::new(), }) } - async fn show(&self, input: ObjectiveShowInput) -> Result { - let id = validate_id(&input.id, "ObjectiveShow")?; - let url = self.objective_url(id); - let response = get_json::(self.client.as_ref(), &url) - .await - .map_err(backend_error)?; - Ok(objective_output( - format!("Read objective {}", response.id), - response, - )?) + async fn show(&self, input: ShowObjectiveInput) -> Result { + let id = validate_id(&input.id, "ShowObjective")?; + let url = format!("{}/show", self.objective_url(id)); + let response = send_json::( + self.client.as_ref(), + reqwest::Method::POST, + &url, + &ObjectiveShowRequest { + event_limit: input.event_limit, + event_cursor: input.event_cursor, + }, + ) + .await + .map_err(backend_error)?; + Ok(ToolOutput { + summary: format!("Read objective {id}"), + content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), + attachments: Vec::new(), + }) } async fn create(&self, input: ObjectiveCreateInput) -> Result { @@ -195,13 +204,6 @@ fn backend_error(error: WorkspaceObjectiveBackendError) -> ToolError { ToolError::ExecutionFailed(error.to_string()) } -async fn get_json Deserialize<'de>>( - client: &dyn WorkspaceClient, - path: &str, -) -> Result { - decode_response(client.execute(WorkspaceRequest::get(path))?) -} - async fn send_json Deserialize<'de>>( client: &dyn WorkspaceClient, method: reqwest::Method, @@ -270,14 +272,14 @@ pub fn workspace_http_objective_tools(client: Arc) -> Vec Result { match self.operation { ObjectiveOperation::List => { - let input = parse_input::(input_json)?; + let input = parse_input::(input_json)?; self.backend.list(input).await } ObjectiveOperation::Show => { - let input = parse_input::(input_json)?; + let input = parse_input::(input_json)?; self.backend.show(input).await } ObjectiveOperation::Create => { @@ -402,10 +404,8 @@ fn parse_input Deserialize<'de>>(input: &str) -> Result serde_json::Value { "type":"object", "additionalProperties": false, "properties":{ - "limit":{"type":["integer","null"],"minimum":0,"maximum":1000} + "query":{"type":["string","null"]}, + "states":{"type":"array","items":{"type":"string"},"default":[]}, + "linked_ticket_id":{"type":["string","null"]}, + "updated_after":{"type":["string","null"]}, + "updated_before":{"type":["string","null"]}, + "sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]}, + "limit":{"type":["integer","null"],"minimum":1,"maximum":100}, + "cursor":{"type":["string","null"]} } }) } fn show_schema() -> serde_json::Value { - id_schema(&["id"]) + json!({ + "type":"object", + "additionalProperties": false, + "required":["id"], + "properties":{ + "id":{"type":"string"}, + "event_limit":{"type":["integer","null"],"minimum":1,"maximum":50}, + "event_cursor":{"type":["string","null"]} + } + }) } fn create_schema() -> serde_json::Value { @@ -480,17 +496,6 @@ fn unlink_ticket_schema() -> serde_json::Value { id_ticket_schema(&["id", "ticket_id"]) } -fn id_schema(required: &[&str]) -> serde_json::Value { - json!({ - "type":"object", - "additionalProperties": false, - "required": required, - "properties":{ - "id":{"type":"string"} - } - }) -} - fn id_ticket_schema(required: &[&str]) -> serde_json::Value { json!({ "type":"object", @@ -503,14 +508,30 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value { }) } -#[derive(Debug, Deserialize)] -struct ObjectiveListInput { +#[derive(Debug, Serialize, Deserialize)] +struct QueryObjectiveInput { + query: Option, + #[serde(default)] + states: Vec, + linked_ticket_id: Option, + updated_after: Option, + updated_before: Option, + sort: Option, limit: Option, + cursor: Option, } #[derive(Debug, Deserialize)] -struct ObjectiveShowInput { +struct ShowObjectiveInput { id: String, + event_limit: Option, + event_cursor: Option, +} + +#[derive(Debug, Serialize)] +struct ObjectiveShowRequest { + event_limit: Option, + event_cursor: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -574,30 +595,6 @@ fn default_state() -> String { "active".to_string() } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct ObjectiveListResponse { - items: Vec, - invalid_records: Vec, - record_authority: String, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct InvalidProjectRecord { - label: String, - reason: String, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct ObjectiveSummary { - id: String, - title: String, - state: String, - updated_at: Option, - summary: String, - linked_tickets: Vec, - record_source: String, -} - #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct ObjectiveDetail { id: String, @@ -637,10 +634,10 @@ mod tests { "ObjectiveCreate", "ObjectiveEdit", "ObjectiveLinkTicket", - "ObjectiveList", "ObjectiveSetState", - "ObjectiveShow", "ObjectiveUnlinkTicket", + "QueryObjective", + "ShowObjective", ] ); } @@ -648,9 +645,12 @@ mod tests { #[test] fn objective_tool_schemas_are_bounded_and_mutation_scoped() { let list = list_schema(); - assert_eq!(list["properties"]["limit"]["maximum"], 1000); + assert_eq!(list["properties"]["limit"]["maximum"], 100); + assert!(list["properties"]["cursor"].is_object()); + assert!(list["properties"]["linked_ticket_id"].is_object()); let show = show_schema(); assert_eq!(show["required"][0], "id"); + assert_eq!(show["properties"]["event_limit"]["maximum"], 50); let create = create_schema(); assert_eq!(create["required"][0], "title"); let edit = edit_schema(); diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 099b7978..ac461c13 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -9,6 +9,10 @@ use std::{ sync::Arc, }; +use async_trait::async_trait; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; use ticket::{ LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, @@ -25,8 +29,238 @@ use crate::feature::{ FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, + ToolDefinition, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; +use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; + +#[derive(Clone, Copy)] +enum WorkspaceTicketReadKind { + Query, + Show, +} + +impl WorkspaceTicketReadKind { + fn name(self) -> &'static str { + match self { + Self::Query => "QueryTicket", + Self::Show => "ShowTicket", + } + } + + fn description(self) -> &'static str { + match self { + Self::Query => { + "Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata." + } + Self::Show => { + "Show one authoritative Workspace Ticket with its item revision, paged thread, links, implementation reports, and current Merge Request review evidence." + } + } + } + + fn schema(self) -> Value { + match self { + Self::Query => serde_json::to_value(schemars::schema_for!(WorkspaceQueryTicketInput)) + .expect("QueryTicket schema serializes"), + Self::Show => serde_json::to_value(schemars::schema_for!(WorkspaceShowTicketInput)) + .expect("ShowTicket schema serializes"), + } + } +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketStateFilter { + Planning, + Ready, + Queued, + Inprogress, + Done, + Closed, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketEvidenceFilter { + ImplementationReport, + ImplementationReportAfterRescope, + MergeRequest, + Commit, + ApprovedReview, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketReviewFilter { + None, + Pending, + Approved, + RequestChanges, + UnresolvedChanges, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketAttentionFilter { + DoneNotClosed, + ImplementationReportNotClosed, + ReportAfterRescope, + UnresolvedReview, + MissingCommit, + Blocked, + Unblocked, + Ready, + AwaitingReview, + UnresolvedChanges, + StaleAfterRescope, + MissingEvidence, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketRelationFilter { + DependsOn, + Blocks, + Related, + Supersedes, + DuplicateOf, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketSort { + Relevance, + UpdatedDesc, + CreatedDesc, + Priority, + Title, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +struct WorkspaceQueryTicketInput { + /// Full-text match over Ticket title, item body, and bounded thread excerpts. + query: Option, + /// Exact workflow states. Empty means every state. + #[serde(default)] + states: Vec, + /// Exact typed event kinds that must occur in the bounded thread window. + #[serde(default)] + event_kinds: Vec, + /// Required evidence kinds: implementation_report, implementation_report_after_rescope, + /// merge_request, commit, or approved_review. + #[serde(default)] + evidence: Vec, + /// Current authoritative Merge Request review status: none, pending, approved, + /// request_changes, or unresolved_changes. + review_status: Option, + /// Attention filters include done_not_closed, implementation_report_not_closed, + /// report_after_rescope, unresolved_review, missing_commit, blocked, and unblocked. + #[serde(default)] + attention: Vec, + related_ticket_id: Option, + relation_kind: Option, + linked_objective_id: Option, + updated_after: Option, + updated_before: Option, + /// relevance (default when query is present), updated_desc, created_desc, + /// priority, or title. + sort: Option, + /// Page size; bounded by the Backend to 1..=100. + limit: Option, + /// Opaque cursor returned by a prior QueryTicket page. + cursor: Option, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +struct WorkspaceShowTicketInput { + id: String, + /// Most-recent thread entries to return, bounded by the Backend to 1..=50. + event_limit: Option, + /// Opaque event cursor returned by a prior ShowTicket page. + event_cursor: Option, +} + +#[derive(Clone)] +struct WorkspaceTicketReadTool { + client: Arc, + kind: WorkspaceTicketReadKind, +} + +#[async_trait] +impl Tool for WorkspaceTicketReadTool { + async fn execute( + &self, + input: &str, + _context: ToolExecutionContext, + ) -> Result { + let workspace_id = self.client.workspace_id().ok_or_else(|| { + ToolError::InvalidArgument("Workspace Ticket reads require workspace identity".into()) + })?; + let (path, body) = match self.kind { + WorkspaceTicketReadKind::Query => { + let input: WorkspaceQueryTicketInput = serde_json::from_str(&input) + .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; + ( + format!("/api/w/{workspace_id}/tickets/query"), + serde_json::to_value(input) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?, + ) + } + WorkspaceTicketReadKind::Show => { + let input: WorkspaceShowTicketInput = serde_json::from_str(&input) + .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; + if input.id.trim().is_empty() { + return Err(ToolError::InvalidArgument( + "ShowTicket.id must not be empty".into(), + )); + } + let path = format!("/api/w/{workspace_id}/tickets/{}/show", input.id.trim()); + let body = json!({ + "event_limit": input.event_limit, + "event_cursor": input.event_cursor, + }); + (path, body) + } + }; + let response = self + .client + .execute(WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + path, + serde_json::to_string(&body) + .map_err(|error| ToolError::Internal(error.to_string()))?, + )) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if !response.is_success() { + return Err(ToolError::ExecutionFailed(format!( + "Workspace Ticket API returned HTTP {}: {}", + response.status, response.body + ))); + } + Ok(ToolOutput { + summary: self.kind.name().to_string(), + content: Some(response.body), + attachments: Vec::new(), + }) + } +} + +fn workspace_ticket_read_definition( + client: Arc, + kind: WorkspaceTicketReadKind, +) -> ToolDefinition { + Arc::new(move || { + let meta = ToolMeta::new(kind.name()) + .description(kind.description()) + .input_schema(kind.schema()); + let tool: Arc = Arc::new(WorkspaceTicketReadTool { + client: client.clone(), + kind, + }); + (meta, tool) + }) +} const FEATURE_ID: &str = "ticket"; const FEATURE_NAME: &str = "Ticket tools"; @@ -142,14 +376,7 @@ impl TicketFeatureAccess { } } -const READ_ONLY_TOOL_NAMES: &[&str] = &[ - "TicketList", - "TicketShow", - "TicketDependencyCheck", - "TicketDoctor", - "TicketRelationQuery", - "TicketOrchestrationPlanQuery", -]; +const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", @@ -168,31 +395,25 @@ const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"]; const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", - "TicketList", - "TicketShow", + "QueryTicket", + "ShowTicket", "TicketComment", "TicketQueue", "TicketClose", - "TicketDependencyCheck", - "TicketDoctor", "TicketRelationRecord", "TicketRelationRemove", - "TicketRelationQuery", - "TicketOrchestrationPlanQuery", ]; #[cfg(test)] const WORKFLOW_TOOL_NAMES: &[&str] = &[ - "TicketList", - "TicketShow", + "QueryTicket", + "ShowTicket", "TicketComment", "TicketWorkflowState", "TicketClose", "TicketDependencyCheck", - "TicketDoctor", "TicketRelationRecord", "TicketRelationRemove", - "TicketRelationQuery", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", ]; @@ -200,9 +421,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ "TicketWorkflowState", "TicketClose", + "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", + "TicketOrchestrationPlanQuery", ]; #[derive(Clone, Debug)] @@ -366,7 +589,7 @@ impl FeatureModule for TicketFeature { )); } if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend { - let names: Vec<&str> = if client.reviewer_attempt_context().is_some() { + let names: Vec<&str> = if client.reviewer_context().is_some() { vec![ "MergeRequestShow", merge_request::MERGE_REQUEST_REVIEW_TOOL_NAME, @@ -413,6 +636,10 @@ impl FeatureModule for TicketFeature { ticket_workflow_instruction(), ))?; let allowed_tool_names = self.enabled_tool_names(); + let workspace_client = match &self.backend { + TicketFeatureBackend::WorkspaceClient(client) => Some(client.clone()), + TicketFeatureBackend::Local { .. } => None, + }; let mut tools = context.tools(); for definition in ticket_tools(backend) { let (meta, _) = definition(); @@ -423,10 +650,19 @@ impl FeatureModule for TicketFeature { { continue; } + let definition = match (name.as_str(), workspace_client.as_ref()) { + ("QueryTicket", Some(client)) => { + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query) + } + ("ShowTicket", Some(client)) => { + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show) + } + _ => definition, + }; tools.register(ToolContribution::new(name, definition))?; } if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend { - let definitions = if client.reviewer_attempt_context().is_some() { + let definitions = if client.reviewer_context().is_some() { merge_request::reviewer_tools(client.clone()) } else { merge_request::common_tools(client.clone()) @@ -1044,6 +1280,47 @@ mod tests { .expect("tool exists") } + #[test] + fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() { + let client: Arc = Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), + ); + let (query, _) = + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)(); + assert_eq!(query.name, "QueryTicket"); + assert!(query.input_schema["properties"]["evidence"].is_object()); + assert!(query.input_schema["properties"]["attention"].is_object()); + assert!(query.input_schema["properties"]["cursor"].is_object()); + let query_schema = serde_json::to_string(&query.input_schema).unwrap(); + assert!(query_schema.contains("done_not_closed")); + assert!(query_schema.contains("request_changes")); + assert!(query_schema.contains("created_desc")); + assert!( + query_schema.len() < 8_000, + "QueryTicket schema grew unexpectedly" + ); + let (show, _) = workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)(); + assert_eq!(show.name, "ShowTicket"); + assert!(show.input_schema["properties"]["event_limit"].is_object()); + let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); + assert_eq!(tool_names.len(), 9); + assert!( + tool_names.len() < 13, + "authoring catalog must stay below the prior broad catalog" + ); + let workflow_names = TicketFeatureAccess::workflow().tool_names(); + assert_eq!(workflow_names.len(), 10); + assert!( + workflow_names.len() < 12, + "workflow catalog must stay below the prior broad catalog" + ); + assert_eq!(TicketFeatureAccess::review().tool_names().len(), 2); + assert!(tool_names.contains(&"QueryTicket")); + assert!(tool_names.contains(&"ShowTicket")); + assert!(!tool_names.contains(&"TicketList")); + assert!(!tool_names.contains(&"TicketShow")); + } + #[test] fn descriptor_declares_ticket_tools() { let temp = TempDir::new().unwrap(); @@ -1200,8 +1477,8 @@ language = "Japanese" let descriptor_description = descriptor .tools .iter() - .find(|tool| tool.name == "TicketShow") - .expect("TicketShow declared") + .find(|tool| tool.name == "ShowTicket") + .expect("ShowTicket declared") .description .clone(); assert!(descriptor_description.contains("Ticket record language: Japanese")); @@ -1214,7 +1491,7 @@ language = "Japanese" assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len()); assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES); - let description = pending_tool_description(&pending_tools, "TicketShow"); + let description = pending_tool_description(&pending_tools, "ShowTicket"); assert!(description.contains("Ticket record language: Japanese")); assert!(description.contains("distinct from worker.language")); assert!(description.contains("Preserve protocol literals")); diff --git a/crates/worker/src/shutdown_after_idle.rs b/crates/worker/src/shutdown_after_idle.rs index 6586b842..bae83158 100644 --- a/crates/worker/src/shutdown_after_idle.rs +++ b/crates/worker/src/shutdown_after_idle.rs @@ -132,7 +132,7 @@ mod tests { let request = ShutdownAfterIdleRequest::default(); let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true); - hook.observe_tool_result(&tool_result("TicketShow", false)); + hook.observe_tool_result(&tool_result("ShowTicket", false)); assert!(!request.is_requested()); } diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 5f29ef5f..17096356 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -28,7 +28,7 @@ use crate::internal_worker::{ use crate::prompt::catalog::PromptCatalog; use crate::spawn::registry::SpawnedWorkerRegistry; use crate::worker::{ - ReviewerAttemptContext, ReviewerChildWorkspaceClient, Worker, WorkerFilesystemAuthority, + ReviewerChildWorkspaceClient, ReviewerContext, Worker, WorkerFilesystemAuthority, WorkspaceRequest, WorkspaceRequestMethod, }; use protocol::Method; @@ -58,8 +58,8 @@ struct SubWorkerSpawnInput { /// spawner's explicit delegation authority; direct tool scope alone is not /// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true. scope: Vec, - /// Binds an actual read-only builtin Reviewer child to an immutable Merge Request revision. - /// Review attempt identity and capability material are generated by the trusted spawn layer. + /// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate. + /// Review capability material is generated by the trusted spawn layer. #[serde(default)] review: Option, } @@ -67,7 +67,6 @@ struct SubWorkerSpawnInput { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct ReviewerHandoffInput { ticket_id: String, - revision_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -337,9 +336,9 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro let Some(review) = &input.review else { return Ok(()); }; - if review.ticket_id.trim().is_empty() || review.revision_id.trim().is_empty() { + if review.ticket_id.trim().is_empty() { return Err(ToolError::InvalidArgument( - "reviewer handoff requires non-empty ticket_id and revision_id".to_string(), + "reviewer handoff requires non-empty ticket_id".to_string(), )); } if input.profile.as_deref() != Some("builtin:reviewer") { @@ -418,11 +417,9 @@ impl Tool for SubWorkerSpawnTool { .map_err(|error| { ToolError::ExecutionFailed(format!("resolve child manifest: {error}")) })?; - let reviewer_attempt = input.review.as_ref().map(|review| { + let reviewer_capability = input.review.as_ref().map(|review| { ( review.ticket_id.clone(), - review.revision_id.clone(), - uuid::Uuid::now_v7().to_string(), format!( "{}{}", uuid::Uuid::now_v7().simple(), @@ -431,7 +428,7 @@ impl Tool for SubWorkerSpawnTool { ) }); let child_workspace_context = - if let Some((ticket_id, revision_id, _, capability_token)) = &reviewer_attempt { + if let Some((ticket_id, capability_token)) = &reviewer_capability { let workspace_id = self.workspace_context .workspace_id() @@ -450,9 +447,8 @@ impl Tool for SubWorkerSpawnTool { let child_client: Arc = Arc::new(ReviewerChildWorkspaceClient::new( parent_client.clone(), - ReviewerAttemptContext { + ReviewerContext { ticket_id: ticket_id.clone(), - revision_id: revision_id.clone(), }, capability_token.clone(), )); @@ -547,9 +543,9 @@ impl Tool for SubWorkerSpawnTool { } }; - if let Some((ticket_id, revision_id, attempt_id, capability_token)) = &reviewer_attempt { + if let Some((ticket_id, capability_token)) = &reviewer_capability { let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| { - ToolError::ExecutionFailed("reviewer attempt lost Workspace identity".to_string()) + ToolError::ExecutionFailed("review capability lost Workspace identity".to_string()) })?; let child_session_id = session.session_id_string(); let child_registration = WorkspaceRequest::json( @@ -577,15 +573,13 @@ impl Tool for SubWorkerSpawnTool { ))); } let body = serde_json::json!({ - "attempt_id": attempt_id, - "revision_id": revision_id, "child_session_id": child_session_id, "capability_token": capability_token, }); let request = WorkspaceRequest::json( WorkspaceRequestMethod::Post, format!( - "/api/w/{}/tickets/{}/merge-request/review-attempts", + "/api/w/{}/tickets/{}/merge-request/review-capabilities", workspace_id.as_str(), ticket_id ), @@ -596,12 +590,12 @@ impl Tool for SubWorkerSpawnTool { .client() .execute(request) .map_err(|error| { - ToolError::ExecutionFailed(format!("register reviewer attempt: {error}")) + ToolError::ExecutionFailed(format!("register review capability: {error}")) })?; if !response.is_success() { let _ = session.stop().await; return Err(ToolError::ExecutionFailed(format!( - "register reviewer attempt failed with status {}: {}", + "register review capability failed with status {}: {}", response.status, response.body ))); } @@ -1044,21 +1038,21 @@ mod tests { let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:reviewer", "scope":[{"target":"/tmp/work","permission":"read"}], - "review":{"ticket_id":"T1","revision_id":"V1"} + "review":{"ticket_id":"T1"} })) .unwrap(); assert!(validate_reviewer_handoff(&valid).is_ok()); let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:coder", "scope":[{"target":"/tmp/work","permission":"read"}], - "review":{"ticket_id":"T1","revision_id":"V1"} + "review":{"ticket_id":"T1"} })) .unwrap(); assert!(validate_reviewer_handoff(&wrong_profile).is_err()); let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:reviewer", "scope":[{"target":"/tmp/work","permission":"write"}], - "review":{"ticket_id":"T1","revision_id":"V1"} + "review":{"ticket_id":"T1"} })) .unwrap(); assert!(validate_reviewer_handoff(&writable).is_err()); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index a9b5bd6d..40a0ec7e 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -238,30 +238,29 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { )) } - /// Trusted review-attempt context is injected by the Internal SubWorker spawn layer. + /// Trusted review capability context is injected by the Internal SubWorker spawn layer. /// It is never accepted from a model-visible tool argument. - fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> { + fn reviewer_context(&self) -> Option<&ReviewerContext> { None } } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReviewerAttemptContext { +pub struct ReviewerContext { pub ticket_id: String, - pub revision_id: String, } #[derive(Debug)] pub struct ReviewerChildWorkspaceClient { inner: Arc, - context: ReviewerAttemptContext, + context: ReviewerContext, capability_token: String, } impl ReviewerChildWorkspaceClient { pub fn new( inner: Arc, - context: ReviewerAttemptContext, + context: ReviewerContext, capability_token: String, ) -> Self { Self { @@ -282,7 +281,7 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { fn is_available(&self) -> bool { self.inner.is_available() } - fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> { + fn reviewer_context(&self) -> Option<&ReviewerContext> { Some(&self.context) } @@ -306,10 +305,6 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { "review submission body must be an object".to_string(), ) })?; - object.insert( - "revision_id".to_string(), - serde_json::Value::String(self.context.revision_id.clone()), - ); object.insert( "capability_token".to_string(), serde_json::Value::String(self.capability_token.clone()), @@ -450,9 +445,8 @@ mod reviewer_client_tests { }); let client = ReviewerChildWorkspaceClient::new( inner, - ReviewerAttemptContext { + ReviewerContext { ticket_id: "T1".into(), - revision_id: "V1".into(), }, "secret".into(), ); diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index b4224666..9070db63 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -1,16 +1,24 @@ -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; use chrono::Utc; +use merge_request::{ + MergeRequest, MergeRequestError, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, + ReviewDecision, +}; use project_record::{allocate_record_id, unix_epoch_millis_now}; use ticket::{ - SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkspaceActionPriority, + SqliteTicketBackend, TicketBackend, TicketEvent, TicketIdOrSlug, TicketWorkspaceActionPriority, project_ticket_workspace_item, }; use crate::records::{ - ObjectiveDetail, ObjectiveResourceSummary, ObjectiveSummary, ProjectRecordList, TicketDetail, - TicketEventDetail, TicketSummary, summarize_body, truncate_body, validate_project_id, + ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveLinkedTicketSummary, + ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary, + ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary, + TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary, + TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse, + TicketShowRequest, TicketSummary, summarize_body, truncate_body, validate_project_id, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, @@ -35,12 +43,16 @@ impl WorkspaceAuthority for T where T: ObjectiveAuthority + TicketAuthority + pub trait TicketAuthority { fn list_tickets(&self, limit: usize) -> Result>; + fn query_tickets(&self, query: TicketQueryRequest) -> Result; fn ticket(&self, id: &str) -> Result; + fn show_ticket(&self, id: &str, query: TicketShowRequest) -> Result; } pub trait ObjectiveAuthority { fn list_objectives(&self, limit: usize) -> Result>; + fn query_objectives(&self, query: ObjectiveQueryRequest) -> Result; fn objective(&self, id: &str) -> Result; + fn show_objective(&self, id: &str, query: ObjectiveShowRequest) -> Result; fn create_objective(&self, input: ObjectiveCreateInput) -> Result; fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result; fn set_objective_state(&self, id: &str, state: &str) -> Result; @@ -117,21 +129,75 @@ pub struct MemoryStagingResolution { pub record_source: String, } +#[derive(Clone)] +struct AuthorityMergeRequestSource { + store: SqliteWorkspaceStore, +} + +impl merge_request::AssignmentSource for AuthorityMergeRequestSource { + fn current_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> std::result::Result, String> { + self.store + .get_current_ticket_worker_assignment(workspace_id, ticket_id) + .map(|assignment| { + assignment.map(|assignment| merge_request::CurrentAssignment { + assignment_id: assignment.assignment_id, + ticket_id: ticket_id.to_string(), + runtime_id: assignment.worker.runtime_id, + worker_id: assignment.worker.worker_id, + }) + }) + .map_err(|error| error.to_string()) + } +} + +impl merge_request::RepositorySource for AuthorityMergeRequestSource { + fn repository_belongs_to_workspace( + &self, + workspace_id: &str, + repository_id: &str, + ) -> std::result::Result { + self.store + .get_repository(workspace_id, repository_id) + .map(|repository| repository.is_some()) + .map_err(|error| error.to_string()) + } +} + #[derive(Clone)] pub struct SqliteWorkspaceAuthority { workspace_id: String, store: SqliteWorkspaceStore, ticket_backend: SqliteTicketBackend, + merge_request_store: Arc, } impl SqliteWorkspaceAuthority { pub fn new(database_path: impl Into, workspace_id: impl Into) -> Result { let database_path = database_path.into(); let workspace_id = workspace_id.into(); + let store = SqliteWorkspaceStore::open(&database_path)?; + let merge_request_source = Arc::new(AuthorityMergeRequestSource { + store: store.clone(), + }); Ok(Self { workspace_id: workspace_id.clone(), - store: SqliteWorkspaceStore::open(&database_path)?, - ticket_backend: SqliteTicketBackend::open_verified(database_path, workspace_id)?, + store, + ticket_backend: SqliteTicketBackend::open_verified( + database_path.clone(), + workspace_id, + )?, + merge_request_store: Arc::new( + MergeRequestStore::open( + database_path, + merge_request_source.clone(), + merge_request_source, + ) + .map_err(|error| Error::Store(error.to_string()))?, + ), }) } @@ -148,6 +214,17 @@ impl SqliteWorkspaceAuthority { .into_iter() .map(|link| link.ticket_id) .collect::>(); + let linked_ticket_summaries = self + .list_tickets(1_000)? + .items + .into_iter() + .filter(|ticket| linked_tickets.iter().any(|id| id == &ticket.id)) + .map(|ticket| ObjectiveLinkedTicketSummary { + id: ticket.id, + title: ticket.title, + state: ticket.state, + }) + .collect::>(); let resources = self .store .list_objective_resources(&self.workspace_id, &record.objective_id)? @@ -160,16 +237,52 @@ impl SqliteWorkspaceAuthority { }) .collect(); let (body, body_truncated) = truncate_body(&record.body_md, DETAIL_BODY_LIMIT); + let all_events = self + .store + .list_objective_events(&self.workspace_id, &record.objective_id)?; + let event_start = all_events.len().saturating_sub(TICKET_EVENT_LIMIT); + let events = all_events[event_start..] + .iter() + .map(|event| ObjectiveEventDetail { + event_ref: event.event_id.clone(), + kind: event.kind.clone(), + body: event + .body_md + .as_deref() + .map(|body| truncate_body(body, TICKET_EVENT_BODY_LIMIT).0), + created_at: event.created_at.clone(), + }) + .collect::>(); + let revision = format!( + "{}:{}", + record.updated_at, + all_events + .last() + .map(|event| event.event_id.as_str()) + .unwrap_or("none") + ); Ok(ObjectiveDetail { id: record.objective_id, title: record.title, state: record.state, + revision, created_at: Some(record.created_at), updated_at: Some(record.updated_at), linked_tickets, + linked_ticket_summaries, resources, body, body_truncated, + events, + event_page: QueryPage { + limit: TICKET_EVENT_LIMIT, + returned: all_events.len() - event_start, + has_more: event_start > 0, + next_cursor: (event_start > 0).then(|| event_start.to_string()), + sort: "sequence_desc".to_string(), + source_limit: None, + source_truncated: false, + }, record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), }) } @@ -203,6 +316,116 @@ impl SqliteWorkspaceAuthority { created_at: now_rfc3339(), }) } + + fn read_ticket_detail(&self, id: &str, request: TicketShowRequest) -> Result { + validate_project_id(id)?; + let ticket = self + .ticket_backend + .show(TicketIdOrSlug::Id(id.to_string()))?; + let (body, body_truncated) = + truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); + let event_limit = request + .event_limit + .unwrap_or(TICKET_EVENT_LIMIT) + .clamp(1, TICKET_EVENT_LIMIT); + let event_end = request + .event_cursor + .as_deref() + .map(|cursor| parse_offset_cursor(cursor, "event_cursor")) + .transpose()? + .unwrap_or(ticket.events.len()) + .min(ticket.events.len()); + let event_start = event_end.saturating_sub(event_limit); + let events = ticket.events[event_start..event_end] + .iter() + .enumerate() + .map(|(index, event)| ticket_event_detail(event_start + index, event)) + .collect::>(); + let linked_objectives = self + .store + .list_objectives_for_ticket(&self.workspace_id, id, 1_000)? + .into_iter() + .map(|objective| ObjectiveLinkSummary { + id: objective.objective_id, + title: objective.title, + state: objective.state, + }) + .collect::>(); + let implementation_reports = ticket + .events + .iter() + .enumerate() + .filter(|(_, event)| event.kind.as_str() == "implementation_report") + .map(|(sequence, event)| ticket_evidence_event(sequence, event)) + .collect::>(); + let current_assignment = self + .store + .get_current_ticket_worker_assignment(&self.workspace_id, id)? + .map(|assignment| TicketAssignmentSummary { + assignment_id: assignment.assignment_id, + runtime_id: assignment.worker.runtime_id, + worker_id: assignment.worker.worker_id, + }); + let merge_request = match self.merge_request_store.get(&self.workspace_id, id) { + Ok(request) => Some(merge_request_summary(request)), + Err(MergeRequestError::NotFound) => None, + Err(error) => return Err(Error::Store(error.to_string())), + }; + let evidence = ticket_evidence_summary(&ticket.events, merge_request.as_ref()); + let item_revision = ticket + .events + .iter() + .rev() + .find(|event| matches!(event.kind.as_str(), "create" | "item_edit")) + .and_then(|event| event.attributes.get("event_id").cloned()) + .or_else(|| ticket.meta.updated_at.clone()) + .unwrap_or_else(|| format!("{}:0", ticket.meta.id)); + Ok(TicketDetail { + id: ticket.meta.id, + title: ticket.meta.title, + state: ticket.meta.workflow_state.as_str().to_string(), + readiness: ticket.meta.readiness, + priority: ticket.meta.priority, + created_at: ticket.meta.created_at, + updated_at: ticket.meta.updated_at, + item_revision, + queued_by: ticket.meta.queued_by, + queued_at: ticket.meta.queued_at, + assignee: ticket.meta.assignee, + repository_id: ticket.meta.repository_id, + ref_selector: ticket.meta.ref_selector, + risk_flags: ticket.meta.risk_flags, + body, + body_truncated, + event_count: ticket.events.len(), + events, + event_page: QueryPage { + limit: event_limit, + returned: event_end - event_start, + has_more: event_start > 0, + next_cursor: (event_start > 0).then(|| event_start.to_string()), + sort: "sequence_desc".to_string(), + source_limit: None, + source_truncated: false, + }, + artifact_count: ticket.artifacts.len(), + artifacts: ticket + .artifacts + .into_iter() + .map(|artifact| artifact.relative_path.display().to_string()) + .collect(), + relations: ticket.relations.into(), + linked_objectives, + implementation_reports, + current_assignment, + merge_request, + evidence, + resolution: ticket + .resolution + .map(|resolution| resolution.as_str().to_string()), + record_source: "sqlite_yoi_ticket".to_string(), + }) + } } impl TicketAuthority for SqliteWorkspaceAuthority { @@ -235,62 +458,77 @@ impl TicketAuthority for SqliteWorkspaceAuthority { }) } - fn ticket(&self, id: &str) -> Result { - validate_project_id(id)?; - let ticket = self - .ticket_backend - .show(TicketIdOrSlug::Id(id.to_string()))?; - let (body, body_truncated) = - truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); - let event_start = ticket.events.len().saturating_sub(TICKET_EVENT_LIMIT); - let events = ticket.events[event_start..] - .iter() - .enumerate() - .map(|(index, event)| TicketEventDetail { - sequence: event_start + index, - kind: event.kind.as_str().to_owned(), - author: event.author.clone(), - at: event.at.clone(), - status: event.status.clone(), - from: event.from.clone(), - to: event.to.clone(), - reason: event.reason.clone(), - state_field: event.state_field.clone(), - heading: event.heading.clone(), - body: (!event.body.as_str().is_empty()) - .then(|| truncate_body(event.body.as_str(), TICKET_EVENT_BODY_LIMIT).0), - }) - .collect(); - Ok(TicketDetail { - id: ticket.meta.id, - title: ticket.meta.title, - state: ticket.meta.workflow_state.as_str().to_string(), - priority: ticket.meta.priority, - created_at: ticket.meta.created_at, - updated_at: ticket.meta.updated_at, - queued_by: ticket.meta.queued_by, - queued_at: ticket.meta.queued_at, - assignee: ticket.meta.assignee, - repository_id: ticket.meta.repository_id, - ref_selector: ticket.meta.ref_selector, - risk_flags: ticket.meta.risk_flags, - body, - body_truncated, - event_count: ticket.events.len(), - events, - artifact_count: ticket.artifacts.len(), - artifacts: ticket - .artifacts - .into_iter() - .map(|artifact| artifact.relative_path.display().to_string()) - .collect(), - relations: ticket.relations.into(), - resolution: ticket - .resolution - .map(|resolution| resolution.as_str().to_string()), - record_source: "sqlite_yoi_ticket".to_string(), + fn query_tickets(&self, query: TicketQueryRequest) -> Result { + validate_ticket_query(&query)?; + let limit = query.limit.unwrap_or(50).clamp(1, 100); + let sort = normalize_ticket_sort(query.sort.as_deref(), query.query.is_some())?; + let cursor = query + .cursor + .as_deref() + .map(parse_query_cursor) + .transpose()?; + let mut summaries = self.list_tickets(1_001)?.items; + let source_truncated = summaries.len() > 1_000; + summaries.truncate(1_000); + let mut items = Vec::new(); + for summary in summaries { + let detail = self.read_ticket_detail( + &summary.id, + TicketShowRequest { + event_limit: Some(TICKET_EVENT_LIMIT), + event_cursor: None, + }, + )?; + let authoritative = self + .ticket_backend + .show(TicketIdOrSlug::Id(summary.id.clone()))?; + if ticket_matches_query( + &summary, + &detail, + authoritative.document.body.as_str(), + &authoritative.events, + &query, + ) { + items.push(ticket_query_item( + summary, + &detail, + authoritative.document.body.as_str(), + &authoritative.events, + &query, + )); + } + } + sort_ticket_query_items(&mut items, sort); + if let Some(cursor) = cursor { + items.retain(|item| ticket_item_after_cursor(item, sort, &cursor)); + } + let has_more = items.len() > limit; + items.truncate(limit); + let next_cursor = has_more + .then(|| items.last().map(|item| make_ticket_cursor(item, sort))) + .flatten(); + Ok(TicketQueryResponse { + page: QueryPage { + limit, + returned: items.len(), + has_more, + next_cursor, + sort: sort.to_string(), + source_limit: Some(1_000), + source_truncated, + }, + items, + record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), }) } + + fn ticket(&self, id: &str) -> Result { + self.read_ticket_detail(id, TicketShowRequest::default()) + } + + fn show_ticket(&self, id: &str, query: TicketShowRequest) -> Result { + self.read_ticket_detail(id, query) + } } impl ObjectiveAuthority for SqliteWorkspaceAuthority { @@ -307,6 +545,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { id: record.objective_id, title: record.title, state: record.state, + created_at: Some(record.created_at), updated_at: Some(record.updated_at), summary: summarize_body(&record.body_md), linked_tickets, @@ -320,12 +559,116 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { }) } + fn query_objectives(&self, query: ObjectiveQueryRequest) -> Result { + validate_time_bounds( + query.updated_after.as_deref(), + query.updated_before.as_deref(), + )?; + let limit = query.limit.unwrap_or(50).clamp(1, 100); + let sort = normalize_objective_sort(query.sort.as_deref(), query.query.is_some())?; + let cursor = query + .cursor + .as_deref() + .map(parse_query_cursor) + .transpose()?; + let mut objectives = self.list_objectives(1_001)?.items; + let source_truncated = objectives.len() > 1_000; + objectives.truncate(1_000); + let mut items = Vec::new(); + for objective in objectives { + let body_md = self.objective_record(&objective.id)?.body_md; + if !objective_matches_query(&objective, &body_md, &query) { + continue; + } + let linked_tickets = self + .store + .list_objective_ticket_links(&self.workspace_id, &objective.id)? + .into_iter() + .map(|link| link.ticket_id) + .collect::>(); + if query + .linked_ticket_id + .as_ref() + .is_some_and(|id| !linked_tickets.iter().any(|ticket_id| ticket_id == id)) + { + continue; + } + items.push(objective_query_item( + objective, + linked_tickets, + query.query.as_deref(), + &body_md, + )); + } + sort_objective_query_items(&mut items, sort); + if let Some(cursor) = cursor { + items.retain(|item| objective_item_after_cursor(item, sort, &cursor)); + } + let has_more = items.len() > limit; + items.truncate(limit); + let next_cursor = has_more + .then(|| items.last().map(|item| make_objective_cursor(item, sort))) + .flatten(); + Ok(ObjectiveQueryResponse { + page: QueryPage { + limit, + returned: items.len(), + has_more, + next_cursor, + sort: sort.to_string(), + source_limit: Some(1_000), + source_truncated, + }, + items, + record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), + }) + } + fn objective(&self, id: &str) -> Result { validate_project_id(id)?; let record = self.objective_record(id)?; self.objective_detail_from_record(record) } + fn show_objective(&self, id: &str, query: ObjectiveShowRequest) -> Result { + let mut detail = self.objective(id)?; + let all_events = self.store.list_objective_events(&self.workspace_id, id)?; + let event_limit = query + .event_limit + .unwrap_or(TICKET_EVENT_LIMIT) + .clamp(1, TICKET_EVENT_LIMIT); + let event_end = query + .event_cursor + .as_deref() + .map(|cursor| parse_offset_cursor(cursor, "event_cursor")) + .transpose()? + .unwrap_or(all_events.len()) + .min(all_events.len()); + let event_start = event_end.saturating_sub(event_limit); + detail.events = all_events[event_start..event_end] + .iter() + .map(|event| ObjectiveEventDetail { + event_ref: event.event_id.clone(), + kind: event.kind.clone(), + body: event + .body_md + .as_deref() + .map(|body| truncate_body(body, TICKET_EVENT_BODY_LIMIT).0), + created_at: event.created_at.clone(), + }) + .collect(); + detail.event_page = QueryPage { + limit: event_limit, + returned: event_end - event_start, + has_more: event_start > 0, + next_cursor: (event_start > 0).then(|| event_start.to_string()), + sort: "sequence_desc".to_string(), + source_limit: None, + source_truncated: false, + }; + Ok(detail) + } + fn create_objective(&self, input: ObjectiveCreateInput) -> Result { validate_objective_title(&input.title)?; validate_objective_state(&input.state)?; @@ -597,6 +940,765 @@ impl MemoryAuthority for SqliteWorkspaceAuthority { } } +#[derive(Clone, Copy)] +enum TicketQuerySort { + Relevance, + UpdatedDesc, + CreatedDesc, + Priority, + Title, +} + +impl std::fmt::Display for TicketQuerySort { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Relevance => "relevance", + Self::UpdatedDesc => "updated_desc", + Self::CreatedDesc => "created_desc", + Self::Priority => "priority", + Self::Title => "title", + }) + } +} + +#[derive(Clone, Copy)] +enum ObjectiveQuerySort { + Relevance, + UpdatedDesc, + CreatedDesc, + Title, +} + +impl std::fmt::Display for ObjectiveQuerySort { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Relevance => "relevance", + Self::UpdatedDesc => "updated_desc", + Self::CreatedDesc => "created_desc", + Self::Title => "title", + }) + } +} + +fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String { + event + .attributes + .get("event_id") + .cloned() + .unwrap_or_else(|| format!("sequence:{sequence}")) +} + +fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail { + TicketEventDetail { + sequence, + event_ref: ticket_event_ref(sequence, event), + kind: event.kind.as_str().to_owned(), + author: event.author.clone(), + at: event.at.clone(), + status: event.status.clone(), + from: event.from.clone(), + to: event.to.clone(), + reason: event.reason.clone(), + state_field: event.state_field.clone(), + heading: event.heading.clone(), + body: (!event.body.as_str().is_empty()) + .then(|| truncate_body(event.body.as_str(), TICKET_EVENT_BODY_LIMIT).0), + attributes: event.attributes.clone(), + references: event + .references + .iter() + .map(|reference| format!("{}:{}", reference.kind.as_str(), reference.target)) + .collect(), + } +} + +fn ticket_evidence_event(sequence: usize, event: &TicketEvent) -> TicketEvidenceEvent { + TicketEvidenceEvent { + event_ref: ticket_event_ref(sequence, event), + sequence, + kind: event.kind.as_str().to_owned(), + at: event.at.clone(), + author: event.author.clone(), + excerpt: truncate_body(event.body.as_str(), 512).0, + } +} + +fn merge_request_summary(request: MergeRequest) -> TicketMergeRequestSummary { + let review_subject_ref = request.thread.iter().rev().find_map(|event| match event { + MergeRequestThreadEvent::ReviewRequested(review) => Some(review.subject_ref.as_str()), + _ => None, + }); + let current_review = + review_subject_ref.and_then(|subject_ref| request.effective_review(subject_ref)); + let review_status = match current_review.map(|review| &review.decision) { + Some(ReviewDecision::Approve) => "approved", + Some(ReviewDecision::RequestChanges) => "changes_requested", + None => "pending", + } + .to_string(); + let state = match request.state { + MergeRequestState::Open => "open", + MergeRequestState::Merged => "merged", + MergeRequestState::Closed => "closed", + } + .to_string(); + + TicketMergeRequestSummary { + merge_request_id: request.merge_request_id.clone(), + state, + review_status, + selector_from: request.selector_from.clone(), + selector_to: request.selector_to.clone(), + updated_at: request.updated_at.to_rfc3339(), + review_subject_ref: review_subject_ref.map(str::to_string), + review_submitted_at: current_review.map(|review| review.created_at.to_rfc3339()), + review_excerpt: current_review.map(|review| truncate_body(&review.body, 240).0), + } +} + +fn ticket_evidence_summary( + events: &[TicketEvent], + merge_request: Option<&TicketMergeRequestSummary>, +) -> TicketEvidenceSummary { + let latest_report = events + .iter() + .enumerate() + .rev() + .find(|(_, event)| event.kind.as_str() == "implementation_report") + .map(|(sequence, _)| sequence); + let latest_rescope = events + .iter() + .enumerate() + .rev() + .find(|(_, event)| event.kind.as_str() == "item_edit") + .map(|(sequence, _)| sequence); + let report_after_rescope = + latest_report.is_some_and(|report| latest_rescope.is_none_or(|rescope| report > rescope)); + let has_commit = merge_request.is_some_and(|request| { + request + .review_subject_ref + .as_ref() + .is_some_and(|subject_ref| !subject_ref.is_empty()) + }) || events.iter().any(|event| { + event.attributes.contains_key("commit") || event.attributes.contains_key("head_commit") + }); + let review_status = merge_request.map(|request| request.review_status.clone()); + let approved = review_status.as_deref() == Some("approved"); + let unresolved_request_changes = review_status.as_deref() == Some("changes_requested"); + let mut missing = Vec::new(); + if latest_report.is_none() { + missing.push("implementation_report".to_string()); + } else if !report_after_rescope { + missing.push("implementation_report_after_rescope".to_string()); + } + if !has_commit { + missing.push("commit".to_string()); + } + if merge_request.is_none() { + missing.push("merge_request".to_string()); + } + if !approved { + missing.push("approved_review".to_string()); + } + TicketEvidenceSummary { + has_implementation_report: latest_report.is_some(), + implementation_report_after_rescope: report_after_rescope, + has_merge_request: merge_request.is_some(), + has_commit, + review_status, + approved, + unresolved_request_changes, + complete_for_integration: missing.is_empty(), + missing, + } +} + +fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { + for state in &query.states { + if ticket::TicketWorkflowState::parse(state).is_none() { + return Err(Error::InvalidRecordId(format!( + "unsupported Ticket workflow state `{state}`" + ))); + } + } + for evidence in &query.evidence { + if !matches!( + evidence.as_str(), + "implementation_report" + | "implementation_report_after_rescope" + | "merge_request" + | "commit" + | "approved_review" + ) { + return Err(Error::InvalidRecordId(format!( + "unsupported Ticket evidence filter `{evidence}`" + ))); + } + } + for attention in &query.attention { + if !matches!( + attention.as_str(), + "done_not_closed" + | "implementation_report_not_closed" + | "report_after_rescope" + | "unresolved_review" + | "missing_commit" + | "blocked" + | "unblocked" + | "ready" + | "awaiting_review" + | "unresolved_changes" + | "stale_after_rescope" + | "missing_evidence" + ) { + return Err(Error::InvalidRecordId(format!( + "unsupported Ticket attention filter `{attention}`" + ))); + } + } + if let Some(status) = query.review_status.as_deref() + && !matches!( + status, + "none" + | "pending" + | "approved" + | "request_changes" + | "unresolved_changes" + | "changes_requested" + ) + { + return Err(Error::InvalidRecordId(format!( + "unsupported review status `{status}`" + ))); + } + if let Some(kind) = query.relation_kind.as_deref() + && !matches!( + kind, + "depends_on" | "blocks" | "related" | "supersedes" | "duplicate_of" + ) + { + return Err(Error::InvalidRecordId(format!( + "unsupported Ticket relation kind `{kind}`" + ))); + } + validate_time_bounds( + query.updated_after.as_deref(), + query.updated_before.as_deref(), + ) +} + +fn validate_time_bounds(after: Option<&str>, before: Option<&str>) -> Result<()> { + let parse = |field: &str, value: &str| { + chrono::DateTime::parse_from_rfc3339(value) + .map_err(|_| Error::InvalidRecordId(format!("{field} must be an RFC3339 timestamp"))) + }; + if let Some(after) = after { + parse("updated_after", after)?; + } + if let Some(before) = before { + parse("updated_before", before)?; + } + Ok(()) +} + +fn normalize_ticket_sort(sort: Option<&str>, has_query: bool) -> Result { + match sort.unwrap_or(if has_query { + "relevance" + } else { + "updated_desc" + }) { + "relevance" => Ok(TicketQuerySort::Relevance), + "updated_desc" => Ok(TicketQuerySort::UpdatedDesc), + "created_desc" => Ok(TicketQuerySort::CreatedDesc), + "priority" => Ok(TicketQuerySort::Priority), + "title" => Ok(TicketQuerySort::Title), + other => Err(Error::InvalidRecordId(format!( + "unsupported Ticket query sort `{other}`" + ))), + } +} + +fn normalize_objective_sort(sort: Option<&str>, has_query: bool) -> Result { + match sort.unwrap_or(if has_query { + "relevance" + } else { + "updated_desc" + }) { + "relevance" => Ok(ObjectiveQuerySort::Relevance), + "updated_desc" => Ok(ObjectiveQuerySort::UpdatedDesc), + "created_desc" => Ok(ObjectiveQuerySort::CreatedDesc), + "title" => Ok(ObjectiveQuerySort::Title), + other => Err(Error::InvalidRecordId(format!( + "unsupported Objective query sort `{other}`" + ))), + } +} + +fn parse_offset_cursor(cursor: &str, field: &str) -> Result { + cursor + .parse::() + .map_err(|_| Error::InvalidRecordId(format!("invalid {field}"))) +} + +fn make_query_cursor(key: &str, id: &str) -> String { + format!("v1:{}:{key}{id}", key.len()) +} + +fn parse_query_cursor(cursor: &str) -> Result<(String, String)> { + let rest = cursor + .strip_prefix("v1:") + .ok_or_else(|| Error::InvalidRecordId("invalid query cursor".to_string()))?; + let (length, value) = rest + .split_once(':') + .ok_or_else(|| Error::InvalidRecordId("invalid query cursor".to_string()))?; + let length = length + .parse::() + .map_err(|_| Error::InvalidRecordId("invalid query cursor".to_string()))?; + if length > value.len() || !value.is_char_boundary(length) { + return Err(Error::InvalidRecordId("invalid query cursor".to_string())); + } + Ok((value[..length].to_string(), value[length..].to_string())) +} + +fn ticket_matches_query( + summary: &TicketSummary, + detail: &TicketDetail, + authoritative_body: &str, + authoritative_events: &[TicketEvent], + query: &TicketQueryRequest, +) -> bool { + if !query.states.is_empty() && !query.states.iter().any(|state| state == &summary.state) { + return false; + } + if query + .updated_after + .as_ref() + .is_some_and(|after| summary.updated_at.as_deref().unwrap_or("") <= after.as_str()) + || query + .updated_before + .as_ref() + .is_some_and(|before| summary.updated_at.as_deref().unwrap_or("") >= before.as_str()) + { + return false; + } + if !query.event_kinds.is_empty() + && !authoritative_events.iter().any(|event| { + query + .event_kinds + .iter() + .any(|kind| kind == event.kind.as_str()) + }) + { + return false; + } + if let Some(review_status) = &query.review_status { + let matches = match review_status.as_str() { + "none" => detail.evidence.review_status.is_none(), + "request_changes" | "unresolved_changes" => { + detail.evidence.review_status.as_deref() == Some("changes_requested") + } + status => detail.evidence.review_status.as_deref() == Some(status), + }; + if !matches { + return false; + } + } + if !query + .evidence + .iter() + .all(|evidence| match evidence.as_str() { + "implementation_report" => detail.evidence.has_implementation_report, + "implementation_report_after_rescope" => { + detail.evidence.implementation_report_after_rescope + } + "merge_request" => detail.evidence.has_merge_request, + "commit" => detail.evidence.has_commit, + "approved_review" => detail.evidence.approved, + _ => false, + }) + { + return false; + } + if !query + .attention + .iter() + .all(|attention| match attention.as_str() { + "done_not_closed" => summary.state == "done", + "implementation_report_not_closed" => { + detail.evidence.has_implementation_report && summary.state != "closed" + } + "report_after_rescope" => detail.evidence.implementation_report_after_rescope, + "unresolved_review" => detail.evidence.unresolved_request_changes, + "missing_commit" => !detail.evidence.has_commit, + "blocked" => !detail.relations.blockers.is_empty(), + "unblocked" => detail.relations.blockers.is_empty(), + "ready" => summary.state == "ready" && detail.relations.blockers.is_empty(), + "awaiting_review" => detail.evidence.review_status.as_deref() == Some("pending"), + "unresolved_changes" => detail.evidence.unresolved_request_changes, + "stale_after_rescope" => { + detail.evidence.has_implementation_report + && !detail.evidence.implementation_report_after_rescope + } + "missing_evidence" => !detail.evidence.complete_for_integration, + _ => false, + }) + { + return false; + } + if query.linked_objective_id.as_ref().is_some_and(|id| { + !detail + .linked_objectives + .iter() + .any(|objective| &objective.id == id) + }) { + return false; + } + if (query.related_ticket_id.is_some() || query.relation_kind.is_some()) + && !detail.relations.outgoing.iter().any(|relation| { + query + .related_ticket_id + .as_ref() + .is_none_or(|ticket_id| &relation.target == ticket_id) + && query + .relation_kind + .as_ref() + .is_none_or(|kind| &relation.kind == kind) + }) + && !detail.relations.incoming.iter().any(|relation| { + query + .related_ticket_id + .as_ref() + .is_none_or(|ticket_id| &relation.source_ticket == ticket_id) + && query + .relation_kind + .as_ref() + .is_none_or(|kind| &relation.forward_kind == kind) + }) + && !detail.relations.blockers.iter().any(|relation| { + query + .related_ticket_id + .as_ref() + .is_none_or(|ticket_id| &relation.blocking_ticket == ticket_id) + && query + .relation_kind + .as_ref() + .is_none_or(|kind| &relation.relation_kind == kind) + }) + && !detail.relations.notices.iter().any(|relation| { + query + .related_ticket_id + .as_ref() + .is_none_or(|ticket_id| &relation.related_ticket == ticket_id) + && query + .relation_kind + .as_ref() + .is_none_or(|kind| &relation.kind == kind) + }) + { + return false; + } + query.query.as_ref().is_none_or(|text| { + let needle = text.to_lowercase(); + summary.title.to_lowercase().contains(&needle) + || authoritative_body.to_lowercase().contains(&needle) + || authoritative_events + .iter() + .any(|event| event.body.as_str().to_lowercase().contains(&needle)) + }) +} + +fn ticket_query_item( + summary: TicketSummary, + detail: &TicketDetail, + authoritative_body: &str, + authoritative_events: &[TicketEvent], + query: &TicketQueryRequest, +) -> TicketQueryItem { + let mut matched_fields = Vec::new(); + let mut snippet = None; + let mut matching_event = None; + if let Some(text) = query.query.as_ref() { + let needle = text.to_lowercase(); + if summary.title.to_lowercase().contains(&needle) { + matched_fields.push("title".to_string()); + snippet = Some(summary.title.clone()); + } + if authoritative_body.to_lowercase().contains(&needle) { + matched_fields.push("body".to_string()); + snippet.get_or_insert_with(|| matching_snippet(authoritative_body, text)); + } + if let Some((sequence, event)) = authoritative_events + .iter() + .enumerate() + .find(|(_, event)| event.body.as_str().to_lowercase().contains(&needle)) + { + matched_fields.push("event".to_string()); + let event_snippet = matching_snippet(event.body.as_str(), text); + let mut evidence = ticket_evidence_event(sequence, event); + evidence.excerpt = event_snippet.clone(); + matching_event = Some(evidence); + snippet = Some(event_snippet); + } + } + TicketQueryItem { + id: summary.id, + title: summary.title, + state: summary.state, + readiness: detail.readiness.clone(), + priority: summary.priority, + created_at: detail.created_at.clone(), + updated_at: summary.updated_at, + item_revision: detail.item_revision.clone(), + workspace_action_priority: summary.workspace_action_priority, + matched_fields, + snippet: snippet.map(|value| truncate_body(&value, 512).0), + matching_event, + linked_objective_ids: detail + .linked_objectives + .iter() + .map(|objective| objective.id.clone()) + .collect(), + relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(), + blocker_count: detail.relations.blockers.len(), + unresolved_blocker_count: detail.relations.blockers.len(), + unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes), + evidence: detail.evidence.clone(), + merge_request: detail.merge_request.clone(), + } +} + +fn matching_snippet(body: &str, text: &str) -> String { + let lower = body.to_lowercase(); + let start = lower + .find(&text.to_lowercase()) + .unwrap_or(0) + .saturating_sub(96); + let start = body.floor_char_boundary(start); + let end = body.ceil_char_boundary((start + 320).min(body.len())); + body[start..end].to_string() +} + +fn ticket_match_rank(item: &TicketQueryItem) -> usize { + if item.matched_fields.iter().any(|field| field == "title") { + 0 + } else if item.matched_fields.iter().any(|field| field == "body") { + 1 + } else if item.matched_fields.iter().any(|field| field == "event") { + 2 + } else { + 3 + } +} + +fn ticket_sort_key(item: &TicketQueryItem, sort: TicketQuerySort) -> String { + match sort { + TicketQuerySort::Relevance => format!( + "{}|{}", + ticket_match_rank(item), + item.updated_at.as_deref().unwrap_or("") + ), + TicketQuerySort::UpdatedDesc => item.updated_at.clone().unwrap_or_default(), + TicketQuerySort::CreatedDesc => item.created_at.clone().unwrap_or_default(), + TicketQuerySort::Priority => format!( + "{}|{}", + match item.workspace_action_priority.as_str() { + "ready_for_queue" => 0, + "active_work" => 1, + _ => 2, + }, + item.updated_at.as_deref().unwrap_or("") + ), + TicketQuerySort::Title => item.title.to_lowercase(), + } +} + +fn sort_ticket_query_items(items: &mut [TicketQueryItem], sort: TicketQuerySort) { + items.sort_by(|left, right| match sort { + TicketQuerySort::Relevance => ticket_match_rank(left) + .cmp(&ticket_match_rank(right)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)), + TicketQuerySort::UpdatedDesc => right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.id.cmp(&right.id)), + TicketQuerySort::CreatedDesc => right + .created_at + .cmp(&left.created_at) + .then_with(|| left.id.cmp(&right.id)), + TicketQuerySort::Priority => { + let rank = |item: &TicketQueryItem| match item.workspace_action_priority.as_str() { + "ready_for_queue" => 0, + "active_work" => 1, + _ => 2, + }; + rank(left) + .cmp(&rank(right)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)) + } + TicketQuerySort::Title => left + .title + .to_lowercase() + .cmp(&right.title.to_lowercase()) + .then_with(|| left.id.cmp(&right.id)), + }); +} + +fn make_ticket_cursor(item: &TicketQueryItem, sort: TicketQuerySort) -> String { + make_query_cursor(&ticket_sort_key(item, sort), &item.id) +} + +fn ticket_item_after_cursor( + item: &TicketQueryItem, + sort: TicketQuerySort, + cursor: &(String, String), +) -> bool { + let key = ticket_sort_key(item, sort); + match sort { + TicketQuerySort::UpdatedDesc | TicketQuerySort::CreatedDesc => { + key < cursor.0 || (key == cursor.0 && item.id > cursor.1) + } + TicketQuerySort::Title => key > cursor.0 || (key == cursor.0 && item.id > cursor.1), + TicketQuerySort::Relevance | TicketQuerySort::Priority => { + let (rank, updated) = key.split_once('|').unwrap_or(("9", "")); + let (cursor_rank, cursor_updated) = cursor.0.split_once('|').unwrap_or(("9", "")); + rank > cursor_rank + || (rank == cursor_rank + && (updated < cursor_updated + || (updated == cursor_updated && item.id > cursor.1))) + } + } +} + +fn objective_matches_query( + objective: &ObjectiveSummary, + body_md: &str, + query: &ObjectiveQueryRequest, +) -> bool { + if !query.states.is_empty() && !query.states.iter().any(|state| state == &objective.state) { + return false; + } + if query + .updated_after + .as_ref() + .is_some_and(|after| objective.updated_at.as_deref().unwrap_or("") <= after.as_str()) + || query + .updated_before + .as_ref() + .is_some_and(|before| objective.updated_at.as_deref().unwrap_or("") >= before.as_str()) + { + return false; + } + query.query.as_ref().is_none_or(|text| { + let needle = text.to_lowercase(); + objective.title.to_lowercase().contains(&needle) || body_md.to_lowercase().contains(&needle) + }) +} + +fn objective_query_item( + objective: ObjectiveSummary, + linked_tickets: Vec, + text: Option<&str>, + body_md: &str, +) -> ObjectiveQueryItem { + let mut matched_fields = Vec::new(); + let mut snippet = None; + if let Some(text) = text { + let needle = text.to_lowercase(); + if objective.title.to_lowercase().contains(&needle) { + matched_fields.push("title".to_string()); + snippet = Some(objective.title.clone()); + } + if body_md.to_lowercase().contains(&needle) { + matched_fields.push("body".to_string()); + snippet.get_or_insert_with(|| matching_snippet(body_md, text)); + } + } + ObjectiveQueryItem { + id: objective.id, + title: objective.title, + state: objective.state, + created_at: objective.created_at, + updated_at: objective.updated_at, + matched_fields, + snippet, + linked_ticket_count: linked_tickets.len(), + linked_tickets, + } +} + +fn objective_match_rank(item: &ObjectiveQueryItem) -> usize { + if item.matched_fields.iter().any(|field| field == "title") { + 0 + } else if item.matched_fields.iter().any(|field| field == "body") { + 1 + } else { + 2 + } +} + +fn objective_sort_key(item: &ObjectiveQueryItem, sort: ObjectiveQuerySort) -> String { + match sort { + ObjectiveQuerySort::Relevance => format!( + "{}|{}", + objective_match_rank(item), + item.updated_at.as_deref().unwrap_or("") + ), + ObjectiveQuerySort::UpdatedDesc => item.updated_at.clone().unwrap_or_default(), + ObjectiveQuerySort::CreatedDesc => item.created_at.clone().unwrap_or_default(), + ObjectiveQuerySort::Title => item.title.to_lowercase(), + } +} + +fn sort_objective_query_items(items: &mut [ObjectiveQueryItem], sort: ObjectiveQuerySort) { + items.sort_by(|left, right| match sort { + ObjectiveQuerySort::Relevance => objective_match_rank(left) + .cmp(&objective_match_rank(right)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)), + ObjectiveQuerySort::UpdatedDesc => right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.id.cmp(&right.id)), + ObjectiveQuerySort::CreatedDesc => right + .created_at + .cmp(&left.created_at) + .then_with(|| left.id.cmp(&right.id)), + ObjectiveQuerySort::Title => left + .title + .to_lowercase() + .cmp(&right.title.to_lowercase()) + .then_with(|| left.id.cmp(&right.id)), + }); +} + +fn make_objective_cursor(item: &ObjectiveQueryItem, sort: ObjectiveQuerySort) -> String { + make_query_cursor(&objective_sort_key(item, sort), &item.id) +} + +fn objective_item_after_cursor( + item: &ObjectiveQueryItem, + sort: ObjectiveQuerySort, + cursor: &(String, String), +) -> bool { + let key = objective_sort_key(item, sort); + match sort { + ObjectiveQuerySort::UpdatedDesc | ObjectiveQuerySort::CreatedDesc => { + key < cursor.0 || (key == cursor.0 && item.id > cursor.1) + } + ObjectiveQuerySort::Relevance => { + let (rank, updated) = key.split_once('|').unwrap_or(("9", "")); + let (cursor_rank, cursor_updated) = cursor.0.split_once('|').unwrap_or(("9", "")); + rank > cursor_rank + || (rank == cursor_rank + && (updated < cursor_updated + || (updated == cursor_updated && item.id > cursor.1))) + } + ObjectiveQuerySort::Title => key > cursor.0 || (key == cursor.0 && item.id > cursor.1), + } +} + fn now_rfc3339() -> String { Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } @@ -716,10 +1818,137 @@ mod tests { use super::*; use crate::store::{ObjectiveRecord, ObjectiveTicketLinkRecord, WorkspaceRecord}; + #[test] + fn merge_request_summary_tracks_the_latest_requested_subject() { + let actor = merge_request::WorkerIdentity { + runtime_id: "runtime-1".to_string(), + worker_id: "worker-1".to_string(), + }; + let at = Utc::now(); + let first_review = merge_request::ReviewEvent { + event_id: "review-1".to_string(), + sequence: 2, + request_event_id: "request-1".to_string(), + subject_ref: "commit-1".to_string(), + decision: ReviewDecision::Approve, + body: "approved".to_string(), + findings: Vec::new(), + reviewer: actor.clone(), + created_at: at, + }; + let mut request = MergeRequest { + workspace_id: "workspace-1".to_string(), + merge_request_id: "mr-1".to_string(), + repository_id: "main".to_string(), + state: MergeRequestState::Open, + selector_from: Some("work/ticket-1".to_string()), + selector_to: "orchestration".to_string(), + ticket_ids: vec!["ticket-1".to_string()], + thread: vec![ + MergeRequestThreadEvent::ReviewRequested(merge_request::ReviewRequestedEvent { + event_id: "request-1".to_string(), + sequence: 1, + subject_ref: "commit-1".to_string(), + requested_by: actor.clone(), + reviewer: actor.clone(), + created_at: at, + }), + MergeRequestThreadEvent::Review(first_review), + MergeRequestThreadEvent::ReviewRequested(merge_request::ReviewRequestedEvent { + event_id: "request-2".to_string(), + sequence: 3, + subject_ref: "commit-2".to_string(), + requested_by: actor.clone(), + reviewer: actor.clone(), + created_at: at, + }), + ], + created_at: at, + updated_at: at, + }; + + let pending = merge_request_summary(request.clone()); + assert_eq!(pending.review_status, "pending"); + assert_eq!(pending.review_subject_ref.as_deref(), Some("commit-2")); + assert_eq!(pending.review_submitted_at, None); + + request.thread.push(MergeRequestThreadEvent::Review( + merge_request::ReviewEvent { + event_id: "review-2".to_string(), + sequence: 4, + request_event_id: "request-2".to_string(), + subject_ref: "commit-2".to_string(), + decision: ReviewDecision::Approve, + body: "current approval".to_string(), + findings: Vec::new(), + reviewer: actor, + created_at: at, + }, + )); + let approved = merge_request_summary(request); + assert_eq!(approved.review_status, "approved"); + assert_eq!(approved.review_subject_ref.as_deref(), Some("commit-2")); + assert_eq!(approved.review_excerpt.as_deref(), Some("current approval")); + } + + #[test] + fn ticket_evidence_summary_requires_report_after_latest_rescope_and_approved_revision() { + let event = |kind: &str| TicketEvent { + kind: ticket::TicketEventKind::Other(kind.to_string()), + author: Some("coder".to_string()), + at: Some("2026-01-01T00:00:00Z".to_string()), + status: None, + from: None, + to: None, + reason: None, + state_field: None, + heading: None, + body: ticket::MarkdownText::new(kind), + attributes: Default::default(), + references: Vec::new(), + }; + let request = TicketMergeRequestSummary { + merge_request_id: "mr-1".to_string(), + state: "open".to_string(), + review_status: "approved".to_string(), + selector_from: Some("work/ticket".to_string()), + selector_to: "orchestration".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + review_subject_ref: Some("head".to_string()), + review_submitted_at: Some("2026-01-01T00:00:00Z".to_string()), + review_excerpt: Some("approved".to_string()), + }; + let stale = ticket_evidence_summary( + &[event("implementation_report"), event("item_edit")], + Some(&request), + ); + assert!(!stale.implementation_report_after_rescope); + assert!(!stale.complete_for_integration); + assert!( + stale + .missing + .contains(&"implementation_report_after_rescope".to_string()) + ); + let current = ticket_evidence_summary( + &[ + event("implementation_report"), + event("item_edit"), + event("implementation_report"), + ], + Some(&request), + ); + assert!(current.implementation_report_after_rescope); + assert!(current.has_commit); + assert!(current.approved); + assert!(current.complete_for_integration); + } + #[tokio::test] async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() { let dir = tempfile::tempdir().unwrap(); write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); + write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning"); + write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning"); let db_path = dir.path().join("workspace.db"); SqliteTicketBackend::open(&db_path, "workspace-test") .unwrap() @@ -745,7 +1974,10 @@ mod tests { objective_id: "00000000001J3".to_string(), title: "Control plane".to_string(), state: "active".to_string(), - body_md: "Objective body.\n".to_string(), + body_md: format!( + "Objective body. {}\n\nDeep objective marker.\n", + "x".repeat(300) + ), created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-02T00:00:00Z".to_string(), }) @@ -771,18 +2003,167 @@ mod tests { ); let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap(); + authority + .ticket_backend + .add_event( + TicketIdOrSlug::Id("00000000001J2".to_string()), + ticket::NewTicketEvent::new( + ticket::TicketEventKind::Other("historical_signal".to_string()), + format!("{} Historical event marker.", "z".repeat(2_000)), + ), + ) + .unwrap(); + for index in 0..110 { + authority + .ticket_backend + .add_event( + TicketIdOrSlug::Id("00000000001J2".to_string()), + ticket::NewTicketEvent::new( + ticket::TicketEventKind::Comment, + format!("Filler event {index}."), + ), + ) + .unwrap(); + } + authority + .ticket_backend + .add_ticket_relation( + TicketIdOrSlug::Id("00000000001J2".to_string()), + ticket::NewTicketRelation { + kind: ticket::TicketRelationKind::Related, + target: "00000000001J5".to_string(), + note: Some( + "mentions unrelated id 00000000001J9 and kind duplicate_of".to_string(), + ), + author: Some("tester".to_string()), + }, + ) + .unwrap(); + authority + .ticket_backend + .add_ticket_relation( + TicketIdOrSlug::Id("00000000001J2".to_string()), + ticket::NewTicketRelation { + kind: ticket::TicketRelationKind::DependsOn, + target: "00000000001J6".to_string(), + note: Some("separate dependency relation".to_string()), + author: Some("tester".to_string()), + }, + ) + .unwrap(); let tickets = authority.list_tickets(20).unwrap(); assert_eq!(tickets.record_authority, "workspace-sqlite"); assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket"); assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].state, "ready"); - assert_eq!( - tickets.items[0].workspace_action_priority, - "ready_for_queue" - ); + assert_eq!(tickets.items[0].workspace_action_priority, "background"); let ticket = authority.ticket("00000000001J2").unwrap(); assert!(ticket.body.contains("Ticket body")); + assert!(ticket.body_truncated); + assert!(!ticket.body.contains("Deep Ticket marker")); + assert!(!ticket.item_revision.is_empty()); + assert_eq!(ticket.linked_objectives[0].id, "00000000001J3"); + assert_eq!(ticket.event_page.returned, ticket.events.len()); + let ticket_query = authority + .query_tickets(TicketQueryRequest { + query: Some("Deep Ticket marker".to_string()), + states: vec!["ready".to_string()], + linked_objective_id: Some("00000000001J3".to_string()), + attention: vec!["missing_commit".to_string()], + limit: Some(1), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert_eq!(ticket_query.items.len(), 1); + assert_eq!(ticket_query.items[0].id, "00000000001J2"); + assert!( + ticket_query.items[0] + .matched_fields + .contains(&"body".to_string()) + ); + assert_eq!(ticket_query.page.limit, 1); + let historical_event_query = authority + .query_tickets(TicketQueryRequest { + query: Some("Historical event marker".to_string()), + event_kinds: vec!["historical_signal".to_string()], + limit: Some(1), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert_eq!(historical_event_query.items.len(), 1); + let matching_event = historical_event_query.items[0] + .matching_event + .as_ref() + .expect("matching historical event"); + assert_eq!(matching_event.kind, "historical_signal"); + assert!(matching_event.excerpt.contains("Historical event marker")); + assert!( + historical_event_query.items[0] + .snippet + .as_deref() + .is_some_and(|snippet| snippet.contains("Historical event marker")) + ); + let note_only_id = authority + .query_tickets(TicketQueryRequest { + related_ticket_id: Some("00000000001J9".to_string()), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert!(note_only_id.items.is_empty()); + let note_only_kind = authority + .query_tickets(TicketQueryRequest { + relation_kind: Some("duplicate_of".to_string()), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert!(note_only_kind.items.is_empty()); + let crossed_relation_filters = authority + .query_tickets(TicketQueryRequest { + related_ticket_id: Some("00000000001J5".to_string()), + relation_kind: Some("depends_on".to_string()), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert!(crossed_relation_filters.items.is_empty()); + let exact_relation = authority + .query_tickets(TicketQueryRequest { + related_ticket_id: Some("00000000001J5".to_string()), + relation_kind: Some("related".to_string()), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert_eq!(exact_relation.items.len(), 1); + assert_eq!(exact_relation.items[0].id, "00000000001J2"); + let first_page = authority + .query_tickets(TicketQueryRequest { + sort: Some("title".to_string()), + limit: Some(1), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert!(first_page.page.has_more); + let second_page = authority + .query_tickets(TicketQueryRequest { + sort: Some("title".to_string()), + limit: Some(1), + cursor: first_page.page.next_cursor.clone(), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert_eq!(second_page.items.len(), 1); + assert_ne!(first_page.items[0].id, second_page.items[0].id); + assert!(second_page.page.has_more); + let third_page = authority + .query_tickets(TicketQueryRequest { + sort: Some("title".to_string()), + limit: Some(1), + cursor: second_page.page.next_cursor.clone(), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert_eq!(third_page.items.len(), 1); + assert!(!third_page.page.has_more); let objectives = authority.list_objectives(20).unwrap(); assert_eq!(objectives.record_authority, "workspace-sqlite"); @@ -792,6 +2173,35 @@ mod tests { let objective = authority.objective("00000000001J3").unwrap(); assert!(objective.body.contains("Objective body")); + assert!(!objective.revision.is_empty()); + assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2"); + assert_eq!(objective.linked_ticket_summaries[0].state, "ready"); + let objective_query = authority + .query_objectives(ObjectiveQueryRequest { + query: Some("Control plane".to_string()), + linked_ticket_id: Some("00000000001J2".to_string()), + limit: Some(1), + ..ObjectiveQueryRequest::default() + }) + .unwrap(); + assert_eq!(objective_query.items.len(), 1); + assert_eq!(objective_query.items[0].linked_ticket_count, 1); + assert_eq!(objective_query.page.limit, 1); + let body_query = authority + .query_objectives(ObjectiveQueryRequest { + query: Some("Deep objective marker".to_string()), + limit: Some(1), + ..ObjectiveQueryRequest::default() + }) + .unwrap(); + assert_eq!(body_query.items.len(), 1); + assert_eq!(body_query.items[0].matched_fields, vec!["body"]); + assert!( + body_query.items[0] + .snippet + .as_deref() + .is_some_and(|snippet| snippet.contains("Deep objective marker")) + ); let memory = authority.ensure_memory_document().unwrap(); assert_eq!(memory.body_md, DEFAULT_MEMORY_DOCUMENT_BODY); let updated = authority @@ -928,7 +2338,10 @@ updated_at: "2026-01-02T00:00:00Z" --- Ticket body. +{padding} +Deep Ticket marker. "#, + padding = "x".repeat(70_000), ), ) .unwrap(); diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index c95aa816..35348f60 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -49,9 +49,11 @@ pub struct TicketDetail { pub id: String, pub title: String, pub state: String, + pub readiness: Option, pub priority: String, pub created_at: Option, pub updated_at: Option, + pub item_revision: String, pub queued_by: Option, pub queued_at: Option, pub assignee: Option, @@ -62,9 +64,15 @@ pub struct TicketDetail { pub body_truncated: bool, pub event_count: usize, pub events: Vec, + pub event_page: QueryPage, pub artifact_count: usize, pub artifacts: Vec, pub relations: TicketRelationView, + pub linked_objectives: Vec, + pub implementation_reports: Vec, + pub current_assignment: Option, + pub merge_request: Option, + pub evidence: TicketEvidenceSummary, pub resolution: Option, pub record_source: String, } @@ -73,6 +81,7 @@ pub struct TicketDetail { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketEventDetail { pub sequence: usize, + pub event_ref: String, pub kind: String, pub author: Option, pub at: Option, @@ -83,6 +92,8 @@ pub struct TicketEventDetail { pub state_field: Option, pub heading: Option, pub body: Option, + pub attributes: std::collections::BTreeMap, + pub references: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -185,11 +196,196 @@ impl From for TicketRelationView { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct QueryPage { + pub limit: usize, + pub returned: usize, + pub has_more: bool, + pub next_cursor: Option, + pub sort: String, + pub source_limit: Option, + pub source_truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct ObjectiveLinkSummary { + pub id: String, + pub title: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketEvidenceEvent { + pub event_ref: String, + pub sequence: usize, + pub kind: String, + pub at: Option, + pub author: Option, + pub excerpt: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketAssignmentSummary { + pub assignment_id: String, + pub runtime_id: String, + pub worker_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketMergeRequestSummary { + pub merge_request_id: String, + pub state: String, + pub review_status: String, + pub selector_from: Option, + pub selector_to: String, + pub updated_at: String, + pub review_subject_ref: Option, + pub review_submitted_at: Option, + pub review_excerpt: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketEvidenceSummary { + pub has_implementation_report: bool, + pub implementation_report_after_rescope: bool, + pub has_merge_request: bool, + pub has_commit: bool, + pub review_status: Option, + pub approved: bool, + pub unresolved_request_changes: bool, + pub complete_for_integration: bool, + pub missing: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketQueryRequest { + pub query: Option, + #[serde(default)] + pub states: Vec, + #[serde(default)] + pub event_kinds: Vec, + #[serde(default)] + pub evidence: Vec, + pub review_status: Option, + #[serde(default)] + pub attention: Vec, + pub related_ticket_id: Option, + pub relation_kind: Option, + pub linked_objective_id: Option, + pub updated_after: Option, + pub updated_before: Option, + pub sort: Option, + pub limit: Option, + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketQueryItem { + pub id: String, + pub title: String, + pub state: String, + pub readiness: Option, + pub priority: String, + pub created_at: Option, + pub updated_at: Option, + pub item_revision: String, + pub workspace_action_priority: String, + pub matched_fields: Vec, + pub snippet: Option, + pub matching_event: Option, + pub linked_objective_ids: Vec, + pub relation_count: usize, + pub blocker_count: usize, + pub unresolved_blocker_count: usize, + pub unresolved_review_count: usize, + pub evidence: TicketEvidenceSummary, + pub merge_request: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketQueryResponse { + pub items: Vec, + pub page: QueryPage, + pub record_authority: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketShowRequest { + pub event_limit: Option, + pub event_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ObjectiveQueryRequest { + pub query: Option, + #[serde(default)] + pub states: Vec, + pub linked_ticket_id: Option, + pub updated_after: Option, + pub updated_before: Option, + pub sort: Option, + pub limit: Option, + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveQueryItem { + pub id: String, + pub title: String, + pub state: String, + pub created_at: Option, + pub updated_at: Option, + pub matched_fields: Vec, + pub snippet: Option, + pub linked_ticket_count: usize, + pub linked_tickets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveQueryResponse { + pub items: Vec, + pub page: QueryPage, + pub record_authority: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ObjectiveShowRequest { + pub event_limit: Option, + pub event_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct ObjectiveEventDetail { + pub event_ref: String, + pub kind: String, + pub body: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveLinkedTicketSummary { + pub id: String, + pub title: String, + pub state: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, pub title: String, pub state: String, + pub created_at: Option, pub updated_at: Option, pub summary: String, pub linked_tickets: Vec, @@ -201,12 +397,16 @@ pub struct ObjectiveDetail { pub id: String, pub title: String, pub state: String, + pub revision: String, pub created_at: Option, pub updated_at: Option, pub linked_tickets: Vec, + pub linked_ticket_summaries: Vec, pub resources: Vec, pub body: String, pub body_truncated: bool, + pub events: Vec, + pub event_page: QueryPage, pub record_source: String, } @@ -227,7 +427,17 @@ pub fn ticket_api_typescript() -> String { InvalidProjectRecord::decl(&config), TicketSummary::decl(&config), TicketListResponse::decl(&config), + QueryPage::decl(&config), TicketEventDetail::decl(&config), + ObjectiveLinkSummary::decl(&config), + TicketEvidenceEvent::decl(&config), + TicketAssignmentSummary::decl(&config), + TicketMergeRequestSummary::decl(&config), + TicketEvidenceSummary::decl(&config), + TicketQueryRequest::decl(&config), + TicketQueryItem::decl(&config), + TicketQueryResponse::decl(&config), + TicketShowRequest::decl(&config), TicketRelation::decl(&config), DerivedTicketRelation::decl(&config), TicketRelationBlocker::decl(&config), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d24e8dd5..c2d7654a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -92,10 +92,13 @@ use crate::observation::{ RuntimeObservationSource, RuntimeObservationSourceConfig, }; use crate::profile_settings::UpdateWorkspaceMetadataRequest; -use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail}; +use crate::records::{ + ObjectiveDetail, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveShowRequest, + ProjectRecordList, TicketDetail, TicketQueryRequest, TicketQueryResponse, TicketShowRequest, +}; use crate::repositories::{ - ConfiguredRepository, MergeTargetObservation, RepositoryListProjection, RepositoryLogRead, - RepositoryLookupError, RepositoryRegistryReader, RepositorySummary, + ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, + RepositoryRegistryReader, RepositorySummary, }; use crate::resource_broker::BackendResourceBroker; use crate::runtime_subscription::RuntimeSubscriptionBroker; @@ -1231,6 +1234,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets", get(scoped_list_tickets).post(scoped_create_ticket_record), ) + .route( + "/api/w/{workspace_id}/tickets/query", + post(scoped_query_tickets), + ) .route( "/api/w/{workspace_id}/memory", get(scoped_get_memory_document), @@ -1324,29 +1331,34 @@ pub fn build_router(api: WorkspaceApi) -> Router { get(scoped_merge_request_readiness), ) .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/revisions", - post(scoped_add_merge_request_revision), + "/api/w/{workspace_id}/tickets/{id}/merge-request/thread", + get(scoped_merge_request_thread), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source", + post(scoped_repair_merge_request_selector), ) .route( "/api/w/{workspace_id}/internal/reviewer-child-sessions", post(scoped_register_reviewer_child_session), ) .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/review-attempts", - post(scoped_register_merge_request_review_attempt), + "/api/w/{workspace_id}/tickets/{id}/merge-request/review-capabilities", + post(scoped_register_merge_request_review_capability), ) .route( "/api/w/{workspace_id}/tickets/{id}/merge-request/reviews", post(scoped_submit_merge_request_review), ) + .route( + "/api/w/{workspace_id}/tickets/{id}/merge-request/reviews/revoke", + post(scoped_revoke_merge_request_review), + ) .route( "/api/w/{workspace_id}/tickets/{id}/merge-request/complete", post(scoped_complete_merge_request), ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/reopen", - post(scoped_reopen_merge_request), - ) + .route( "/api/w/{workspace_id}/tickets/{id}/workflow/close", post(scoped_close_ticket_record), @@ -1367,6 +1379,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket).patch(scoped_edit_ticket_item), ) + .route( + "/api/w/{workspace_id}/tickets/{id}/show", + post(scoped_show_ticket), + ) .route( "/api/w/{workspace_id}/tickets/{id}/assignment", get(scoped_get_ticket_worker_assignment) @@ -1398,11 +1414,19 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/objectives", get(scoped_list_objectives).post(scoped_create_objective), ) + .route( + "/api/w/{workspace_id}/objectives/query", + post(scoped_query_objectives), + ) .route("/api/objectives/{id}", get(get_objective)) .route( "/api/w/{workspace_id}/objectives/{objective_id}", get(scoped_get_objective).patch(scoped_edit_objective), ) + .route( + "/api/w/{workspace_id}/objectives/{objective_id}/show", + post(scoped_show_objective), + ) .route( "/api/w/{workspace_id}/objectives/{objective_id}/state", post(scoped_set_objective_state), @@ -2624,6 +2648,24 @@ async fn scoped_get_ticket( get_ticket(State(api), AxumPath(path.id)).await } +async fn scoped_query_tickets( + State(api): State, + AxumPath(path): AxumPath, + Json(query): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.query_tickets(query)?)) +} + +async fn scoped_show_ticket( + State(api): State, + AxumPath(path): AxumPath, + Json(query): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.show_ticket(&path.id, query)?)) +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct TicketWorkerAssignmentResponse { workspace_id: String, @@ -3577,25 +3619,30 @@ async fn scoped_queue_ticket_record( #[derive(Debug, serde::Deserialize)] struct OpenMergeRequestRequest { repository_id: String, - revision_id: String, - base_commit: String, - head_commit: String, - #[serde(default)] - changed_paths: Vec, + selector_from: String, + selector_to: String, #[serde(default)] summary: String, } #[derive(Debug, serde::Deserialize)] -struct AddMergeRequestRevisionRequest { - expected_current_revision_id: String, - revision_id: String, - base_commit: String, - head_commit: String, - #[serde(default)] - changed_paths: Vec, - #[serde(default)] - summary: String, +struct RepairMergeRequestSelectorRequest { + selector_from: String, + reason: String, + explicit_confirmation: bool, +} + +#[derive(Debug, serde::Deserialize)] +struct RevokeMergeRequestReviewRequest { + review_event_id: String, + reason: String, + explicit_confirmation: bool, +} + +#[derive(Debug, serde::Deserialize)] +struct MergeRequestThreadQuery { + after: Option, + limit: Option, } #[derive(Debug, serde::Deserialize)] @@ -3604,16 +3651,13 @@ struct RegisterReviewerChildSessionRequest { } #[derive(Debug, serde::Deserialize)] -struct RegisterMergeRequestReviewAttemptRequest { - attempt_id: String, - revision_id: String, +struct RegisterMergeRequestReviewCapabilityRequest { child_session_id: String, capability_token: String, } #[derive(Debug, serde::Deserialize)] struct SubmitMergeRequestReviewRequest { - revision_id: String, capability_token: String, decision: merge_request::ReviewDecision, #[serde(default)] @@ -3625,18 +3669,11 @@ struct SubmitMergeRequestReviewRequest { #[derive(Debug, serde::Deserialize)] struct CompleteMergeRequestRequest { operation_id: String, - expected_revision_id: String, - target_commit: String, - source_commit: String, - result_commit: String, + approval_event_id: String, + target_ref_before: String, + target_ref_after: String, strategy: merge_request::MergeStrategy, - resolution: merge_request::MergeResolution, -} - -#[derive(Debug, serde::Deserialize)] -struct RevisionTransitionRequest { - expected_revision_id: String, - explicit_confirmation: bool, + resolution: merge_request::ConflictResolution, } fn parse_workspace_id(value: &str) -> ApiResult { @@ -3653,14 +3690,64 @@ fn require_workspace_access(workspace_id: &str, api: &WorkspaceApi) -> ApiResult Ok(()) } +#[derive(Clone)] +struct MergeRequestAssignmentSource { + store: Arc, +} + +impl merge_request::AssignmentSource for MergeRequestAssignmentSource { + fn current_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> std::result::Result, String> { + self.store + .get_current_ticket_worker_assignment(workspace_id, ticket_id) + .map(|value| { + value.map(|assignment| merge_request::CurrentAssignment { + assignment_id: assignment.assignment_id, + ticket_id: ticket_id.to_string(), + runtime_id: assignment.worker.runtime_id, + worker_id: assignment.worker.worker_id, + }) + }) + .map_err(|error| error.to_string()) + } +} + +#[derive(Clone)] +struct MergeRequestRepositorySource { + workspace_id: String, + reader: RepositoryRegistryReader, +} + +impl merge_request::RepositorySource for MergeRequestRepositorySource { + fn repository_belongs_to_workspace( + &self, + workspace_id: &str, + repository_id: &str, + ) -> std::result::Result { + if workspace_id != self.workspace_id { + return Ok(false); + } + Ok(self.reader.summary(repository_id).is_ok()) + } +} + fn merge_request_store( api: &WorkspaceApi, workspace_id: &str, -) -> ApiResult { +) -> ApiResult { require_workspace_access(workspace_id, api)?; - merge_request::SqliteMergeRequestStore::open_verified( + merge_request::MergeRequestStore::open( api.config.database_path.clone(), - workspace_id, + Arc::new(MergeRequestAssignmentSource { + store: api.store.clone(), + }), + Arc::new(MergeRequestRepositorySource { + workspace_id: workspace_id.to_string(), + reader: api.repository_reader(), + }), ) .map_err(Error::from) .map_err(Into::into) @@ -3673,102 +3760,64 @@ fn repository_merge_evidence_error(error: RepositoryLookupError) -> ApiError { .into() } -fn validate_open_merge_request_evidence( - api: &WorkspaceApi, - ticket_id: &str, - repository_id: &str, - base_commit: &str, - head_commit: &str, -) -> ApiResult<(String, String, MergeTargetObservation)> { - let ticket = browser_ticket_backend(api)? - .show(TicketIdOrSlug::Id(ticket_id.into())) - .map_err(Error::from)?; - if ticket.meta.repository_id.as_deref() != Some(repository_id) { - return Err(Error::InvalidInput( - "Merge Request repository must match the authoritative Ticket target".into(), - ) - .into()); - } - let reader = api.repository_reader(); - let target = reader - .observe_merge_target(repository_id, ticket.meta.ref_selector.as_deref()) - .map_err(repository_merge_evidence_error)?; - let base = reader - .observe_commit(repository_id, base_commit) - .map_err(repository_merge_evidence_error)?; - let source = reader - .observe_commit(repository_id, head_commit) - .map_err(repository_merge_evidence_error)?; - reader - .ensure_ancestor(repository_id, &base.commit, &source.commit) - .map_err(repository_merge_evidence_error)?; - Ok((base.commit, source.commit, target)) -} - -fn validate_revision_evidence( - api: &WorkspaceApi, - repository_id: &str, - base_commit: &str, - head_commit: &str, -) -> ApiResult<(String, String)> { - let reader = api.repository_reader(); - let base = reader - .observe_commit(repository_id, base_commit) - .map_err(repository_merge_evidence_error)?; - let source = reader - .observe_commit(repository_id, head_commit) - .map_err(repository_merge_evidence_error)?; - reader - .ensure_ancestor(repository_id, &base.commit, &source.commit) - .map_err(repository_merge_evidence_error)?; - Ok((base.commit, source.commit)) -} - -fn observe_merge_request_target( - api: &WorkspaceApi, - mr: &merge_request::MergeRequest, -) -> Option { - let selector = mr.target_ref_selector.as_deref()?; - api.repository_reader() - .observe_merge_target(&mr.repository_id, Some(selector)) - .ok() - .map(|target| target.commit) -} - async fn scoped_show_merge_request( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let store = merge_request_store(&api, &workspace_id)?; - let current = store.show_for_ticket(&ticket_id)?.ok_or_else(|| { - Error::from(merge_request::MergeRequestError::NotFound( - ticket_id.clone(), - )) - })?; - let target_commit = observe_merge_request_target(&api, ¤t); - let value = store - .show_for_ticket_with_target(&ticket_id, target_commit.as_deref())? - .ok_or_else(|| Error::from(merge_request::MergeRequestError::NotFound(ticket_id)))?; - Ok(Json(value)) + let mut mr = store.get(&workspace_id, &ticket_id)?; + mr.thread = store.thread_page(&workspace_id, &ticket_id, None, 100)?; + let reader = api.repository_reader(); + let observed_at = Utc::now().to_rfc3339(); + let source = match mr.selector_from.as_deref() { + Some(selector) => match reader.observe_merge_target(&mr.repository_id, Some(selector)) { + Ok(value) => { + serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at}) + } + Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}), + }, + None => serde_json::json!({"status":"requires_repair","observed_at":observed_at}), + }; + let target = match reader.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) { + Ok(value) => { + serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at}) + } + Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}), + }; + let mut response = + serde_json::to_value(mr).map_err(|error| Error::InvalidInput(error.to_string()))?; + if let Some(object) = response.as_object_mut() { + object.insert("source".into(), source); + object.insert("target".into(), target); + } + Ok(Json(response)) } async fn scoped_merge_request_readiness( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let store = merge_request_store(&api, &workspace_id)?; - let current = store.show_for_ticket(&ticket_id)?.ok_or_else(|| { - Error::from(merge_request::MergeRequestError::NotFound( - ticket_id.clone(), - )) - })?; - let target_commit = observe_merge_request_target(&api, ¤t); - Ok(Json(store.readiness_for_ticket_with_target( - &ticket_id, - target_commit.as_deref(), - )?)) + let mr = store.get(&workspace_id, &ticket_id)?; + let current_subject_ref = mr.selector_from.as_deref().and_then(|selector| { + api.repository_reader() + .observe_merge_target(&mr.repository_id, Some(selector)) + .ok() + .map(|v| v.commit) + }); + Ok(Json(store.readiness(merge_request::ReadinessCheck { + ticket_id, + current_subject_ref, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: String::new(), + worker_id: String::new(), + assignment_id: String::new(), + }, + })?)) } async fn scoped_open_merge_request( @@ -3776,62 +3825,6 @@ async fn scoped_open_merge_request( headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json, -) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; - require_workspace_access(&workspace_id, &api)?; - let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; - let assignment = api - .store - .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no current assigned Coder".to_string()) - })?; - if assignment.worker.runtime_id != source.runtime_id - || assignment.worker.worker_id != source.worker_id - { - return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not the current Ticket assignee".to_string(), - ) - .into()); - } - let (base_commit, source_commit, target) = validate_open_merge_request_evidence( - &api, - &ticket_id, - &input.repository_id, - &input.base_commit, - &input.head_commit, - )?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - let revision = merge_request::MergeRequestRevision { - revision_id: input.revision_id, - ordinal: 1, - base_commit, - head_commit: source_commit, - changed_paths: input.changed_paths, - summary: input.summary, - assignment_id: assignment.assignment_id.clone(), - created_at: now.clone(), - }; - let mr = merge_request_store(&api, &workspace_id)?.open_merge_request( - merge_request::OpenMergeRequest { - merge_request_id: format!("mr_{}", Uuid::now_v7().simple()), - ticket_id, - repository_id: input.repository_id, - target_ref_selector: target.selector, - revision, - authenticated_runtime_id: source.runtime_id, - authenticated_worker_id: source.worker_id, - now, - }, - )?; - Ok(Json(mr)) -} - -async fn scoped_add_merge_request_revision( - State(api): State, - headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; @@ -3846,43 +3839,100 @@ async fn scoped_add_merge_request_revision( || assignment.worker.worker_id != source.worker_id { return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not the current Ticket assignee".into(), + "authenticated Worker is not current assignee".into(), ) .into()); } - let current = merge_request_store(&api, &workspace_id)? - .show_for_ticket(&ticket_id)? - .ok_or_else(|| { - Error::from(merge_request::MergeRequestError::NotFound( - ticket_id.clone(), - )) - })?; - let (base_commit, head_commit) = validate_revision_evidence( - &api, - ¤t.repository_id, - &input.base_commit, - &input.head_commit, - )?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - let mr = - merge_request_store(&api, &workspace_id)?.add_revision(merge_request::AddRevision { - ticket_id, - expected_current_revision_id: input.expected_current_revision_id, - revision: merge_request::MergeRequestRevision { - revision_id: input.revision_id, - ordinal: current.current_revision.ordinal + 1, - base_commit, - head_commit, - changed_paths: input.changed_paths, + let ticket = browser_ticket_backend(&api)? + .show(TicketIdOrSlug::Id(ticket_id.clone().into())) + .map_err(Error::from)?; + if ticket.meta.repository_id.as_deref() != Some(input.repository_id.as_str()) + || ticket.meta.ref_selector.as_deref() != Some(input.selector_to.as_str()) + { + return Err(Error::InvalidInput( + "selectors must match the authoritative Ticket repository target".into(), + ) + .into()); + } + let reader = api.repository_reader(); + reader + .observe_merge_target(&input.repository_id, Some(&input.selector_from)) + .map_err(repository_merge_evidence_error)?; + reader + .observe_merge_target(&input.repository_id, Some(&input.selector_to)) + .map_err(repository_merge_evidence_error)?; + Ok(Json( + merge_request_store(&api, &workspace_id)?.open_merge_request( + merge_request::OpenMergeRequest { + merge_request_id: Uuid::now_v7().to_string(), + ticket_id, + repository_id: input.repository_id.clone(), + selector_from: input.selector_from, + selector_to: input.selector_to, summary: input.summary, - assignment_id: assignment.assignment_id, - created_at: now.clone(), + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: input.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), }, - authenticated_runtime_id: source.runtime_id, - authenticated_worker_id: source.worker_id, - now, - })?; - Ok(Json(mr)) + )?, + )) +} + +async fn scoped_merge_request_thread( + State(api): State, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Query(query): Query, +) -> ApiResult>> { + let workspace_id = parse_workspace_id(&workspace_id)?; + Ok(Json( + merge_request_store(&api, &workspace_id)?.thread_page( + &workspace_id, + &ticket_id, + query.after, + query.limit.unwrap_or(100), + )?, + )) +} + +async fn scoped_repair_merge_request_selector( + State(api): State, + headers: HeaderMap, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Json(input): Json, +) -> ApiResult> { + let workspace_id = parse_workspace_id(&workspace_id)?; + require_workspace_access(&workspace_id, &api)?; + reject_non_browser_reopen_auth(&headers)?; + let _actor = require_actor(&api, &headers).await?; + if !input.explicit_confirmation { + return Err(Error::BrowserReopenConfirmationRequired.into()); + } + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get(&workspace_id, &ticket_id)?; + let resolved_subject_ref = api + .repository_reader() + .observe_merge_target(&mr.repository_id, Some(&input.selector_from)) + .map_err(repository_merge_evidence_error)? + .commit; + Ok(Json(store.repair_selector_from( + merge_request::RepairSelectorFrom { + workspace_id, + ticket_id, + selector_from: input.selector_from, + resolved_subject_ref, + repaired_by: merge_request::WorkerIdentity { + runtime_id: "browser".into(), + worker_id: "authenticated-user".into(), + }, + reason: input.reason, + now: Utc::now(), + }, + )?)) } async fn scoped_register_reviewer_child_session( @@ -3896,20 +3946,22 @@ async fn scoped_register_reviewer_child_session( let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; merge_request_store(&api, &workspace_id)?.register_reviewer_child_session( merge_request::RegisterReviewerChildSession { + workspace_id, parent_runtime_id: source.runtime_id, parent_worker_id: source.worker_id, child_session_id: input.child_session_id, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + reviewer_profile: "builtin:reviewer".into(), + now: Utc::now(), }, )?; Ok(StatusCode::NO_CONTENT) } -async fn scoped_register_merge_request_review_attempt( +async fn scoped_register_merge_request_review_capability( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, + Json(input): Json, ) -> ApiResult { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; @@ -3924,23 +3976,35 @@ async fn scoped_register_merge_request_review_attempt( || assignment.worker.worker_id != source.worker_id { return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not the current Ticket assignee".into(), + "authenticated Worker is not current assignee".into(), ) .into()); } - merge_request_store(&api, &workspace_id)?.register_review_attempt( - merge_request::RegisterReviewAttempt { - attempt_id: input.attempt_id, - ticket_id, - revision_id: input.revision_id, - parent_assignment_id: assignment.assignment_id, - parent_runtime_id: source.runtime_id, - parent_worker_id: source.worker_id, - child_session_id: input.child_session_id, - capability_token: input.capability_token, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get(&workspace_id, &ticket_id)?; + let selector = mr + .selector_from + .as_deref() + .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + let subject_ref = api + .repository_reader() + .observe_merge_target(&mr.repository_id, Some(selector)) + .map_err(repository_merge_evidence_error)? + .commit; + store.request_review(merge_request::RequestMergeRequestReview { + ticket_id, + subject_ref, + child_session_id: input.child_session_id, + capability_token: input.capability_token, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, }, - )?; + now: Utc::now(), + })?; Ok(StatusCode::NO_CONTENT) } @@ -3948,19 +4012,75 @@ async fn scoped_submit_merge_request_review( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let review = - merge_request_store(&api, &workspace_id)?.submit_review(merge_request::SubmitReview { + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get(&workspace_id, &ticket_id)?; + let selector = mr + .selector_from + .as_deref() + .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + let current_subject_ref = api + .repository_reader() + .observe_merge_target(&mr.repository_id, Some(selector)) + .map_err(repository_merge_evidence_error)? + .commit; + Ok(Json(store.submit_review( + merge_request::SubmitMergeRequestReview { ticket_id, - revision_id: input.revision_id, + current_subject_ref, capability_token: input.capability_token, decision: input.decision, body: input.body, findings: input.findings, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + now: Utc::now(), + }, + )?)) +} + +async fn scoped_revoke_merge_request_review( + State(api): State, + headers: HeaderMap, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Json(input): Json, +) -> ApiResult> { + let workspace_id = parse_workspace_id(&workspace_id)?; + require_workspace_access(&workspace_id, &api)?; + if !input.explicit_confirmation { + return Err(Error::BrowserReopenConfirmationRequired.into()); + } + let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + let assignment = api + .store + .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) })?; - Ok(Json(review)) + if assignment.worker.runtime_id != source.runtime_id + || assignment.worker.worker_id != source.worker_id + { + return Err(Error::TicketAssignmentConflict( + "authenticated Worker is not current assignee".into(), + ) + .into()); + } + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get(&workspace_id, &ticket_id)?; + Ok(Json(store.revoke_review( + merge_request::RevokeMergeRequestReview { + ticket_id, + review_event_id: input.review_event_id, + reason: input.reason, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), + }, + )?)) } async fn scoped_complete_merge_request( @@ -3968,7 +4088,7 @@ async fn scoped_complete_merge_request( headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; @@ -3980,197 +4100,71 @@ async fn scoped_complete_merge_request( Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) })?; let store = merge_request_store(&api, &workspace_id)?; - let mr = store - .show_for_ticket(&ticket_id)? - .ok_or_else(|| merge_request::MergeRequestError::NotFound(ticket_id.clone()))?; - if mr.current_revision.revision_id != input.expected_revision_id { - return Err(merge_request::MergeRequestError::StaleRevision { - expected: input.expected_revision_id, - current: mr.current_revision.revision_id, - } - .into()); - } - if mr.review_status != merge_request::ReviewStatus::Approved { - return Err(merge_request::MergeRequestError::NotApproved.into()); - } - if input.source_commit != mr.current_revision.head_commit { - return Err(merge_request::MergeRequestError::InvalidMergeOutcome( - "source commit does not match the current approved revision".into(), + let mr = store.get(&workspace_id, &ticket_id)?; + let selector = mr + .selector_from + .as_deref() + .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + let repositories = api.repository_reader(); + let current_source_ref = repositories + .observe_merge_target(&mr.repository_id, Some(selector)) + .map_err(repository_merge_evidence_error)? + .commit; + let observed = repositories + .observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) + .map_err(repository_merge_evidence_error)?; + if observed.commit != input.target_ref_before && observed.commit != input.target_ref_after { + return Err(Error::InvalidInput( + "target selector moved outside completion evidence".into(), ) .into()); } - if mr.state == merge_request::MergeRequestState::Merged { - return Ok(Json(store.complete( - merge_request::CompleteMergeRequest { - operation_id: input.operation_id, - ticket_id, - expected_revision_id: mr.current_revision.revision_id, - target_commit: input.target_commit, - source_commit: input.source_commit, - result_commit: input.result_commit, - strategy: input.strategy, - resolution: input.resolution, - implementation_assignment_id: assignment.assignment_id, - completion_actor_runtime_id: source.runtime_id, - completion_actor_worker_id: source.worker_id, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - }, - )?)); - } - let selector = mr - .target_ref_selector - .as_deref() - .ok_or(merge_request::MergeRequestError::UnknownTarget)?; - let repositories = api.repository_reader(); - let observed_target = repositories - .observe_merge_target(&mr.repository_id, Some(selector)) - .map_err(repository_merge_evidence_error)?; - let source_commit = repositories - .observe_commit(&mr.repository_id, &input.source_commit) - .map_err(repository_merge_evidence_error)?; - if source_commit.commit != input.source_commit { - return Err(Error::InvalidInput("source commit must be canonical".into()).into()); - } - let result_commit = repositories - .observe_commit(&mr.repository_id, &input.result_commit) - .map_err(repository_merge_evidence_error)?; - if result_commit.commit != input.result_commit { - return Err(Error::InvalidInput("result commit must be canonical".into()).into()); - } - match input.strategy { - merge_request::MergeStrategy::FastForward => { - if input.resolution != merge_request::MergeResolution::None - || input.result_commit != input.source_commit - { - return Err(merge_request::MergeRequestError::InvalidMergeOutcome( - "fast-forward result must equal the approved source and use resolution=none" - .into(), - ) - .into()); - } - repositories - .ensure_ancestor( - &mr.repository_id, - &input.target_commit, - &input.source_commit, - ) - .map_err(repository_merge_evidence_error)?; - } - merge_request::MergeStrategy::Merge => { - if input.resolution == merge_request::MergeResolution::None - || result_commit.parents - != vec![input.target_commit.clone(), input.source_commit.clone()] - { - return Err(merge_request::MergeRequestError::InvalidMergeOutcome( - "merge result must have the expected target and approved source as its two ordered parents" - .into(), - ) - .into()); - } - } - } - let target_was_already_updated = observed_target.commit == input.result_commit; - if observed_target.commit != input.target_commit && !target_was_already_updated { - return Err(Error::InvalidInput(format!( - "Merge Request target moved: expected {}, observed {}", - input.target_commit, observed_target.commit - )) - .into()); - } - if !target_was_already_updated { + let completion = merge_request::CompleteMergeRequest { + ticket_id, + operation_id: input.operation_id, + approval_event_id: input.approval_event_id, + current_subject_ref: current_source_ref, + target_ref_before: input.target_ref_before.clone(), + target_ref_after: input.target_ref_after.clone(), + strategy: input.strategy, + resolution: input.resolution, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id.clone(), + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), + }; + store.validate_completion(&completion)?; + let already = observed.commit == input.target_ref_after; + if !already { repositories .update_merge_target( &mr.repository_id, - selector, - &input.target_commit, - &input.result_commit, + &mr.selector_to, + &input.target_ref_before, + &input.target_ref_after, ) - .map_err(repository_merge_evidence_error)?; + .map_err(repository_merge_evidence_error)? } - let verified_target = repositories.observe_merge_target(&mr.repository_id, Some(selector)); - let verified_result = matches!( - verified_target.as_ref(), - Ok(target) if target.commit == input.result_commit - ); - if !verified_result { - if !target_was_already_updated { - if let Err(rollback_error) = repositories.update_merge_target( - &mr.repository_id, - selector, - &input.result_commit, - &input.target_commit, - ) { - return Err(Error::InvalidInput(format!( - "post-update target verification failed and guarded rollback also failed: verification={verified_target:?}; rollback={rollback_error:?}" - )) - .into()); - } - } - return Err(Error::InvalidInput(format!( - "post-update target verification failed: expected result {}, observed {:?}", - input.result_commit, - verified_target - .as_ref() - .map(|target| target.commit.as_str()) - .map_err(|error| error) - )) - .into()); - } - let completion = merge_request::CompleteMergeRequest { - operation_id: input.operation_id, - ticket_id, - expected_revision_id: mr.current_revision.revision_id, - target_commit: input.target_commit.clone(), - source_commit: input.source_commit, - result_commit: input.result_commit.clone(), - strategy: input.strategy, - resolution: input.resolution, - implementation_assignment_id: assignment.assignment_id, - completion_actor_runtime_id: source.runtime_id, - completion_actor_worker_id: source.worker_id, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - }; match store.complete(completion) { - Ok(outcome) => Ok(Json(outcome)), - Err(error) => { - if !target_was_already_updated { - if let Err(rollback_error) = repositories.update_merge_target( + Ok(v) => Ok(Json(v)), + Err(e) => { + if !already { + let _ = repositories.update_merge_target( &mr.repository_id, - selector, - &input.result_commit, - &input.target_commit, - ) { - return Err(Error::InvalidInput(format!( - "merge finalization failed after target update and guarded rollback also failed: finalization={error}; rollback={rollback_error:?}" - )) - .into()); - } + &mr.selector_to, + &input.target_ref_after, + &input.target_ref_before, + ); } - Err(error.into()) + Err(e.into()) } } } -async fn scoped_reopen_merge_request( - State(api): State, - headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; - require_workspace_access(&workspace_id, &api)?; - reject_non_browser_reopen_auth(&headers)?; - let _actor = require_actor(&api, &headers).await?; - if !input.explicit_confirmation { - return Err(Error::BrowserReopenConfirmationRequired.into()); - } - Ok(Json(merge_request_store(&api, &workspace_id)?.reopen( - &ticket_id, - &input.expected_revision_id, - &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - )?)) -} - fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { if headers.contains_key("authorization") { return Err(Error::BrowserReopenConfirmationRequired); @@ -5558,6 +5552,26 @@ async fn scoped_get_objective( get_objective(State(api), AxumPath(path.objective_id)).await } +async fn scoped_query_objectives( + State(api): State, + AxumPath(path): AxumPath, + Json(query): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.query_objectives(query)?)) +} + +async fn scoped_show_objective( + State(api): State, + AxumPath(path): AxumPath, + Json(query): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json( + api.authority.show_objective(&path.objective_id, query)?, + )) +} + async fn scoped_create_objective( State(api): State, AxumPath(path): AxumPath, @@ -11904,10 +11918,10 @@ impl IntoResponse for ApiError { StatusCode::BAD_REQUEST } Error::Ticket(ticket::TicketError::NotFound(_)) - | Error::MergeRequest(merge_request::MergeRequestError::NotFound(_)) => { + | Error::MergeRequest(merge_request::MergeRequestError::NotFound) => { StatusCode::NOT_FOUND } - Error::MergeRequest(merge_request::MergeRequestError::Empty(_)) => { + Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => { StatusCode::BAD_REQUEST } Error::MergeRequest(_) => StatusCode::CONFLICT, @@ -12720,238 +12734,6 @@ mod tests { )); } - #[tokio::test] - async fn merge_request_completion_endpoint_rejects_coder_and_accepts_orchestrator() { - let workspace = tempfile::tempdir().unwrap(); - init_clean_git_workspace(workspace.path()); - let git_value = |args: &[&str]| { - let output = std::process::Command::new("git") - .arg("-C") - .arg(workspace.path()) - .args(args) - .output() - .unwrap(); - assert!(output.status.success()); - String::from_utf8(output.stdout).unwrap().trim().to_string() - }; - let target_commit = git_value(&["rev-parse", "HEAD"]); - let target_ref = git_value(&["symbolic-ref", "HEAD"]); - std::fs::write(workspace.path().join("README.md"), "merge source\n").unwrap(); - for args in [&["add", "README.md"][..], &["commit", "-m", "source"][..]] { - assert!( - std::process::Command::new("git") - .arg("-C") - .arg(workspace.path()) - .args(args) - .status() - .unwrap() - .success() - ); - } - let source_commit = git_value(&["rev-parse", "HEAD"]); - assert!( - std::process::Command::new("git") - .arg("-C") - .arg(workspace.path()) - .args(["reset", "--hard", &target_commit]) - .status() - .unwrap() - .success() - ); - let api = test_api(workspace.path()).await; - let workspace_id = api.config.workspace_id.clone(); - let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Orchestrator completion authority"); - input.workflow_state = Some(TicketWorkflowState::InProgress); - let ticket = backend.create(input).unwrap(); - let Json(coder) = create_workspace_worker( - State(api.clone()), - HeaderMap::new(), - Json(CreateWorkspaceWorkerRequest { - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - display_name: "Assigned Coder".to_string(), - profile: Some("builtin:coder".to_string()), - ticket_assignment: Some(CreateWorkspaceWorkerTicketAssignmentRequest { - ticket_id: ticket.id.clone(), - operation_id: "completion-coder-assignment".to_string(), - }), - initial_submit: vec![Segment::Flow { - selector: "builtin:coder-review".to_string(), - }], - working_directory: None, - control_operation_id: None, - resolved_control_operation: None, - }), - ) - .await - .unwrap(); - let assignment = api - .store - .get_current_ticket_worker_assignment(&workspace_id, &ticket.id) - .unwrap() - .unwrap(); - let mr_store = merge_request_store(&api, &workspace_id).unwrap(); - mr_store - .open_merge_request(merge_request::OpenMergeRequest { - merge_request_id: "MR-server-completion".into(), - ticket_id: ticket.id.clone(), - repository_id: TEST_REPOSITORY_ID.into(), - target_ref_selector: target_ref.clone(), - revision: merge_request::MergeRequestRevision { - revision_id: "V1".into(), - ordinal: 1, - base_commit: target_commit.clone(), - head_commit: source_commit.clone(), - - changed_paths: vec!["src/lib.rs".into()], - summary: "approved revision".into(), - assignment_id: assignment.assignment_id.clone(), - created_at: "t1".into(), - }, - authenticated_runtime_id: coder.worker_ref.runtime_id.clone(), - authenticated_worker_id: coder.worker_ref.worker_id.clone(), - now: "t1".into(), - }) - .unwrap(); - mr_store - .register_reviewer_child_session(merge_request::RegisterReviewerChildSession { - parent_runtime_id: coder.worker_ref.runtime_id.clone(), - parent_worker_id: coder.worker_ref.worker_id.clone(), - child_session_id: "reviewer-child".into(), - now: "t2".into(), - }) - .unwrap(); - mr_store - .register_review_attempt(merge_request::RegisterReviewAttempt { - attempt_id: "attempt".into(), - ticket_id: ticket.id.clone(), - revision_id: "V1".into(), - parent_assignment_id: assignment.assignment_id.clone(), - parent_runtime_id: coder.worker_ref.runtime_id.clone(), - parent_worker_id: coder.worker_ref.worker_id.clone(), - child_session_id: "reviewer-child".into(), - capability_token: "review-token".into(), - now: "t2".into(), - }) - .unwrap(); - mr_store - .submit_review(merge_request::SubmitReview { - ticket_id: ticket.id.clone(), - revision_id: "V1".into(), - capability_token: "review-token".into(), - decision: merge_request::ReviewDecision::Approve, - body: "approved".into(), - findings: Vec::new(), - now: "t3".into(), - }) - .unwrap(); - - let worker_headers = |worker: &RuntimeWorkerRef| { - let mut headers = HeaderMap::new(); - headers.insert( - "x-yoi-runtime-id", - axum::http::HeaderValue::from_str(&worker.runtime_id).unwrap(), - ); - headers.insert( - "x-yoi-worker-id", - axum::http::HeaderValue::from_str(&worker.worker_id).unwrap(), - ); - headers - }; - let request = || CompleteMergeRequestRequest { - operation_id: "complete-operation".into(), - expected_revision_id: "V1".into(), - target_commit: target_commit.clone(), - source_commit: source_commit.clone(), - result_commit: source_commit.clone(), - strategy: merge_request::MergeStrategy::FastForward, - resolution: merge_request::MergeResolution::None, - }; - let coder_error = scoped_complete_merge_request( - State(api.clone()), - worker_headers(&coder.worker_ref), - AxumPath((workspace_id.clone(), ticket.id.clone())), - Json(request()), - ) - .await - .unwrap_err(); - assert!(matches!( - coder_error.error, - Error::TicketAssignmentConflict(_) - )); - - let Json(started) = scoped_start_workspace_orchestrator( - State(api.clone()), - AxumPath(ScopedWorkspacePath { - workspace_id: workspace_id.clone(), - }), - ) - .await - .unwrap(); - let orchestrator = started.worker.unwrap().worker; - let Json(completed) = scoped_complete_merge_request( - State(api.clone()), - worker_headers(&orchestrator), - AxumPath((workspace_id, ticket.id.clone())), - Json(request()), - ) - .await - .unwrap(); - assert!(!completed.replayed); - assert_eq!( - backend - .show(ticket.id.clone().into()) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::Done - ); - assert_eq!( - api.repository_reader() - .observe_merge_target(TEST_REPOSITORY_ID, Some(&target_ref)) - .unwrap() - .commit, - source_commit - ); - let merged = mr_store.show_for_ticket(&ticket.id).unwrap().unwrap(); - assert_eq!( - merged.merged_target_commit.as_deref(), - Some(target_commit.as_str()) - ); - assert_eq!( - merged.merged_result_commit.as_deref(), - Some(source_commit.as_str()) - ); - assert_eq!( - merged.merge_strategy, - Some(merge_request::MergeStrategy::FastForward) - ); - let Json(replayed) = scoped_complete_merge_request( - State(api.clone()), - worker_headers(&orchestrator), - AxumPath((api.config.workspace_id.clone(), ticket.id.clone())), - Json(request()), - ) - .await - .unwrap(); - assert!(replayed.replayed); - let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); - let actor: String = conn - .query_row( - "SELECT author FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2 AND kind='state_changed'", - rusqlite::params![api.config.workspace_id, ticket.id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - actor, - format!( - "worker:{}:{}", - orchestrator.runtime_id, orchestrator.worker_id - ) - ); - } - #[tokio::test] async fn production_profile_backend_launches_and_restores_workspace_orchestrator() { let workspace = tempfile::tempdir().unwrap(); @@ -17164,10 +16946,64 @@ mod tests { .await; assert_eq!(scoped_objective["id"], "00000000001J3"); assert_eq!(scoped_objective["record_source"], "workspace-sqlite"); + assert_eq!( + scoped_objective["revision"].as_str().unwrap().is_empty(), + false + ); assert_eq!( scoped_objective["resources"][0]["path"], "memory-architecture-overview.md" ); + let queried_tickets = request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"), + Some(json!({ + "limit": 1 + })), + StatusCode::OK, + ) + .await; + assert_eq!(queried_tickets["items"][0]["title"], "API Ticket"); + let queried_ticket_id = queried_tickets["items"][0]["id"] + .as_str() + .expect("query Ticket id") + .to_string(); + assert_eq!(queried_tickets["page"]["limit"], 1); + let shown_ticket = request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{queried_ticket_id}/show"), + Some(json!({"event_limit": 10})), + StatusCode::OK, + ) + .await; + assert!(shown_ticket["evidence"]["missing"].is_array()); + assert!(shown_ticket["item_revision"].as_str().is_some()); + let queried_objectives = request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/objectives/query"), + Some(json!({ + "query": "Objective body", + "linked_ticket_id": "00000000001J2", + "limit": 1 + })), + StatusCode::OK, + ) + .await; + assert_eq!(queried_objectives["items"][0]["id"], "00000000001J3"); + assert_eq!(queried_objectives["page"]["limit"], 1); + let shown_objective = request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/objectives/00000000001J3/show"), + Some(json!({"event_limit": 10})), + StatusCode::OK, + ) + .await; + assert_eq!(shown_objective["linked_tickets"][0], "00000000001J2"); + assert!(shown_objective["event_page"]["returned"].is_number()); let memory_document = get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await; diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 43228d9c..06e18e5e 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -626,6 +626,12 @@ pub trait ControlPlaneStore: Send + Sync { fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>; fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result>; + fn list_objectives_for_ticket( + &self, + workspace_id: &str, + ticket_id: &str, + limit: usize, + ) -> Result>; fn get_objective( &self, workspace_id: &str, @@ -1532,6 +1538,33 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn list_objectives_for_ticket( + &self, + workspace_id: &str, + ticket_id: &str, + limit: usize, + ) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + r#"SELECT o.workspace_id, o.objective_id, o.title, o.state, o.body_md, + o.created_at, o.updated_at + FROM objectives AS o + INNER JOIN objective_ticket_links AS l + ON l.workspace_id = o.workspace_id + AND l.objective_id = o.objective_id + WHERE o.workspace_id = ?1 AND l.ticket_id = ?2 + ORDER BY o.updated_at DESC, o.objective_id ASC + LIMIT ?3"#, + )?; + let rows = stmt.query_map( + params![workspace_id, ticket_id, limit as i64], + read_objective_record, + )?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } + fn get_objective( &self, workspace_id: &str, diff --git a/docs/development/work-items.md b/docs/development/work-items.md index df27693d..8e096fea 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -34,15 +34,15 @@ Maintainers can inspect the local `.yoi/tickets/` files directly when debugging Workers with the Ticket built-in feature can use typed Ticket tools: - `TicketCreate` -- `TicketList` — lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority. -- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds. +- `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata. +- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence. - `TicketComment` - `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete` - `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input. - `TicketClose` - `TicketRelationRecord` -- `TicketRelationQuery` -- `TicketDoctor` + +Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics. These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`. diff --git a/docs/report/merge-request-open-ticket-target-discovery.md b/docs/report/merge-request-open-ticket-target-discovery.md new file mode 100644 index 00000000..2d10f1f5 --- /dev/null +++ b/docs/report/merge-request-open-ticket-target-discovery.md @@ -0,0 +1,27 @@ +# Merge Request open requires an undiscoverable Ticket repository target + +## Observed + +While implementing Ticket `00001KZRNHB35`, the assigned Coder had a clean committed Workdir and all immutable base/head/changed-path evidence needed by `MergeRequestOpen`. + +`MergeRequestOpen` rejected candidate repository ids with: + +```text +invalid input: Merge Request repository must match the authoritative Ticket target +``` + +The typed `TicketShow` result available to the Coder rendered only the Ticket id and state; it did not expose the authoritative `repository_id` or ref selector. No typed repository/workdir lookup tool was available to the Coder. Continuing would therefore require guessing control-plane identity, mutating the Ticket target without evidence, or bypassing typed authority, all of which are correctly prohibited. + +## Impact + +A Coder can finish and validate implementation but cannot open the required immutable MR revision or start independent review. The failure is safe, but it strands otherwise review-ready work and provides no actionable expected target. + +## Suggested improvement + +At least one trusted read surface in the assigned-Coder flow should return the immutable Ticket target needed by `MergeRequestOpen`: + +- include `repository_id` and `ref_selector` in `TicketShow`'s bounded authoritative projection; or +- have `MergeRequestOpen` derive repository identity from the authoritative Ticket target and remove it from model input; or +- return a bounded structured mismatch diagnostic containing the authoritative repository id when the caller is already authorized to read that Ticket. + +Deriving the repository in `MergeRequestOpen` is preferable because it removes duplicated model-controlled identity and avoids target drift between Ticket read and MR creation. diff --git a/resources/prompts/common/git.md b/resources/prompts/common/git.md index 2b05bc52..72e8b3e2 100644 --- a/resources/prompts/common/git.md +++ b/resources/prompts/common/git.md @@ -10,4 +10,4 @@ When creating a commit, use the change type as the subject prefix, not the affec A change made because review, validation, or user feedback found a defect is a `fix:` even when it belongs to the same feature Ticket and has not been merged yet. Do not keep reusing a domain prefix such as `merge-request:`, `runtime:`, or `worker:` across a series; those labels identify where the code lives rather than why each commit exists. If one prospective commit contains distinct change types, split it into coherent validated commits when practical; otherwise name it for the dominant intent. -Before opening or appending an immutable Merge Request revision, inspect the proposed commit subjects and correct misclassified local, unshared commits when safe. Do not rewrite shared history solely to rename existing commits unless the user explicitly requests it. +Before opening a Merge Request or requesting review, inspect the proposed commit subjects and correct misclassified local, unshared commits when safe. Do not rewrite shared history solely to rename existing commits unless the user explicitly requests it. diff --git a/resources/prompts/common/tickets.md b/resources/prompts/common/tickets.md index 5ccf064e..c9e5e1e1 100644 --- a/resources/prompts/common/tickets.md +++ b/resources/prompts/common/tickets.md @@ -1,9 +1,9 @@ ## Ticket workflow -Use the available typed Ticket tools as the authority for Ticket reads and mutations. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools. +Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools. Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work. -Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. +Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish implementation completion from review and closure, and perform only lifecycle actions supported by the tools and authority available to the current Worker. diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index 339c7b57..adfa0e54 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -4,6 +4,6 @@ Treat the first committed user message as the bounded Ticket/action context and {% include "common.git" %} -Before review, open or append an immutable Merge Request revision containing the exact base/head/tree and changed-path evidence. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and include the structured `review` handoff with the Ticket id and current MR revision id. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected attempt authority. +Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected capability authority. -A request-changes result requires a new immutable revision and a fresh Reviewer child attempt. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id and the currently approved revision; the Server revalidates assignment and fences Ticket state side effects. +A request-changes result requires a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id, the approved `Review` event id, and final target-ref evidence; the Server re-resolves selectors, revalidates assignment, and fences Ticket state side effects. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index dc3e889b..32092653 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -4,7 +4,7 @@ You are the Ticket Orchestrator role. Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers, and stop for human authority when merge/closure is not explicitly delegated. -The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If current-revision durable review evidence is missing, indeterminate, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker. +The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. diff --git a/resources/prompts/role/reviewer.md b/resources/prompts/role/reviewer.md index 67334119..112c412c 100644 --- a/resources/prompts/role/reviewer.md +++ b/resources/prompts/role/reviewer.md @@ -1,7 +1,7 @@ You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder. -Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the immutable current Merge Request revision against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation. +Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation. -Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Attempt identity and revision identity are injected by your child Workspace client and are not model inputs. If the authoritative revision changed, submission must fail rather than approving stale work. +Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility. diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index df60854d..a24976be 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -23,8 +23,19 @@ export type TicketListResponse = { record_authority: string; }; +export type QueryPage = { + limit: number; + returned: number; + has_more: boolean; + next_cursor: string | null; + sort: string; + source_limit: number | null; + source_truncated: boolean; +}; + export type TicketEventDetail = { sequence: number; + event_ref: string; kind: string; author: string | null; at: string | null; @@ -35,6 +46,99 @@ export type TicketEventDetail = { state_field: string | null; heading: string | null; body: string | null; + attributes: { [key in string]: string }; + references: Array; +}; + +export type ObjectiveLinkSummary = { id: string; title: string; state: string }; + +export type TicketEvidenceEvent = { + event_ref: string; + sequence: number; + kind: string; + at: string | null; + author: string | null; + excerpt: string; +}; + +export type TicketAssignmentSummary = { + assignment_id: string; + runtime_id: string; + worker_id: string; +}; + +export type TicketMergeRequestSummary = { + merge_request_id: string; + state: string; + review_status: string; + selector_from: string | null; + selector_to: string; + updated_at: string; + review_subject_ref: string | null; + review_submitted_at: string | null; + review_excerpt: string | null; +}; + +export type TicketEvidenceSummary = { + has_implementation_report: boolean; + implementation_report_after_rescope: boolean; + has_merge_request: boolean; + has_commit: boolean; + review_status: string | null; + approved: boolean; + unresolved_request_changes: boolean; + complete_for_integration: boolean; + missing: Array; +}; + +export type TicketQueryRequest = { + query: string | null; + states: Array; + event_kinds: Array; + evidence: Array; + review_status: string | null; + attention: Array; + related_ticket_id: string | null; + relation_kind: string | null; + linked_objective_id: string | null; + updated_after: string | null; + updated_before: string | null; + sort: string | null; + limit: number | null; + cursor: string | null; +}; + +export type TicketQueryItem = { + id: string; + title: string; + state: string; + readiness: string | null; + priority: string; + created_at: string | null; + updated_at: string | null; + item_revision: string; + workspace_action_priority: string; + matched_fields: Array; + snippet: string | null; + matching_event: TicketEvidenceEvent | null; + linked_objective_ids: Array; + relation_count: number; + blocker_count: number; + unresolved_blocker_count: number; + unresolved_review_count: number; + evidence: TicketEvidenceSummary; + merge_request: TicketMergeRequestSummary | null; +}; + +export type TicketQueryResponse = { + items: Array; + page: QueryPage; + record_authority: string; +}; + +export type TicketShowRequest = { + event_limit: number | null; + event_cursor: string | null; }; export type TicketRelation = { @@ -80,9 +184,11 @@ export type TicketDetail = { id: string; title: string; state: string; + readiness: string | null; priority: string; created_at: string | null; updated_at: string | null; + item_revision: string; queued_by: string | null; queued_at: string | null; assignee: string | null; @@ -93,9 +199,15 @@ export type TicketDetail = { body_truncated: boolean; event_count: number; events: Array; + event_page: QueryPage; artifact_count: number; artifacts: Array; relations: TicketRelationView; + linked_objectives: Array; + implementation_reports: Array; + current_assignment: TicketAssignmentSummary | null; + merge_request: TicketMergeRequestSummary | null; + evidence: TicketEvidenceSummary; resolution: string | null; record_source: string; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index 5696a486..875f2472 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -17,22 +17,52 @@ TicketDetail, } from "$lib/workspace/sidebar/types"; + type MergeRequestThreadEvent = + | { + kind: "review_requested"; + event_id: string; + sequence: number; + subject_ref: string; + requested_by: { runtime_id: string; worker_id: string }; + reviewer: { runtime_id: string; worker_id: string }; + } + | { + kind: "review"; + event_id: string; + sequence: number; + request_event_id: string; + subject_ref: string; + decision: "approve" | "request_changes"; + body: string; + reviewer: { runtime_id: string; worker_id: string }; + } + | { kind: "review_revoked"; sequence: number; review_event_id: string; reason: string } + | { kind: "review_cancelled"; sequence: number; request_event_id: string; reason: string } + | { + kind: "comment"; + sequence: number; + body: string; + author: { runtime_id: string; worker_id: string }; + } + | { + kind: "merge"; + sequence: number; + approval_event_id: string; + approved_source_ref: string; + target_ref_after: string; + strategy: "fast_forward" | "merge"; + resolution: "none" | "clean" | "conflicts_resolved"; + merged_by: { runtime_id: string; worker_id: string }; + }; + + type RefProjection = { status: "known" | "unknown" | "requires_repair"; ref?: string }; type MergeRequestDetail = { - state: "draft" | "open" | "closed" | "merged"; - review_status: "pending" | "approved" | "changes_requested"; - target_ref_selector?: string | null; - target_status: "known" | "unknown"; - observed_target_commit?: string | null; - current_revision: { revision_id: string; head_commit: string; changed_paths: string[]; summary: string }; - current_review?: { decision: string; body: string; reviewer_effective_profile: string } | null; - merged_revision_id?: string | null; - merged_target_commit?: string | null; - merged_result_commit?: string | null; - merge_strategy?: "fast_forward" | "merge" | null; - merge_resolution?: "none" | "clean" | "conflicts_resolved" | null; - merged_by_runtime_id?: string | null; - merged_by_worker_id?: string | null; - merged_at?: string | null; + state: "open" | "closed" | "merged"; + selector_from: string | null; + selector_to: string; + source: RefProjection; + target: RefProjection; + thread: MergeRequestThreadEvent[]; }; const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done"); @@ -56,6 +86,23 @@ let ticket = $state(loadedTicket); let mergeRequest = $state(initialData.mergeRequest.data ?? null); + const currentReviewRequest = $derived( + mergeRequest?.thread.findLast((event) => event.kind === "review_requested") ?? null, + ); + const currentReview = $derived.by(() => { + if (mergeRequest?.source.status !== "known") return null; + const review = mergeRequest.thread.findLast( + (event) => event.kind === "review" && event.subject_ref === mergeRequest.source.ref, + ); + if (!review || review.kind !== "review") return null; + const revoked = mergeRequest.thread.some( + (event) => event.kind === "review_revoked" && event.review_event_id === review.event_id, + ); + return revoked ? null : review; + }); + const mergeEvent = $derived( + mergeRequest?.thread.findLast((event) => event.kind === "merge") ?? null, + ); let editing = $state(false); let editTitle = $state(loadedTicket.title); let editBody = $state(loadedTicket.body); @@ -353,29 +400,38 @@ {#if data.mergeRequest.error}

{data.mergeRequest.error}

{:else if mergeRequest} -

{mergeRequest.state} · {mergeRequest.review_status}

-

Target {mergeRequest.target_ref_selector ?? "unknown"} · {mergeRequest.target_status}

- {#if mergeRequest.observed_target_commit}

Target tip {mergeRequest.observed_target_commit}

{/if} -

{mergeRequest.current_revision.revision_id}

-

Head {mergeRequest.current_revision.head_commit}

- {#if mergeRequest.merged_result_commit} -

- Final merge · {mergeRequest.merge_strategy} / {mergeRequest.merge_resolution} -

-

- Target before {mergeRequest.merged_target_commit} · result - {mergeRequest.merged_result_commit} -

-

- Revision {mergeRequest.merged_revision_id} · completed by - {mergeRequest.merged_by_runtime_id}/{mergeRequest.merged_by_worker_id} -

+

{mergeRequest.state}

+

From {mergeRequest.selector_from ?? "requires repair"} · {mergeRequest.source.status}{mergeRequest.source.ref ? ` @ ${mergeRequest.source.ref}` : ""}

+

To {mergeRequest.selector_to} · {mergeRequest.target.status}{mergeRequest.target.ref ? ` @ ${mergeRequest.target.ref}` : ""}

+ {#if currentReviewRequest?.kind === "review_requested"} +

Review requested for {currentReviewRequest.subject_ref}

{/if} - {#if mergeRequest.current_revision.summary}

{mergeRequest.current_revision.summary}

{/if} - {#if mergeRequest.current_review} -

{mergeRequest.current_review.decision} by {mergeRequest.current_review.reviewer_effective_profile}

- {#if mergeRequest.current_review.body}{/if} + {#if currentReview?.kind === "review"} +

{currentReview.decision} by {currentReview.reviewer.runtime_id}/{currentReview.reviewer.worker_id}

+ {#if currentReview.body}{/if} {/if} + {#if mergeEvent?.kind === "merge"} +

Final merge · {mergeEvent.strategy} / {mergeEvent.resolution}

+

Target ref {mergeEvent.target_ref_after}

+

Completed by {mergeEvent.merged_by.runtime_id}/{mergeEvent.merged_by.worker_id}

+ {/if} +

Thread

+ {#each mergeRequest.thread as event (event.sequence)} +

+ #{event.sequence} · {event.kind} + {#if event.kind === "review_requested"} + · {event.subject_ref} · {event.requested_by.runtime_id}/{event.requested_by.worker_id} + {:else if event.kind === "review"} + · {event.subject_ref} · {event.reviewer.runtime_id}/{event.reviewer.worker_id} + {:else if event.kind === "comment"} + · {event.author.runtime_id}/{event.author.worker_id} · {event.body} + {:else if event.kind === "review_cancelled" || event.kind === "review_revoked"} + · {event.reason} + {:else if event.kind === "merge"} + · approval {event.approval_event_id} + {/if} +

+ {/each} {:else}

The assigned Coder has not opened a Merge Request.

{/if}