1424 lines
57 KiB
Rust
1424 lines
57 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
path::Path,
|
|
sync::{Arc, Mutex},
|
|
};
|
|
use thiserror::Error;
|
|
use uuid::Uuid;
|
|
|
|
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 {
|
|
Open,
|
|
Merged,
|
|
Closed,
|
|
}
|
|
impl MergeRequestState {
|
|
fn parse(v: &str) -> Result<Self, MergeRequestError> {
|
|
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,
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
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<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub path: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub line: Option<u32>,
|
|
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,
|
|
}
|
|
|
|
impl MergeRequestAuth {
|
|
fn actor(&self) -> WorkerIdentity {
|
|
WorkerIdentity {
|
|
runtime_id: self.runtime_id.clone(),
|
|
worker_id: self.worker_id.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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<Utc>,
|
|
}
|
|
#[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<ReviewFinding>,
|
|
pub reviewer: WorkerIdentity,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
#[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<Utc>,
|
|
}
|
|
#[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<Utc>,
|
|
}
|
|
#[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<Utc>,
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum MergeStrategy {
|
|
FastForward,
|
|
Merge,
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ConflictResolution {
|
|
None,
|
|
Clean,
|
|
ConflictsResolved,
|
|
}
|
|
#[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<Utc>,
|
|
}
|
|
#[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::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 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 MergeRequest {
|
|
pub workspace_id: String,
|
|
pub merge_request_id: String,
|
|
pub repository_id: String,
|
|
pub state: MergeRequestState,
|
|
pub selector_from: Option<String>,
|
|
pub selector_to: String,
|
|
pub ticket_ids: Vec<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
#[serde(default)]
|
|
pub thread: Vec<MergeRequestThreadEvent>,
|
|
}
|
|
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)]
|
|
pub struct OpenMergeRequest {
|
|
pub merge_request_id: String,
|
|
pub ticket_id: String,
|
|
pub repository_id: String,
|
|
pub selector_from: String,
|
|
pub selector_to: String,
|
|
pub summary: String,
|
|
pub auth: MergeRequestAuth,
|
|
pub now: DateTime<Utc>,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct RequestMergeRequestReview {
|
|
pub ticket_id: String,
|
|
pub subject_ref: String,
|
|
pub child_session_id: String,
|
|
pub capability_token: String,
|
|
pub auth: MergeRequestAuth,
|
|
pub now: DateTime<Utc>,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
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<Utc>,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct SubmitMergeRequestReview {
|
|
pub ticket_id: String,
|
|
pub current_subject_ref: String,
|
|
pub capability_token: String,
|
|
pub decision: ReviewDecision,
|
|
pub body: String,
|
|
pub findings: Vec<ReviewFinding>,
|
|
pub now: DateTime<Utc>,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct RevokeMergeRequestReview {
|
|
pub ticket_id: String,
|
|
pub review_event_id: String,
|
|
pub reason: String,
|
|
pub auth: MergeRequestAuth,
|
|
pub now: DateTime<Utc>,
|
|
}
|
|
#[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<Utc>,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct ReadinessCheck {
|
|
pub ticket_id: String,
|
|
pub current_subject_ref: Option<String>,
|
|
pub auth: MergeRequestAuth,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ReadinessReport {
|
|
pub ready: bool,
|
|
pub blockers: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub subject_ref: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub review: Option<ReviewEvent>,
|
|
}
|
|
#[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<Utc>,
|
|
}
|
|
#[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<Option<CurrentAssignment>, String>;
|
|
}
|
|
pub trait RepositorySource: Send + Sync {
|
|
fn repository_belongs_to_workspace(
|
|
&self,
|
|
workspace_id: &str,
|
|
repository_id: &str,
|
|
) -> Result<bool, String>;
|
|
}
|
|
#[derive(Debug, Error)]
|
|
pub enum MergeRequestError {
|
|
#[error("Merge Request not found")]
|
|
NotFound,
|
|
#[error("Merge Request conflict: {0}")]
|
|
Conflict(String),
|
|
#[error("Merge Request unauthorized: {0}")]
|
|
Unauthorized(String),
|
|
#[error("Merge Request is not ready: {0}")]
|
|
NotReady(String),
|
|
#[error("Merge Request validation failed: {0}")]
|
|
Validation(String),
|
|
#[error("Merge Request operation failed: {0}")]
|
|
Operation(String),
|
|
#[error("Merge Request storage is corrupt: {0}")]
|
|
Corrupt(String),
|
|
#[error("Merge Request storage error: {0}")]
|
|
Storage(#[from] rusqlite::Error),
|
|
}
|
|
|
|
pub struct MergeRequestStore {
|
|
conn: Arc<Mutex<Connection>>,
|
|
assignments: Arc<dyn AssignmentSource>,
|
|
repositories: Arc<dyn RepositorySource>,
|
|
}
|
|
impl MergeRequestStore {
|
|
pub fn open(
|
|
path: impl AsRef<Path>,
|
|
assignments: Arc<dyn AssignmentSource>,
|
|
repositories: Arc<dyn RepositorySource>,
|
|
) -> Result<Self, MergeRequestError> {
|
|
let conn = Connection::open(path)?;
|
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
|
migrate(&conn)?;
|
|
Ok(Self {
|
|
conn: Arc::new(Mutex::new(conn)),
|
|
assignments,
|
|
repositories,
|
|
})
|
|
}
|
|
pub fn open_merge_request(
|
|
&self,
|
|
i: OpenMergeRequest,
|
|
) -> Result<MergeRequest, MergeRequestError> {
|
|
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(n, v)?
|
|
}
|
|
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 register_reviewer_child_session(
|
|
&self,
|
|
i: RegisterReviewerChildSession,
|
|
) -> Result<(), MergeRequestError> {
|
|
if i.reviewer_profile != "builtin:reviewer" {
|
|
return Err(MergeRequestError::Unauthorized(
|
|
"reviewer profile mismatch".into(),
|
|
));
|
|
}
|
|
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(())
|
|
}
|
|
pub fn request_review(
|
|
&self,
|
|
i: RequestMergeRequestReview,
|
|
) -> Result<RequestedMergeRequestReview, MergeRequestError> {
|
|
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 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(),
|
|
));
|
|
};
|
|
if p != "builtin:reviewer" {
|
|
return Err(MergeRequestError::Unauthorized(
|
|
"reviewer profile mismatch".into(),
|
|
));
|
|
}
|
|
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<ReviewEvent, MergeRequestError> {
|
|
bounded_body("review body", &i.body)?;
|
|
for finding in &i.findings {
|
|
bounded_body("review finding", &finding.body)?;
|
|
}
|
|
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<ReviewRevokedEvent, MergeRequestError> {
|
|
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<ReadinessReport, MergeRequestError> {
|
|
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())
|
|
}
|
|
_ => {}
|
|
}
|
|
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.repo(&i.auth, &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.approved_source_ref == i.current_subject_ref
|
|
&& existing.target_ref_before == i.target_ref_before
|
|
&& existing.target_ref_after == i.target_ref_after
|
|
&& existing.strategy == i.strategy
|
|
&& existing.resolution == i.resolution
|
|
&& existing.merged_by == i.auth.actor()
|
|
{
|
|
return Ok(());
|
|
}
|
|
return Err(MergeRequestError::Conflict(
|
|
"operation fingerprint mismatch".into(),
|
|
));
|
|
}
|
|
self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
|
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<String> = 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(())
|
|
}
|
|
|
|
pub fn complete(&self, i: CompleteMergeRequest) -> Result<MergeEvent, MergeRequestError> {
|
|
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.approved_source_ref == i.current_subject_ref
|
|
&& existing.target_ref_before == i.target_ref_before
|
|
&& existing.target_ref_after == i.target_ref_after
|
|
&& existing.strategy == i.strategy
|
|
&& existing.resolution == i.resolution
|
|
&& existing.merged_by == i.auth.actor()
|
|
{
|
|
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),
|
|
)?;
|
|
if !assignment_is_current {
|
|
return Err(MergeRequestError::Unauthorized(
|
|
"completion assignment changed before commit".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 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<String> = 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 released_assignment = transaction.execute(
|
|
"DELETE FROM ticket_current_worker_assignments
|
|
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3",
|
|
params![mr.workspace_id, i.ticket_id, i.auth.assignment_id],
|
|
)?;
|
|
if released_assignment != 1 {
|
|
return Err(MergeRequestError::Unauthorized(
|
|
"completion assignment changed while closing Ticket".into(),
|
|
));
|
|
}
|
|
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::<Result<Vec<_>, _>>()?
|
|
};
|
|
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)
|
|
}
|
|
|
|
pub fn repair_selector_from(
|
|
&self,
|
|
i: RepairSelectorFrom,
|
|
) -> Result<MergeRequest, MergeRequestError> {
|
|
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)
|
|
}
|
|
pub fn get(&self, ws: &str, ticket: &str) -> Result<MergeRequest, MergeRequestError> {
|
|
let c = self.lock()?;
|
|
let id:Option<String>=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),
|
|
}
|
|
}
|
|
pub fn thread_page(
|
|
&self,
|
|
ws: &str,
|
|
ticket: &str,
|
|
after: Option<u64>,
|
|
limit: usize,
|
|
) -> Result<Vec<MergeRequestThreadEvent>, MergeRequestError> {
|
|
let mr = self.get(ws, ticket)?;
|
|
let c = self.lock()?;
|
|
load_thread(&c, ws, &mr.merge_request_id, after, limit.clamp(1, 200))
|
|
}
|
|
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(())
|
|
}
|
|
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(())
|
|
}
|
|
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(())
|
|
}
|
|
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>, MergeRequestError> {
|
|
self.conn
|
|
.lock()
|
|
.map_err(|_| MergeRequestError::Operation("database lock poisoned".into()))
|
|
}
|
|
}
|
|
|
|
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<u64, MergeRequestError> {
|
|
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: Serialize>(
|
|
t: &Transaction<'_>,
|
|
w: &str,
|
|
m: &str,
|
|
k: &str,
|
|
e: &T,
|
|
at: DateTime<Utc>,
|
|
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<Option<MergeRequest>, MergeRequestError> {
|
|
let merge_request_id: Option<String> = 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_mr(c: &Connection, w: &str, m: &str) -> Result<Option<MergeRequest>, MergeRequestError> {
|
|
let row:Option<(String,String,Option<String>,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 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::<Result<Vec<String>, _>>()?;
|
|
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 load_thread(
|
|
c: &Connection,
|
|
w: &str,
|
|
m: &str,
|
|
after: Option<u64>,
|
|
limit: usize,
|
|
) -> Result<Vec<MergeRequestThreadEvent>, 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::<Result<Vec<_>, _>>()?;
|
|
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 json<T: for<'a> Deserialize<'a>>(v: &str) -> Result<T, MergeRequestError> {
|
|
serde_json::from_str(v).map_err(|e| MergeRequestError::Corrupt(e.to_string()))
|
|
}
|
|
fn time(v: &str) -> Result<DateTime<Utc>, 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(())
|
|
}
|
|
|
|
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<Option<i64>, 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<String>>(8)?,
|
|
r.get::<_, String>(9)?,
|
|
))
|
|
})?
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
};
|
|
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::<Result<Vec<_>, _>>()?
|
|
};
|
|
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<String>>(3)?,
|
|
r.get::<_, Option<String>>(4)?,
|
|
r.get::<_, Option<String>>(5)?,
|
|
r.get::<_, Option<String>>(6)?,
|
|
r.get::<_, Option<String>>(7)?,
|
|
r.get::<_, Option<String>>(8)?,
|
|
r.get::<_, Option<String>>(9)?,
|
|
r.get::<_, String>(10)?,
|
|
r.get::<_, String>(11)?,
|
|
))
|
|
})?
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
};
|
|
for (
|
|
ws,
|
|
op,
|
|
_ticket,
|
|
target,
|
|
source,
|
|
result,
|
|
strategy,
|
|
resolution,
|
|
runtime,
|
|
worker,
|
|
updated,
|
|
mr,
|
|
) in completed
|
|
{
|
|
let subject = source.ok_or_else(|| {
|
|
MergeRequestError::Operation(format!("completed operation {op} lacks source evidence"))
|
|
})?;
|
|
let approval:Option<String>=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 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(())
|
|
}
|