fix: expose provider ref blockers without failing reads

This commit is contained in:
2026-09-03 14:25:51 +09:00
parent 2884c08466
commit da14c82f71
3 changed files with 347 additions and 89 deletions
@@ -2740,6 +2740,89 @@ mod tests {
); );
} }
#[test]
fn repository_ref_observation_ignores_unpublished_and_stale_workdir_or_cache_refs() {
let seed = create_clean_repo();
let layout = tempfile::tempdir().unwrap();
let provider = layout.path().join("provider.git");
git(
layout.path(),
&[
"clone",
"--bare",
seed.path().to_str().unwrap(),
provider.to_str().unwrap(),
],
);
let cache = layout.path().join("cache");
git(
layout.path(),
&["clone", provider.to_str().unwrap(), cache.to_str().unwrap()],
);
let workdir = layout.path().join("workdir");
git(
layout.path(),
&[
"clone",
provider.to_str().unwrap(),
workdir.to_str().unwrap(),
],
);
git(&workdir, &["config", "user.name", "Yoi Test"]);
git(&workdir, &["config", "user.email", "yoi@example.com"]);
git(&workdir, &["switch", "-c", "published-source"]);
fs::write(workdir.join("source.txt"), "first\n").unwrap();
git(&workdir, &["add", "source.txt"]);
git(&workdir, &["commit", "-m", "source first"]);
let runtime_root = tempfile::tempdir().unwrap();
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
let repository = request(&provider).repository;
let observation_request = RepositoryRefObservationRequest {
repository,
selector: "refs/heads/published-source".to_string(),
materialization: None,
};
assert_eq!(
materializer
.observe_repository_ref(&observation_request)
.unwrap_err()
.code,
"repository_ref_not_found"
);
git(
&workdir,
&["push", "origin", "HEAD:refs/heads/published-source"],
);
let first = materializer
.observe_repository_ref(&observation_request)
.unwrap();
fs::write(workdir.join("source.txt"), "second\n").unwrap();
git(&workdir, &["add", "source.txt"]);
git(&workdir, &["commit", "-m", "source second"]);
let unpublished_second = git_stdout(&workdir, ["rev-parse", "HEAD"]).unwrap();
let still_first = materializer
.observe_repository_ref(&observation_request)
.unwrap();
assert_eq!(still_first.revision_ref, first.revision_ref);
assert_ne!(still_first.revision_ref, unpublished_second);
git(
&workdir,
&["push", "origin", "HEAD:refs/heads/published-source"],
);
let second = materializer
.observe_repository_ref(&observation_request)
.unwrap();
assert_eq!(second.revision_ref, unpublished_second);
assert_ne!(second.revision_ref, first.revision_ref);
assert_ne!(
git_stdout(&cache, ["rev-parse", "HEAD"]).unwrap(),
second.revision_ref
);
}
#[test] #[test]
fn repository_ref_observation_rejects_missing_and_non_branch_selectors() { fn repository_ref_observation_rejects_missing_and_non_branch_selectors() {
let repo = create_clean_repo(); let repo = create_clean_repo();
+9
View File
@@ -314,12 +314,21 @@ pub struct TicketMergeRequestSummary {
pub review_excerpt: Option<String>, pub review_excerpt: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct MergeRequestRefDiagnostic {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct MergeRequestListItem { pub struct MergeRequestListItem {
pub summary: TicketMergeRequestSummary, pub summary: TicketMergeRequestSummary,
pub ticket_ids: Vec<String>, pub ticket_ids: Vec<String>,
pub thread_event_count: usize, pub thread_event_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ref_diagnostics: Vec<MergeRequestRefDiagnostic>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+255 -89
View File
@@ -118,9 +118,9 @@ use crate::observation::{
RuntimeObservationSource, RuntimeObservationSourceConfig, RuntimeObservationSource, RuntimeObservationSourceConfig,
}; };
use crate::records::{ use crate::records::{
MergeRequestListItem, MergeRequestListResponse, ObjectiveDetail, ObjectiveQueryRequest, MergeRequestListItem, MergeRequestListResponse, MergeRequestRefDiagnostic, ObjectiveDetail,
ObjectiveQueryResponse, ObjectiveShowRequest, ProjectRecordList, TicketDetail, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveShowRequest, ProjectRecordList,
TicketQueryRequest, TicketQueryResponse, TicketShowRequest, TicketDetail, TicketQueryRequest, TicketQueryResponse, TicketShowRequest,
}; };
use crate::repositories::{ use crate::repositories::{
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
@@ -5562,6 +5562,64 @@ fn observe_published_merge_ref(
.map_err(repository_ref_observation_error) .map_err(repository_ref_observation_error)
} }
fn source_ref_error_code(code: &str) -> String {
match code {
"repository_ref_not_found" => "source_ref_not_found".to_string(),
"repository_ref_provider_timeout" => "source_ref_provider_timeout".to_string(),
"repository_ref_provider_auth_failed" => "source_ref_provider_auth_failed".to_string(),
"repository_ref_response_invalid" => "source_ref_response_invalid".to_string(),
"repository_ref_selector_invalid" => "source_ref_selector_invalid".to_string(),
"repository_access_credential_expired" => "source_ref_credential_expired".to_string(),
"repository_access_credential_unavailable" => {
"source_ref_credential_unavailable".to_string()
}
"repository_access_credential_unauthorized" => {
"source_ref_credential_unauthorized".to_string()
}
"repository_access_credential_invalid" => "source_ref_credential_invalid".to_string(),
"repository_access_provider_unavailable" | "repository_ref_provider_unavailable" => {
"source_ref_provider_unavailable".to_string()
}
other => other.to_string(),
}
}
fn source_ref_readiness_blocker(code: &str) -> &'static str {
if code == "source_ref_not_found" {
"source_ref_not_found"
} else {
"source_ref_unavailable"
}
}
fn remap_source_ref_error(error: ApiError) -> ApiError {
let ApiError { error, diagnostics } = error;
match error {
Error::RuntimeOperationFailed {
runtime_id,
code,
message,
} => Error::RuntimeOperationFailed {
runtime_id,
code: source_ref_error_code(&code),
message,
}
.into(),
error => ApiError { error, diagnostics },
}
}
fn observe_published_source_ref(
api: &WorkspaceApi,
workspace_id: &str,
runtime_id: &str,
repository_id: &str,
selector: &str,
) -> ApiResult<RepositoryRefObservation> {
observe_published_merge_ref(api, workspace_id, runtime_id, repository_id, selector)
.map_err(remap_source_ref_error)
}
fn require_assigned_workdir_source( fn require_assigned_workdir_source(
api: &WorkspaceApi, api: &WorkspaceApi,
assignment: &crate::store::TicketCoderAssignmentRecord, assignment: &crate::store::TicketCoderAssignmentRecord,
@@ -5593,8 +5651,13 @@ fn require_assigned_workdir_source(
)) ))
})? })?
.summary; .summary;
validate_assigned_workdir_source(&workdir, repository_id, selector, revision_ref) validate_assigned_workdir_source(&workdir, repository_id, selector, revision_ref).map_err(
.map_err(Error::MergeRequest)?; |diagnostic| Error::RuntimeOperationFailed {
runtime_id: assignment.worker.runtime_id.clone(),
code: diagnostic.code,
message: diagnostic.message,
},
)?;
Ok(()) Ok(())
} }
@@ -5603,16 +5666,22 @@ fn validate_assigned_workdir_source(
repository_id: &str, repository_id: &str,
selector: &str, selector: &str,
revision_ref: &str, revision_ref: &str,
) -> std::result::Result<(), merge_request::MergeRequestError> { ) -> std::result::Result<(), worker_runtime::working_directory::WorkingDirectoryDiagnostic> {
if workdir.repository_id != repository_id { if workdir.repository_id != repository_id {
return Err(merge_request::MergeRequestError::Conflict( return Err(
"merge_request_source_workdir_repository_mismatch: current Coder Workdir belongs to a different Repository".into(), worker_runtime::working_directory::WorkingDirectoryDiagnostic {
)); code: "source_workdir_repository_mismatch".to_string(),
message: "Current Coder Workdir belongs to a different Repository".to_string(),
},
);
} }
if workdir.cleanliness.as_deref() != Some("clean") { if workdir.cleanliness.as_deref() != Some("clean") {
return Err(merge_request::MergeRequestError::Conflict( return Err(
"merge_request_source_workdir_dirty: current Coder Workdir must be clean".into(), worker_runtime::working_directory::WorkingDirectoryDiagnostic {
)); code: "source_workdir_dirty".to_string(),
message: "Current Coder Workdir must be clean".to_string(),
},
);
} }
let workdir_selector_matches = workdir let workdir_selector_matches = workdir
.current_selector .current_selector
@@ -5626,9 +5695,12 @@ fn validate_assigned_workdir_source(
.is_ok_and(|selector| selector == workdir_selector) .is_ok_and(|selector| selector == workdir_selector)
}); });
if !workdir_selector_matches || workdir.current_ref.as_deref() != Some(revision_ref) { if !workdir_selector_matches || workdir.current_ref.as_deref() != Some(revision_ref) {
return Err(merge_request::MergeRequestError::Conflict( return Err(
"merge_request_source_workdir_head_mismatch: current Coder Workdir selector and HEAD must match the provider-published source ref".into(), worker_runtime::working_directory::WorkingDirectoryDiagnostic {
)); code: "source_ref_revision_mismatch".to_string(),
message: "Current Coder Workdir selector and HEAD do not match the provider-published source ref".to_string(),
},
);
} }
Ok(()) Ok(())
} }
@@ -5752,6 +5824,8 @@ struct MergeRequestRefResponse {
#[serde(rename = "ref")] #[serde(rename = "ref")]
revision_ref: Option<String>, revision_ref: Option<String>,
observed_at: String, observed_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
diagnostic: Option<MergeRequestRefDiagnostic>,
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -5835,36 +5909,39 @@ async fn scoped_list_merge_requests(
.items .items
.into_iter() .into_iter()
.map(|merge_request| -> ApiResult<MergeRequestListItem> { .map(|merge_request| -> ApiResult<MergeRequestListItem> {
let current_subject_ref = let (current_subject_ref, ref_diagnostics) =
if merge_request.state == merge_request::MergeRequestState::Open { if merge_request.state == merge_request::MergeRequestState::Open {
match ( match (
merge_request.selector_from.as_deref(), merge_request.selector_from.as_deref(),
merge_request.ticket_ids.first(), merge_request.ticket_ids.first(),
) { ) {
(Some(selector), Some(ticket_id)) => { (Some(selector), Some(ticket_id)) => match api
let assignment = api .store
.store .get_current_ticket_coder_assignment(&workspace_id, ticket_id)?
.get_current_ticket_coder_assignment(&workspace_id, ticket_id)? {
.ok_or_else(|| { Some(assignment) => match observe_published_source_ref(
Error::TicketAssignmentConflict( &api,
"Open Merge Request has no current assigned Coder".into(), &workspace_id,
) &assignment.worker.runtime_id,
})?; &merge_request.repository_id,
Some( selector,
observe_published_merge_ref( ) {
&api, Ok(observation) => (Some(observation.revision_ref), Vec::new()),
&workspace_id, Err(error) => (None, vec![merge_ref_diagnostic(error)]),
&assignment.worker.runtime_id, },
&merge_request.repository_id, None => (
selector, None,
)? vec![MergeRequestRefDiagnostic {
.revision_ref, code: "source_ref_runtime_unavailable".to_string(),
) message: "No current Coder Runtime is available to observe the source ref"
} .to_string(),
_ => None, }],
),
},
_ => (None, Vec::new()),
} }
} else { } else {
None (None, Vec::new())
}; };
let repository_key = api let repository_key = api
.store .store
@@ -5881,6 +5958,7 @@ async fn scoped_list_merge_requests(
summary: merge_request_summary(merge_request, repository_key, current_subject_ref), summary: merge_request_summary(merge_request, repository_key, current_subject_ref),
ticket_ids, ticket_ids,
thread_event_count, thread_event_count,
ref_diagnostics,
}) })
}) })
.collect::<ApiResult<Vec<_>>>()?; .collect::<ApiResult<Vec<_>>>()?;
@@ -5890,6 +5968,42 @@ async fn scoped_list_merge_requests(
})) }))
} }
fn merge_ref_diagnostic(error: ApiError) -> MergeRequestRefDiagnostic {
let ApiError { error, diagnostics } = error;
diagnostics
.into_iter()
.next()
.map(|diagnostic| MergeRequestRefDiagnostic {
code: diagnostic.code,
message: diagnostic.message,
})
.unwrap_or_else(|| MergeRequestRefDiagnostic {
code: "merge_ref_observation_unavailable".to_string(),
message: sanitize_backend_error(&error.to_string()),
})
}
fn unknown_merge_ref(code: &str, message: &str) -> MergeRequestRefResponse {
MergeRequestRefResponse {
status: "unknown".to_string(),
revision_ref: None,
observed_at: Utc::now().to_rfc3339(),
diagnostic: Some(MergeRequestRefDiagnostic {
code: code.to_string(),
message: message.to_string(),
}),
}
}
fn unknown_merge_ref_response(error: ApiError) -> MergeRequestRefResponse {
MergeRequestRefResponse {
status: "unknown".to_string(),
revision_ref: None,
observed_at: Utc::now().to_rfc3339(),
diagnostic: Some(merge_ref_diagnostic(error)),
}
}
fn merge_ref_response(observation: RepositoryRefObservation) -> MergeRequestRefResponse { fn merge_ref_response(observation: RepositoryRefObservation) -> MergeRequestRefResponse {
let observed_at = let observed_at =
chrono::DateTime::<Utc>::from_timestamp(observation.observed_at_epoch_seconds as i64, 0) chrono::DateTime::<Utc>::from_timestamp(observation.observed_at_epoch_seconds as i64, 0)
@@ -5899,6 +6013,7 @@ fn merge_ref_response(observation: RepositoryRefObservation) -> MergeRequestRefR
status: "known".into(), status: "known".into(),
revision_ref: Some(observation.revision_ref), revision_ref: Some(observation.revision_ref),
observed_at, observed_at,
diagnostic: None,
} }
} }
@@ -5924,31 +6039,47 @@ async fn scoped_show_merge_request(
})?; })?;
let assignment = api let assignment = api
.store .store
.get_current_ticket_coder_assignment(&workspace_id, ticket_id)? .get_current_ticket_coder_assignment(&workspace_id, ticket_id)?;
.ok_or_else(|| { let source = match (mr.selector_from.as_deref(), assignment.as_ref()) {
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) (Some(selector), Some(assignment)) => {
})?; match observe_published_source_ref(
let source = match mr.selector_from.as_deref() { &api,
Some(selector) => merge_ref_response(observe_published_merge_ref( &workspace_id,
&assignment.worker.runtime_id,
&mr.repository_id,
selector,
) {
Ok(observation) => merge_ref_response(observation),
Err(error) => unknown_merge_ref_response(error),
}
}
(Some(_), None) => unknown_merge_ref(
"source_ref_runtime_unavailable",
"No current Coder Runtime is available to observe the source ref",
),
(None, _) => MergeRequestRefResponse {
status: "requires_repair".into(),
revision_ref: None,
observed_at: Utc::now().to_rfc3339(),
diagnostic: None,
},
};
let target = match assignment.as_ref() {
Some(assignment) => match observe_published_merge_ref(
&api, &api,
&workspace_id, &workspace_id,
&assignment.worker.runtime_id, &assignment.worker.runtime_id,
&mr.repository_id, &mr.repository_id,
selector, &mr.selector_to,
)?), ) {
None => MergeRequestRefResponse { Ok(observation) => merge_ref_response(observation),
status: "requires_repair".into(), Err(error) => unknown_merge_ref_response(error),
revision_ref: None,
observed_at: Utc::now().to_rfc3339(),
}, },
None => unknown_merge_ref(
"target_ref_runtime_unavailable",
"No current Coder Runtime is available to observe the target ref",
),
}; };
let target = merge_ref_response(observe_published_merge_ref(
&api,
&workspace_id,
&assignment.worker.runtime_id,
&mr.repository_id,
&mr.selector_to,
)?);
let linked_tickets = mr let linked_tickets = mr
.ticket_ids .ticket_ids
.iter() .iter()
@@ -5982,25 +6113,26 @@ async fn scoped_merge_request_readiness(
let mr = store.get(&workspace_id, &ticket_id)?; let mr = store.get(&workspace_id, &ticket_id)?;
let assignment = api let assignment = api
.store .store
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?;
.ok_or_else(|| { let (current_subject_ref, source_blocker) = match (mr.selector_from.as_deref(), assignment) {
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) (Some(selector), Some(assignment)) => match observe_published_source_ref(
})?; &api,
let current_subject_ref = mr &workspace_id,
.selector_from &assignment.worker.runtime_id,
.as_deref() &mr.repository_id,
.map(|selector| { selector,
observe_published_merge_ref( ) {
&api, Ok(observation) => (Some(observation.revision_ref), None),
&workspace_id, Err(error) => {
&assignment.worker.runtime_id, let diagnostic = merge_ref_diagnostic(error);
&mr.repository_id, let blocker = source_ref_readiness_blocker(&diagnostic.code);
selector, (None, Some(blocker.to_string()))
) }
.map(|observation| observation.revision_ref) },
}) (Some(_), None) => (None, Some("source_ref_unavailable".to_string())),
.transpose()?; (None, _) => (None, None),
Ok(Json(store.readiness(merge_request::ReadinessCheck { };
let mut report = store.readiness(merge_request::ReadinessCheck {
ticket_id, ticket_id,
current_subject_ref, current_subject_ref,
auth: merge_request::MergeRequestAuth { auth: merge_request::MergeRequestAuth {
@@ -6010,7 +6142,17 @@ async fn scoped_merge_request_readiness(
worker_id: String::new(), worker_id: String::new(),
assignment_id: String::new(), assignment_id: String::new(),
}, },
})?)) })?;
if let Some(blocker) = source_blocker {
report
.blockers
.retain(|current| current != "selector_unresolved");
if !report.blockers.contains(&blocker) {
report.blockers.push(blocker);
}
report.ready = false;
}
Ok(Json(report))
} }
async fn scoped_open_merge_request( async fn scoped_open_merge_request(
@@ -6050,7 +6192,7 @@ async fn scoped_open_merge_request(
) )
.into()); .into());
} }
let source_observation = observe_published_merge_ref( let source_observation = observe_published_source_ref(
&api, &api,
&workspace_id, &workspace_id,
&assignment.worker.runtime_id, &assignment.worker.runtime_id,
@@ -6135,7 +6277,7 @@ async fn scoped_repair_merge_request_selector(
.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 resolved_subject_ref = observe_published_merge_ref( let resolved_subject_ref = observe_published_source_ref(
&api, &api,
&workspace_id, &workspace_id,
&assignment.worker.runtime_id, &assignment.worker.runtime_id,
@@ -6210,7 +6352,7 @@ async fn scoped_register_merge_request_review_capability(
.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 source_observation = observe_published_merge_ref( let source_observation = observe_published_source_ref(
&api, &api,
&workspace_id, &workspace_id,
&assignment.worker.runtime_id, &assignment.worker.runtime_id,
@@ -6271,7 +6413,7 @@ async fn scoped_submit_merge_request_review(
.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 current_subject_ref = observe_published_merge_ref( let current_subject_ref = observe_published_source_ref(
&api, &api,
&workspace_id, &workspace_id,
&assignment.worker.runtime_id, &assignment.worker.runtime_id,
@@ -6382,7 +6524,7 @@ async fn scoped_complete_merge_request(
.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 current_source_ref = observe_published_merge_ref( let current_source_ref = observe_published_source_ref(
&api, &api,
&workspace_id, &workspace_id,
&assignment.worker.runtime_id, &assignment.worker.runtime_id,
@@ -16606,9 +16748,11 @@ mod tests {
"scoped_complete_merge_request", "scoped_complete_merge_request",
] { ] {
let handler = handler_source(source, handler); let handler = handler_source(source, handler);
assert!(handler.contains("observe_published_merge_ref(")); assert!(
handler.contains("observe_published_source_ref(")
|| handler.contains("observe_published_merge_ref(")
);
assert!(!handler.contains("repository_reader()")); assert!(!handler.contains("repository_reader()"));
assert!(!handler.contains("status: \"unknown\""));
} }
let submit = handler_source(source, "scoped_submit_merge_request_review"); let submit = handler_source(source, "scoped_submit_merge_request_review");
assert!( assert!(
@@ -16616,13 +16760,37 @@ mod tests {
.find("authorize_review_submission(") .find("authorize_review_submission(")
.is_some_and(|authorization| { .is_some_and(|authorization| {
submit submit
.find("observe_published_merge_ref(") .find("observe_published_source_ref(")
.is_some_and(|observation| authorization < observation) .is_some_and(|observation| authorization < observation)
}), }),
"review capability must be validated before provider access", "review capability must be validated before provider access",
); );
} }
#[test]
fn readiness_distinguishes_missing_source_from_unavailable_provider() {
assert_eq!(
source_ref_readiness_blocker("source_ref_not_found"),
"source_ref_not_found"
);
assert_eq!(
source_ref_error_code("repository_ref_not_found"),
"source_ref_not_found"
);
assert_eq!(
source_ref_error_code("repository_ref_provider_timeout"),
"source_ref_provider_timeout"
);
for code in [
"source_ref_provider_unavailable",
"source_ref_provider_timeout",
"source_ref_provider_auth_failed",
"source_ref_credential_expired",
] {
assert_eq!(source_ref_readiness_blocker(code), "source_ref_unavailable");
}
}
#[test] #[test]
fn assigned_workdir_must_be_clean_and_match_published_source() { fn assigned_workdir_must_be_clean_and_match_published_source() {
let mut workdir = worker_runtime::catalog::WorkingDirectorySummary { let mut workdir = worker_runtime::catalog::WorkingDirectorySummary {
@@ -16660,8 +16828,7 @@ mod tests {
"work/T-549", "work/T-549",
"abc123" "abc123"
), ),
Err(merge_request::MergeRequestError::Conflict(message)) Err(diagnostic) if diagnostic.code == "source_workdir_dirty"
if message.starts_with("merge_request_source_workdir_dirty:")
)); ));
workdir.cleanliness = Some("clean".to_string()); workdir.cleanliness = Some("clean".to_string());
workdir.current_ref = Some("different".to_string()); workdir.current_ref = Some("different".to_string());
@@ -16672,8 +16839,7 @@ mod tests {
"work/T-549", "work/T-549",
"abc123" "abc123"
), ),
Err(merge_request::MergeRequestError::Conflict(message)) Err(diagnostic) if diagnostic.code == "source_ref_revision_mismatch"
if message.starts_with("merge_request_source_workdir_head_mismatch:")
)); ));
} }