Support multiple merge requests per ticket
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user