Support multiple merge requests per ticket

This commit is contained in:
2026-09-29 01:08:46 +09:00
parent ac7a6027bd
commit 590abf3142
26 changed files with 1539 additions and 723 deletions
+130 -103
View File
@@ -2,8 +2,7 @@ use std::{path::PathBuf, sync::Arc};
use chrono::{DateTime, Utc};
use merge_request::{
MergeRequest, MergeRequestError, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent,
ReviewDecision,
MergeRequest, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, ReviewDecision,
};
use project_record::{allocate_record_id, unix_epoch_millis_now};
use rusqlite::{params_from_iter, types::Value as SqlValue};
@@ -856,8 +855,8 @@ impl SqliteWorkspaceAuthority {
})
})
.collect::<Result<Vec<_>>>()?;
let write_repository_key = ticket_write_target_repository_key(&targets);
let has_target = write_repository_key.is_some();
let write_repository_keys = ticket_write_target_repository_keys(&targets);
let has_target = !write_repository_keys.is_empty();
let has_blockers = !ticket.relations.blockers.is_empty();
let mut queue_assignment_blockers = Vec::new();
for ticket_id in &dependency_check.queue_tickets {
@@ -929,8 +928,12 @@ impl SqliteWorkspaceAuthority {
queue_tickets: dependency_check.queue_tickets.clone(),
blockers: action_blockers,
};
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
Ok(request) => {
let merge_requests = self
.merge_request_store
.list_for_ticket(&self.workspace_id, id)
.map_err(|error| Error::Store(error.to_string()))?
.into_iter()
.map(|request| {
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
self.merge_revision_source
.resolve_subject_ref(&request.repository_id, selector)
@@ -940,17 +943,16 @@ impl SqliteWorkspaceAuthority {
.get_repository(&self.workspace_id, &request.repository_id)?
.map(|repository| repository.repository_key)
.ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?;
Some(merge_request_summary(
Ok(merge_request_summary(
request,
repository_key,
current_subject_ref,
))
}
Err(MergeRequestError::NotFound) => None,
Err(error) => return Err(Error::Store(error.to_string())),
};
})
.collect::<Result<Vec<_>>>()?;
let merge_request = (merge_requests.len() == 1).then(|| merge_requests[0].clone());
let evidence =
ticket_evidence_summary(write_repository_key, &ticket.events, merge_request.as_ref());
ticket_evidence_summary(&write_repository_keys, &ticket.events, &merge_requests);
let item_revision = ticket
.events
.iter()
@@ -1031,6 +1033,7 @@ impl SqliteWorkspaceAuthority {
current_coder,
assignment_diagnostics,
action_eligibility,
merge_requests,
merge_request,
evidence,
resolution: ticket
@@ -1850,81 +1853,93 @@ fn event_timestamp(event: &TicketEvent) -> Option<DateTime<Utc>> {
.map(|value| value.with_timezone(&Utc))
}
fn ticket_write_target_repository_key(targets: &[ticket::TicketTarget]) -> Option<&str> {
let mut write_targets = targets
fn ticket_write_target_repository_keys(targets: &[ticket::TicketTarget]) -> Vec<&str> {
targets
.iter()
.filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite);
let write_target = write_targets.next()?;
write_targets
.next()
.is_none()
.then_some(write_target.repository_key.as_str())
.filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite)
.map(|target| target.repository_key.as_str())
.collect()
}
fn ticket_evidence_summary(
ticket_write_repository_key: Option<&str>,
ticket_write_repository_keys: &[&str],
events: &[TicketEvent],
merge_request: Option<&TicketMergeRequestSummary>,
merge_requests: &[TicketMergeRequestSummary],
) -> TicketEvidenceSummary {
let linked_merge_request = merge_request.filter(|request| {
request.state == "open"
&& ticket_write_repository_key
.is_some_and(|repository_key| repository_key == request.repository_key)
});
let has_merge_request = linked_merge_request.is_some();
let has_current_subject_ref = linked_merge_request.is_some_and(|request| {
request
.current_subject_ref
.as_deref()
.is_some_and(|subject_ref| !subject_ref.is_empty())
});
let has_review_request = linked_merge_request.is_some_and(|request| {
request
.review_subject_ref
.as_deref()
.is_some_and(|subject_ref| !subject_ref.is_empty())
});
let relevant = merge_requests
.iter()
.filter(|request| {
request.state != "closed"
&& ticket_write_repository_keys.contains(&request.repository_key.as_str())
})
.collect::<Vec<_>>();
let has_merge_request = !relevant.is_empty();
let has_current_subject_ref = has_merge_request
&& relevant.iter().all(|request| {
request.state == "merged"
|| request
.current_subject_ref
.as_deref()
.is_some_and(|subject_ref| !subject_ref.is_empty())
});
let has_review_request = has_merge_request
&& relevant.iter().all(|request| {
request.state == "merged"
|| request
.review_subject_ref
.as_deref()
.is_some_and(|subject_ref| !subject_ref.is_empty())
});
let has_commit = has_current_subject_ref;
let review_status = linked_merge_request.map(|request| request.review_status.clone());
let approved_current_subject = review_status.as_deref() == Some("approved");
let unresolved_request_changes = review_status.as_deref() == Some("changes_requested");
let unresolved_request_changes = relevant
.iter()
.any(|request| request.review_status == "changes_requested");
let approved_current_subject = has_merge_request
&& relevant
.iter()
.all(|request| request.state == "merged" || request.review_status == "approved");
let review_status = if !has_merge_request {
None
} else if unresolved_request_changes {
Some("changes_requested".to_string())
} else if approved_current_subject {
Some("approved".to_string())
} else {
Some("pending".to_string())
};
let latest_rescope = events
.iter()
.filter(|event| substantive_item_edit(event))
.last();
let review_after_rescope = approved_current_subject
&& latest_rescope.is_none_or(|rescope| {
let Some(rescope_at) = event_timestamp(rescope) else {
return false;
};
let Some(requested_at) = linked_merge_request
.and_then(|request| request.review_requested_at.as_deref())
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.with_timezone(&Utc))
else {
return false;
};
let Some(reviewed_at) = linked_merge_request
.and_then(|request| request.review_submitted_at.as_deref())
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.with_timezone(&Utc))
else {
return false;
};
requested_at > rescope_at && reviewed_at > rescope_at
&& relevant.iter().all(|request| {
latest_rescope.is_none_or(|rescope| {
let Some(rescope_at) = event_timestamp(rescope) else {
return false;
};
let Some(requested_at) = request
.review_requested_at
.as_deref()
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.with_timezone(&Utc))
else {
return false;
};
let Some(reviewed_at) = request
.review_submitted_at
.as_deref()
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.with_timezone(&Utc))
else {
return false;
};
requested_at > rescope_at && reviewed_at > rescope_at
})
});
let mut missing = Vec::new();
match merge_request {
None => missing.push("merge_request".to_string()),
Some(request) if request.state != "open" => missing.push("open_merge_request".to_string()),
Some(request)
if ticket_write_repository_key
.is_none_or(|repository_key| repository_key != request.repository_key) =>
{
missing.push("merge_request_repository".to_string())
}
Some(_) => {}
if !has_merge_request {
missing.push("merge_request".to_string());
}
if !has_current_subject_ref {
missing.push("current_subject_ref".to_string());
@@ -2349,6 +2364,7 @@ fn ticket_query_item(
unresolved_blocker_count: detail.relations.blockers.len(),
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
evidence: detail.evidence.clone(),
merge_requests: detail.merge_requests.clone(),
merge_request: detail.merge_request.clone(),
current_coder: detail.current_coder.clone(),
}
@@ -2789,6 +2805,7 @@ mod tests {
event_id: "request-1".to_string(),
sequence: 1,
subject_ref: "commit-1".to_string(),
ticket_item_revision: "revision-1".to_string(),
requested_by: reviewer.clone(),
reviewer: reviewer.clone(),
created_at: requested_at,
@@ -2798,6 +2815,7 @@ mod tests {
sequence: 2,
request_event_id: "request-1".to_string(),
subject_ref: "commit-1".to_string(),
ticket_item_revision: "revision-1".to_string(),
decision,
body: "review body".to_string(),
findings: Vec::new(),
@@ -2879,33 +2897,39 @@ mod tests {
}
#[test]
fn ticket_target_projection_requires_exactly_one_write_target() {
fn ticket_target_projection_accepts_multiple_write_targets() {
let target = |repository_key: &str, access| ticket::TicketTarget {
repository_key: repository_key.to_string(),
ref_selector: Some("develop".to_string()),
access,
};
assert_eq!(ticket_write_target_repository_key(&[]), None);
assert_eq!(
ticket_write_target_repository_key(&[target(
assert!(ticket_write_target_repository_keys(&[]).is_empty());
assert!(
ticket_write_target_repository_keys(&[target(
"docs",
ticket::TicketTargetAccess::ReadOnly,
)]),
None,
)])
.is_empty()
);
let one_write = [
target("main", ticket::TicketTargetAccess::ReadWrite),
target("docs", ticket::TicketTargetAccess::ReadOnly),
];
assert_eq!(ticket_write_target_repository_key(&one_write), Some("main"),);
assert_eq!(
ticket_write_target_repository_keys(&one_write),
vec!["main"]
);
let two_writes = [
target("main", ticket::TicketTargetAccess::ReadWrite),
target("docs", ticket::TicketTargetAccess::ReadWrite),
];
assert_eq!(ticket_write_target_repository_key(&two_writes), None);
assert_eq!(
ticket_write_target_repository_keys(&two_writes),
vec!["main", "docs"]
);
}
#[test]
@@ -2915,15 +2939,12 @@ mod tests {
"docs".to_string(),
Some("commit-1".to_string()),
);
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&read_only_request));
let evidence =
ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&read_only_request));
assert!(!evidence.has_merge_request);
assert!(!evidence.complete_for_integration);
assert!(
evidence
.missing
.contains(&"merge_request_repository".to_string())
);
assert!(evidence.missing.contains(&"merge_request".to_string()));
}
#[test]
@@ -2933,19 +2954,19 @@ mod tests {
"main".to_string(),
Some("commit-1".to_string()),
);
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&approved));
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&approved));
assert!(evidence.complete_for_integration);
assert!(evidence.approved_current_subject);
assert!(evidence.review_after_rescope);
assert!(evidence.has_commit);
let with_audit_events = ticket_evidence_summary(
Some("main"),
&["main"],
&[
ticket_event("comment", "2026-01-01T00:05:00Z", None),
ticket_event("implementation_report", "2026-01-01T00:06:00Z", None),
],
Some(&approved),
std::slice::from_ref(&approved),
);
assert!(with_audit_events.complete_for_integration);
@@ -2954,7 +2975,7 @@ mod tests {
"main".to_string(),
Some("commit-1".to_string()),
);
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&revoked));
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&revoked));
assert!(!evidence.approved_current_subject);
assert!(!evidence.complete_for_integration);
@@ -2963,7 +2984,7 @@ mod tests {
"main".to_string(),
Some("commit-1".to_string()),
);
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&changes));
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&changes));
assert!(evidence.unresolved_request_changes);
assert!(!evidence.complete_for_integration);
}
@@ -2975,7 +2996,7 @@ mod tests {
"main".to_string(),
None,
);
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&unresolved));
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&unresolved));
assert!(!evidence.has_current_subject_ref);
assert!(!evidence.has_commit);
assert!(!evidence.complete_for_integration);
@@ -2987,10 +3008,10 @@ mod tests {
"main".to_string(),
Some("commit-1".to_string()),
);
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&closed));
let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&closed));
assert!(!evidence.has_merge_request);
assert!(!evidence.complete_for_integration);
assert!(evidence.missing.contains(&"open_merge_request".to_string()));
assert!(evidence.missing.contains(&"merge_request".to_string()));
}
#[test]
@@ -3001,38 +3022,38 @@ mod tests {
Some("commit-1".to_string()),
);
let fresh = ticket_evidence_summary(
Some("main"),
&["main"],
&[ticket_event(
"item_edit",
"2026-01-01T00:02:00Z",
Some("body"),
)],
Some(&approved),
std::slice::from_ref(&approved),
);
assert!(fresh.review_after_rescope);
assert!(fresh.complete_for_integration);
let stale = ticket_evidence_summary(
Some("main"),
&["main"],
&[ticket_event(
"item_edit",
"2026-01-01T00:05:00Z",
Some("title"),
)],
Some(&approved),
std::slice::from_ref(&approved),
);
assert!(!stale.review_after_rescope);
assert!(!stale.complete_for_integration);
assert!(stale.missing.contains(&"review_after_rescope".to_string()));
let metadata_only = ticket_evidence_summary(
Some("main"),
&["main"],
&[ticket_event(
"item_edit",
"2026-01-01T00:05:00Z",
Some("formatter"),
)],
Some(&approved),
std::slice::from_ref(&approved),
);
assert!(metadata_only.complete_for_integration);
}
@@ -3044,7 +3065,8 @@ mod tests {
"main".to_string(),
Some("commit-1".to_string()),
);
let approved = ticket_evidence_summary(Some("main"), &[], Some(&approved_summary));
let approved =
ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&approved_summary));
assert!(ticket_evidence_matches(&approved, "merge_request"));
assert!(ticket_evidence_matches(&approved, "commit"));
assert!(ticket_evidence_matches(&approved, "approved_review"));
@@ -3061,7 +3083,8 @@ mod tests {
"main".to_string(),
Some("commit-2".to_string()),
);
let pending = ticket_evidence_summary(Some("main"), &[], Some(&pending_summary));
let pending =
ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&pending_summary));
assert!(ticket_attention_matches(
"inprogress",
&pending,
@@ -3076,7 +3099,11 @@ mod tests {
"2026-01-01T00:05:00Z",
Some("targets"),
)];
let stale = ticket_evidence_summary(Some("main"), &stale_events, Some(&approved_summary));
let stale = ticket_evidence_summary(
&["main"],
&stale_events,
std::slice::from_ref(&approved_summary),
);
assert!(ticket_attention_matches(
"inprogress",
&stale,
+144 -46
View File
@@ -3673,9 +3673,9 @@ fn validated_ticket_implementation_targets(
.iter()
.filter(|target| target.access == TicketTargetAccess::ReadWrite)
.count();
if read_write_count != 1 {
if read_write_count == 0 {
return Err(Error::InvalidInput(format!(
"Ticket `{ticket_id}` requires exactly one read_write target, found {read_write_count}"
"Ticket `{ticket_id}` requires at least one read_write target"
))
.into());
}
@@ -3732,14 +3732,16 @@ fn require_ticket_read_write_target(
) -> ApiResult<ValidatedTicketImplementationTarget> {
let write_target = validated_ticket_implementation_targets(api, ticket_id)?
.into_iter()
.find(|target| target.access == TicketTargetAccess::ReadWrite)
.expect("validated Ticket target collection has exactly one read_write target");
if write_target.repository_id != repository_id || write_target.ref_selector != selector {
return Err(Error::InvalidInput(
"selectors must match the authoritative Ticket read_write repository target".into(),
)
.into());
}
.find(|target| {
target.access == TicketTargetAccess::ReadWrite
&& target.repository_id == repository_id
&& target.ref_selector == selector
})
.ok_or_else(|| {
ApiError::from(Error::InvalidInput(
"repository and selector must match a declared Ticket read_write target".into(),
))
})?;
Ok(write_target)
}
@@ -4398,6 +4400,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::merge_request_complete(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_complete(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_close_record(
service.clone(),
))
@@ -7769,6 +7774,25 @@ impl server_api::ServerApi for ServerApiContractService {
Ok(response)
}
async fn ticket_complete(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::CompleteTicketRequest,
) -> std::result::Result<server_api::TicketCompletionEvent, server_api::RepositoryApiError>
{
let Json(response) = scoped_complete_ticket(
State(self.workspace_api()?.clone()),
contract_request_headers(&context)?,
AxumPath((workspace_id, id)),
Json(request),
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(response)
}
async fn ticket_close_record(
&self,
context: server_api::ServerRequestContext,
@@ -12053,6 +12077,24 @@ fn resolve_workspace_worker_ticket_assignment(
Ok(())
}
fn merge_request_ticket_id(mr: &merge_request::MergeRequest) -> ApiResult<String> {
match mr.ticket_ids.as_slice() {
[ticket_id] => Ok(ticket_id.clone()),
[] => Err(
Error::MergeRequest(merge_request::MergeRequestError::Corrupt(
"Merge Request has no linked Ticket".into(),
))
.into(),
),
_ => Err(
Error::MergeRequest(merge_request::MergeRequestError::Conflict(
"Merge Request must be linked to exactly one workflow Ticket".into(),
))
.into(),
),
}
}
fn public_merge_request_state(
state: merge_request::MergeRequestState,
) -> server_api::MergeRequestState {
@@ -12124,6 +12166,7 @@ fn public_review_event(event: merge_request::ReviewEvent) -> server_api::ReviewE
sequence: event.sequence,
request_event_id: event.request_event_id,
subject_ref: event.subject_ref,
ticket_item_revision: event.ticket_item_revision,
decision: public_review_decision(event.decision),
body: event.body,
findings: event
@@ -12166,6 +12209,19 @@ fn public_merge_event(event: merge_request::MergeEvent) -> server_api::MergeEven
}
}
fn public_ticket_completion_event(
event: merge_request::TicketCompletionEvent,
) -> server_api::TicketCompletionEvent {
server_api::TicketCompletionEvent {
operation_id: event.operation_id,
ticket_id: event.ticket_id,
item_revision: event.item_revision,
merge_request_ids: event.merge_request_ids,
completed_by: public_merge_request_worker_identity(event.completed_by),
created_at: event.created_at.to_rfc3339(),
}
}
fn public_merge_strategy(strategy: merge_request::MergeStrategy) -> server_api::MergeStrategy {
match strategy {
merge_request::MergeStrategy::FastForward => server_api::MergeStrategy::FastForward,
@@ -12213,6 +12269,7 @@ fn public_merge_request_thread_event(
event_id: event.event_id,
sequence: event.sequence,
subject_ref: event.subject_ref,
ticket_item_revision: event.ticket_item_revision,
requested_by: public_merge_request_worker_identity(event.requested_by),
reviewer: public_merge_request_worker_identity(event.reviewer),
created_at: event.created_at.to_rfc3339(),
@@ -12527,12 +12584,12 @@ async fn scoped_show_merge_request(
async fn scoped_merge_request_readiness(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<server_api::MergeRequestReadinessResponse>> {
let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
let ticket_id = merge_request_ticket_id(&mr)?;
let assignment = api
.store
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?;
@@ -12555,6 +12612,7 @@ async fn scoped_merge_request_readiness(
(None, _) => (None, None),
};
let mut report = store.readiness(merge_request::ReadinessCheck {
merge_request_id,
ticket_id,
current_subject_ref,
auth: merge_request::MergeRequestAuth {
@@ -12657,14 +12715,13 @@ async fn scoped_open_merge_request(
async fn scoped_merge_request_thread(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
Query(query): Query<server_api::MergeRequestThreadQuery>,
) -> ApiResult<Json<Vec<server_api::MergeRequestThreadEvent>>> {
let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
let events = merge_request_store(&api, &workspace_id)?.thread_page(
let events = merge_request_store(&api, &workspace_id)?.thread_page_by_id(
&workspace_id,
&ticket_id,
&merge_request_id,
query.after,
query.limit.unwrap_or(100),
)?;
@@ -12679,11 +12736,10 @@ async fn scoped_merge_request_thread(
async fn scoped_repair_merge_request_selector(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
Json(input): Json<server_api::RepairMergeRequestSelectorRequest>,
) -> ApiResult<Json<server_api::PublicMergeRequest>> {
let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
require_workspace_access(&workspace_id, &api)?;
reject_non_browser_reopen_auth(&headers)?;
let _actor = require_actor(&ServerAuthApi::from(&api), &headers).await?;
@@ -12691,7 +12747,8 @@ async fn scoped_repair_merge_request_selector(
return Err(Error::BrowserReopenConfirmationRequired.into());
}
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
let ticket_id = merge_request_ticket_id(&mr)?;
let assignment = api
.store
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
@@ -12708,6 +12765,7 @@ async fn scoped_repair_merge_request_selector(
.revision_ref;
let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom {
workspace_id: workspace_id.clone(),
merge_request_id,
ticket_id,
selector_from: input.selector_from,
resolved_subject_ref,
@@ -12746,13 +12804,15 @@ async fn scoped_register_reviewer_child_session(
async fn scoped_register_merge_request_review_capability(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
Json(input): Json<server_api::RegisterMergeRequestReviewCapabilityRequest>,
) -> ApiResult<StatusCode> {
let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
require_workspace_access(&workspace_id, &api)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
let ticket_id = merge_request_ticket_id(&mr)?;
let assignment = api
.store
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
@@ -12767,8 +12827,6 @@ async fn scoped_register_merge_request_review_capability(
)
.into());
}
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let selector = mr
.selector_from
.as_deref()
@@ -12788,8 +12846,11 @@ async fn scoped_register_merge_request_review_capability(
&source_observation.revision_ref,
)?;
let subject_ref = source_observation.revision_ref;
let ticket_item_revision = api.authority.ticket(&ticket_id)?.item_revision;
store.request_review(merge_request::RequestMergeRequestReview {
merge_request_id,
ticket_id,
ticket_item_revision,
subject_ref,
child_session_id: input.child_session_id,
capability_token: input.capability_token,
@@ -12807,15 +12868,16 @@ async fn scoped_register_merge_request_review_capability(
async fn scoped_submit_merge_request_review(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
Json(input): Json<server_api::SubmitMergeRequestReviewRequest>,
) -> ApiResult<Json<server_api::ReviewEvent>> {
let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
let store = merge_request_store(&api, &workspace_id)?;
let review_authorization =
store.authorize_review_submission(&ticket_id, &input.capability_token)?;
if review_authorization.workspace_id != workspace_id {
store.authorize_review_submission(&merge_request_id, &input.capability_token)?;
if review_authorization.workspace_id != workspace_id
|| review_authorization.merge_request_id != merge_request_id
{
return Err(
Error::MergeRequest(merge_request::MergeRequestError::Unauthorized(
"review grant invalid".into(),
@@ -12823,7 +12885,8 @@ async fn scoped_submit_merge_request_review(
.into(),
);
}
let mr = store.get(&workspace_id, &ticket_id)?;
let ticket_id = review_authorization.ticket_id;
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
let selector = mr
.selector_from
.as_deref()
@@ -12849,6 +12912,7 @@ async fn scoped_submit_merge_request_review(
.map(internal_review_finding)
.collect();
let event = store.submit_review(merge_request::SubmitMergeRequestReview {
merge_request_id,
ticket_id,
current_subject_ref,
capability_token: input.capability_token,
@@ -12863,16 +12927,18 @@ async fn scoped_submit_merge_request_review(
async fn scoped_revoke_merge_request_review(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
Json(input): Json<server_api::RevokeMergeRequestReviewRequest>,
) -> ApiResult<Json<server_api::ReviewRevokedEvent>> {
let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_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 store = merge_request_store(&api, &workspace_id)?;
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
let ticket_id = merge_request_ticket_id(&mr)?;
let assignment = api
.store
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
@@ -12887,9 +12953,8 @@ async fn scoped_revoke_merge_request_review(
)
.into());
}
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let event = store.revoke_review(merge_request::RevokeMergeRequestReview {
merge_request_id,
ticket_id,
review_event_id: input.review_event_id,
reason: input.reason,
@@ -12908,18 +12973,19 @@ async fn scoped_revoke_merge_request_review(
async fn scoped_complete_merge_request(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
Json(input): Json<server_api::CompleteMergeRequestRequest>,
) -> ApiResult<Json<server_api::MergeEvent>> {
let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
require_workspace_access(&workspace_id, &api)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
require_online_workspace_orchestrator_source(&api, &source)?;
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
let ticket_id = merge_request_ticket_id(&mr)?;
if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) {
let replay = merge_request::CompleteMergeRequest {
merge_request_id,
ticket_id,
operation_id: input.operation_id,
approval_event_id: input.approval_event_id,
@@ -12974,6 +13040,7 @@ async fn scoped_complete_merge_request(
&input.target_ref_after,
)?;
let completion = merge_request::CompleteMergeRequest {
merge_request_id,
ticket_id,
operation_id: input.operation_id,
approval_event_id: input.approval_event_id,
@@ -12999,6 +13066,42 @@ async fn scoped_complete_merge_request(
.map_err(Into::into)
}
async fn scoped_complete_ticket(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
Json(input): Json<server_api::CompleteTicketRequest>,
) -> ApiResult<Json<server_api::TicketCompletionEvent>> {
let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
require_workspace_access(&workspace_id, &api)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
require_online_workspace_orchestrator_source(&api, &source)?;
let assignment = api
.store
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
.ok_or_else(|| {
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
})?;
let event = merge_request_store(&api, &workspace_id)?.complete_ticket(
merge_request::CompleteTicket {
ticket_id,
operation_id: input.operation_id,
item_revision: input.item_revision,
merge_request_ids: input.merge_request_ids,
auth: merge_request::MergeRequestAuth {
workspace_id,
repository_id: String::new(),
runtime_id: source.runtime_id,
worker_id: source.worker_id,
assignment_id: assignment.assignment_id,
},
now: Utc::now(),
},
)?;
Ok(Json(public_ticket_completion_event(event)))
}
fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> {
if headers.contains_key("authorization") {
return Err(Error::BrowserReopenConfirmationRequired);
@@ -25809,8 +25912,7 @@ mod tests {
#[test]
fn worker_projection_does_not_expose_unavailable_snapshot_as_current() {
let availability =
protocol::subscription::SubscriptionWorkerAvailability::Unavailable;
let availability = protocol::subscription::SubscriptionWorkerAvailability::Unavailable;
let worker_ref = RuntimeWorkerRef::new("runtime-a", "worker-a");
let projection = WorkerRegistryProjectionRecord {
registry: WorkerRegistryRecord {
@@ -25860,8 +25962,7 @@ mod tests {
let mut observed_stopped = projection;
let observation = observed_stopped.observation.as_mut().unwrap();
observation.availability =
protocol::subscription::SubscriptionWorkerAvailability::Observed;
observation.availability = protocol::subscription::SubscriptionWorkerAvailability::Observed;
observation.worker.availability =
protocol::subscription::SubscriptionWorkerAvailability::Observed;
observation.worker.state = protocol::subscription::SubscriptionWorkerState::Stopped;
@@ -26791,8 +26892,7 @@ mod tests {
identity: TEST_WORKSPACE_ID.to_string(),
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
},
availability:
protocol::subscription::SubscriptionWorkerAvailability::Observed,
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
state: "idle".to_string(),
worker_state: None,
last_seen_at: None,
@@ -38590,8 +38690,7 @@ mod tests {
identity: "workspace-test".to_string(),
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
},
availability:
protocol::subscription::SubscriptionWorkerAvailability::Observed,
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
state: "idle".to_string(),
worker_state: None,
last_seen_at: None,
@@ -38689,8 +38788,7 @@ mod tests {
identity: "workspace-test".to_string(),
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
},
availability:
protocol::subscription::SubscriptionWorkerAvailability::Observed,
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
state: "idle".to_string(),
worker_state: None,
last_seen_at: None,
+2 -2
View File
@@ -6883,9 +6883,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
params![record.workspace_id, record.ticket_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
if target_count == 0 || read_write_count != 1 {
if target_count == 0 || read_write_count == 0 {
return Err(Error::TicketAssignmentConflict(
"manual Coder assignment requires exactly one read_write repository target"
"manual Coder assignment requires at least one read_write repository target"
.to_string(),
));
}