fix: require observed merge target completion

This commit is contained in:
2026-08-18 08:05:57 +09:00
parent 796fa2ee85
commit 9a548d2b5e
4 changed files with 148 additions and 116 deletions
+29 -1
View File
@@ -68,6 +68,15 @@ pub struct MergeRequestAuth {
pub assignment_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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewRequestedEvent { pub struct ReviewRequestedEvent {
pub event_id: String, pub event_id: String,
@@ -641,7 +650,7 @@ impl MergeRequestStore {
} }
pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> { pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> {
let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?;
self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?; self.repo(&i.auth, &mr.repository_id)?;
if let Some(existing) = mr.thread.iter().find_map(|event| match event { if let Some(existing) = mr.thread.iter().find_map(|event| match event {
MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => { MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => {
Some(value) Some(value)
@@ -649,8 +658,12 @@ impl MergeRequestStore {
_ => None, _ => None,
}) { }) {
if existing.approval_event_id == i.approval_event_id 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_before == i.target_ref_before
&& existing.target_ref_after == i.target_ref_after && 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 Ok(());
} }
@@ -658,6 +671,7 @@ impl MergeRequestStore {
"operation fingerprint mismatch".into(), "operation fingerprint mismatch".into(),
)); ));
} }
self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?;
if mr.state != MergeRequestState::Open { if mr.state != MergeRequestState::Open {
return Err(MergeRequestError::Conflict( return Err(MergeRequestError::Conflict(
"Merge Request is not open".into(), "Merge Request is not open".into(),
@@ -711,8 +725,12 @@ impl MergeRequestStore {
_ => None, _ => None,
}) { }) {
if existing.approval_event_id == i.approval_event_id 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_before == i.target_ref_before
&& existing.target_ref_after == i.target_ref_after && 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 Ok(existing.clone());
} }
@@ -767,6 +785,16 @@ impl MergeRequestStore {
WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'", WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'",
params![mr.workspace_id, i.ticket_id, i.now.to_rfc3339()], 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 issued_grants = {
let mut statement = transaction.prepare( let mut statement = transaction.prepare(
"SELECT request_event_id,subject_ref,capability_token "SELECT request_event_id,subject_ref,capability_token
+28 -1
View File
@@ -93,7 +93,7 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
} }
#[test] #[test]
fn selectors_thread_and_completion_have_no_revision_or_commit_api() { fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
let (_d, s) = fixture(); let (d, s) = fixture();
open(&s); open(&s);
let review = approve(&s, "opaque-source-ref", "token"); let review = approve(&s, "opaque-source-ref", "token");
let ready = s let ready = s
@@ -122,6 +122,33 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
let mr = s.get("W", "T").unwrap(); let mr = s.get("W", "T").unwrap();
assert_eq!(mr.selector_from.as_deref(), Some("work/t")); assert_eq!(mr.selector_from.as_deref(), Some("work/t"));
assert_eq!(mr.state, MergeRequestState::Merged); assert_eq!(mr.state, MergeRequestState::Merged);
let current_assignment: bool = Connection::open(d.path().join("db"))
.unwrap()
.query_row(
"SELECT EXISTS(
SELECT 1 FROM ticket_current_worker_assignments
WHERE workspace_id='W' AND ticket_id='T'
)",
[],
|row| row.get(0),
)
.unwrap();
assert!(!current_assignment);
let replayed = s
.complete(CompleteMergeRequest {
ticket_id: "T".into(),
operation_id: "op".into(),
approval_event_id: merged.approval_event_id.clone(),
current_subject_ref: merged.approved_source_ref.clone(),
target_ref_before: merged.target_ref_before.clone(),
target_ref_after: merged.target_ref_after.clone(),
strategy: merged.strategy,
resolution: merged.resolution,
auth: auth(),
now: at(6),
})
.unwrap();
assert_eq!(replayed, merged);
let json = serde_json::to_string(&mr).unwrap(); let json = serde_json::to_string(&mr).unwrap();
for banned in [ for banned in [
"revision_id", "revision_id",
+8 -79
View File
@@ -97,38 +97,13 @@ pub struct CommitObservation {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepositoryLookupError { pub enum RepositoryLookupError {
UnknownRepository { UnknownRepository { id: RepositoryId },
id: RepositoryId, UnsupportedProvider { id: RepositoryId, provider: String },
}, MissingDefaultSelector { id: RepositoryId },
UnsupportedProvider { InvalidSelector { id: RepositoryId, selector: String },
id: RepositoryId, CommitNotFound { id: RepositoryId, commit: String },
provider: String, InvalidCommitRelation { id: RepositoryId, detail: String },
}, ProviderFailure { id: RepositoryId, operation: String },
MissingDefaultSelector {
id: RepositoryId,
},
InvalidSelector {
id: RepositoryId,
selector: String,
},
CommitNotFound {
id: RepositoryId,
commit: String,
},
InvalidCommitRelation {
id: RepositoryId,
detail: String,
},
TargetMoved {
id: RepositoryId,
selector: String,
expected: String,
observed: Option<String>,
},
ProviderFailure {
id: RepositoryId,
operation: String,
},
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -306,45 +281,6 @@ impl RepositoryRegistryReader {
} }
} }
pub fn update_merge_target(
&self,
id: &str,
selector: &str,
expected_target: &str,
result_commit: &str,
) -> Result<(), RepositoryLookupError> {
let repository = self.merge_repository(id)?;
let target_ref = normalize_target_branch_selector(id, selector)?;
self.observe_commit(id, result_commit)?;
let status = Command::new("git")
.arg("-C")
.arg(&repository.path)
.args([
"update-ref",
target_ref.as_str(),
result_commit,
expected_target,
])
.status()
.map_err(|_| RepositoryLookupError::ProviderFailure {
id: id.into(),
operation: "guarded target update".into(),
})?;
if status.success() {
return Ok(());
}
let observed = self
.observe_merge_target(id, Some(selector))
.ok()
.map(|target| target.commit);
Err(RepositoryLookupError::TargetMoved {
id: id.into(),
selector: selector.into(),
expected: expected_target.into(),
observed,
})
}
fn merge_repository(&self, id: &str) -> Result<&ConfiguredRepository, RepositoryLookupError> { fn merge_repository(&self, id: &str) -> Result<&ConfiguredRepository, RepositoryLookupError> {
let repository = self let repository = self
.find(id) .find(id)
@@ -755,20 +691,13 @@ mod tests {
vec![base.clone()] vec![base.clone()]
); );
reader.ensure_ancestor("main", &base, &source).unwrap(); reader.ensure_ancestor("main", &base, &source).unwrap();
reader
.update_merge_target("main", "main", &base, &source)
.unwrap();
assert_eq!( assert_eq!(
reader reader
.observe_merge_target("main", Some("refs/heads/main")) .observe_merge_target("main", Some("refs/heads/main"))
.unwrap() .unwrap()
.commit, .commit,
source base
); );
assert!(matches!(
reader.update_merge_target("main", "refs/heads/main", &base, &base),
Err(RepositoryLookupError::TargetMoved { .. })
));
assert!(matches!( assert!(matches!(
reader.ensure_ancestor("main", &source, &base), reader.ensure_ancestor("main", &source, &base),
Err(RepositoryLookupError::InvalidCommitRelation { .. }) Err(RepositoryLookupError::InvalidCommitRelation { .. })
+83 -35
View File
@@ -3893,6 +3893,23 @@ fn repository_merge_evidence_error(error: RepositoryLookupError) -> ApiError {
.into() .into()
} }
fn require_completed_target_observation(
observed: &str,
target_ref_before: &str,
target_ref_after: &str,
) -> ApiResult<()> {
if observed == target_ref_after {
return Ok(());
}
if observed == target_ref_before {
return Err(Error::InvalidInput(
"target selector is still at target_ref_before; push the verified result from the Orchestrator Workdir before MergeRequestComplete".into(),
)
.into());
}
Err(Error::InvalidInput("target selector moved outside completion evidence".into()).into())
}
async fn scoped_show_merge_request( async fn scoped_show_merge_request(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
@@ -4226,19 +4243,55 @@ async fn scoped_complete_merge_request(
require_workspace_access(&workspace_id, &api)?; require_workspace_access(&workspace_id, &api)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
require_online_workspace_orchestrator_source(&api, &source)?; require_online_workspace_orchestrator_source(&api, &source)?;
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let repositories = api.repository_reader();
if let Some(existing) = mr.thread.iter().find_map(|event| match event {
merge_request::MergeRequestThreadEvent::Merge(event)
if event.operation_id == input.operation_id =>
{
Some(event)
}
_ => None,
}) {
let observed = repositories
.observe_merge_target(&mr.repository_id, Some(&mr.selector_to))
.map_err(repository_merge_evidence_error)?;
require_completed_target_observation(
&observed.commit,
&input.target_ref_before,
&input.target_ref_after,
)?;
let replay = merge_request::CompleteMergeRequest {
ticket_id,
operation_id: input.operation_id,
approval_event_id: input.approval_event_id,
current_subject_ref: existing.approved_source_ref.clone(),
target_ref_before: input.target_ref_before,
target_ref_after: input.target_ref_after,
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: String::new(),
},
now: Utc::now(),
};
return store.complete(replay).map(Json).map_err(Into::into);
}
let assignment = api let assignment = api
.store .store
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
.ok_or_else(|| { .ok_or_else(|| {
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
})?; })?;
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let selector = mr let selector = mr
.selector_from .selector_from
.as_deref() .as_deref()
.ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?;
let repositories = api.repository_reader();
let current_source_ref = repositories let current_source_ref = repositories
.observe_merge_target(&mr.repository_id, Some(selector)) .observe_merge_target(&mr.repository_id, Some(selector))
.map_err(repository_merge_evidence_error)? .map_err(repository_merge_evidence_error)?
@@ -4246,12 +4299,11 @@ async fn scoped_complete_merge_request(
let observed = repositories let observed = repositories
.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) .observe_merge_target(&mr.repository_id, Some(&mr.selector_to))
.map_err(repository_merge_evidence_error)?; .map_err(repository_merge_evidence_error)?;
if observed.commit != input.target_ref_before && observed.commit != input.target_ref_after { require_completed_target_observation(
return Err(Error::InvalidInput( &observed.commit,
"target selector moved outside completion evidence".into(), &input.target_ref_before,
) &input.target_ref_after,
.into()); )?;
}
let completion = merge_request::CompleteMergeRequest { let completion = merge_request::CompleteMergeRequest {
ticket_id, ticket_id,
operation_id: input.operation_id, operation_id: input.operation_id,
@@ -4271,31 +4323,7 @@ async fn scoped_complete_merge_request(
now: Utc::now(), now: Utc::now(),
}; };
store.validate_completion(&completion)?; store.validate_completion(&completion)?;
let already = observed.commit == input.target_ref_after; store.complete(completion).map(Json).map_err(Into::into)
if !already {
repositories
.update_merge_target(
&mr.repository_id,
&mr.selector_to,
&input.target_ref_before,
&input.target_ref_after,
)
.map_err(repository_merge_evidence_error)?
}
match store.complete(completion) {
Ok(v) => Ok(Json(v)),
Err(e) => {
if !already {
let _ = repositories.update_merge_target(
&mr.repository_id,
&mr.selector_to,
&input.target_ref_after,
&input.target_ref_before,
);
}
Err(e.into())
}
}
} }
fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> {
@@ -12443,7 +12471,7 @@ mod tests {
assert_eq!(builtin.definition.name, "coder-review"); assert_eq!(builtin.definition.name, "coder-review");
assert_eq!(builtin.selector.to_string(), "builtin:coder-review"); assert_eq!(builtin.selector.to_string(), "builtin:coder-review");
assert_eq!(builtin.flow_id, "builtin:coder-review"); assert_eq!(builtin.flow_id, "builtin:coder-review");
assert_eq!(builtin.revision, 2); assert_eq!(builtin.revision, 3);
assert_eq!( assert_eq!(
api.store api.store
.list_flow_sources(&api.config.workspace_id) .list_flow_sources(&api.config.workspace_id)
@@ -12604,6 +12632,26 @@ mod tests {
); );
} }
#[test]
fn merge_request_completion_records_only_an_observed_remote_target_update() {
require_completed_target_observation("after", "before", "after").unwrap();
let not_pushed =
require_completed_target_observation("before", "before", "after").unwrap_err();
assert!(matches!(
not_pushed.error,
Error::InvalidInput(ref message)
if message.contains("push the verified result from the Orchestrator Workdir")
));
let moved = require_completed_target_observation("other", "before", "after").unwrap_err();
assert!(matches!(
moved.error,
Error::InvalidInput(ref message)
if message.contains("moved outside completion evidence")
));
}
#[test] #[test]
fn worker_ticket_assignment_projects_coder_intent_and_run_acceptance() { fn worker_ticket_assignment_projects_coder_intent_and_run_acceptance() {
let initial_submit = vec![ let initial_submit = vec![