Bind ticket completion to current review evidence
This commit is contained in:
+159
-41
@@ -73,6 +73,11 @@ pub struct WorkerIdentity {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct MergeRequestReviewSubject {
|
||||
pub merge_request_id: String,
|
||||
pub subject_ref: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MergeRequestAuth {
|
||||
pub workspace_id: String,
|
||||
@@ -99,6 +104,8 @@ pub struct ReviewRequestedEvent {
|
||||
pub subject_ref: String,
|
||||
#[serde(default)]
|
||||
pub ticket_item_revision: String,
|
||||
#[serde(default)]
|
||||
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
pub requested_by: WorkerIdentity,
|
||||
pub reviewer: WorkerIdentity,
|
||||
pub created_at: DateTime<Utc>,
|
||||
@@ -112,6 +119,8 @@ pub struct ReviewEvent {
|
||||
pub subject_ref: String,
|
||||
#[serde(default)]
|
||||
pub ticket_item_revision: String,
|
||||
#[serde(default)]
|
||||
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
pub decision: ReviewDecision,
|
||||
pub body: String,
|
||||
pub findings: Vec<ReviewFinding>,
|
||||
@@ -266,6 +275,7 @@ pub struct RequestMergeRequestReview {
|
||||
pub merge_request_id: String,
|
||||
pub ticket_id: String,
|
||||
pub ticket_item_revision: String,
|
||||
pub ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
pub subject_ref: String,
|
||||
pub child_session_id: String,
|
||||
pub capability_token: String,
|
||||
@@ -360,6 +370,7 @@ pub struct CompleteTicket {
|
||||
pub operation_id: String,
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub requirement_approval_event_id: String,
|
||||
pub auth: MergeRequestAuth,
|
||||
pub now: DateTime<Utc>,
|
||||
}
|
||||
@@ -369,6 +380,7 @@ pub struct TicketCompletionEvent {
|
||||
pub ticket_id: String,
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub requirement_approval_event_id: String,
|
||||
pub completed_by: WorkerIdentity,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -527,15 +539,36 @@ impl MergeRequestStore {
|
||||
}
|
||||
pub fn request_review(
|
||||
&self,
|
||||
i: RequestMergeRequestReview,
|
||||
mut i: RequestMergeRequestReview,
|
||||
) -> Result<RequestedMergeRequestReview, MergeRequestError> {
|
||||
nonempty("subject_ref", &i.subject_ref)?;
|
||||
if i.ticket_merge_request_subjects.is_empty() {
|
||||
return Err(MergeRequestError::Validation(
|
||||
"review must bind the linked Merge Request subject set".into(),
|
||||
));
|
||||
}
|
||||
for subject in &i.ticket_merge_request_subjects {
|
||||
nonempty("merge_request_id", &subject.merge_request_id)?;
|
||||
nonempty("subject_ref", &subject.subject_ref)?;
|
||||
}
|
||||
i.ticket_merge_request_subjects
|
||||
.sort_by(|left, right| left.merge_request_id.cmp(&right.merge_request_id));
|
||||
if i.ticket_merge_request_subjects
|
||||
.windows(2)
|
||||
.any(|subjects| subjects[0].merge_request_id == subjects[1].merge_request_id)
|
||||
{
|
||||
return Err(MergeRequestError::Validation(
|
||||
"review Merge Request subject set must not contain duplicates".into(),
|
||||
));
|
||||
}
|
||||
if !i.ticket_merge_request_subjects.iter().any(|subject| {
|
||||
subject.merge_request_id == i.merge_request_id && subject.subject_ref == i.subject_ref
|
||||
}) {
|
||||
return Err(MergeRequestError::Validation(
|
||||
"review subject set does not contain the addressed Merge Request subject".into(),
|
||||
));
|
||||
}
|
||||
let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?;
|
||||
self.require_current_ticket_revision(
|
||||
&i.auth.workspace_id,
|
||||
&i.ticket_id,
|
||||
&i.ticket_item_revision,
|
||||
)?;
|
||||
self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||
if mr.state != MergeRequestState::Open || mr.selector_from.is_none() {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
@@ -544,6 +577,32 @@ impl MergeRequestStore {
|
||||
}
|
||||
let mut c = self.lock()?;
|
||||
let t = c.transaction()?;
|
||||
let current_revision = current_ticket_revision(&t, &i.auth.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if current_revision != i.ticket_item_revision {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket item revision changed before review request".into(),
|
||||
));
|
||||
}
|
||||
let current_mr = load_mr(&t, &i.auth.workspace_id, &i.merge_request_id)?
|
||||
.filter(|request| request.ticket_ids.iter().any(|id| id == &i.ticket_id))
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if current_mr.state != MergeRequestState::Open || current_mr.selector_from.is_none() {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Merge Request changed before review request".into(),
|
||||
));
|
||||
}
|
||||
let linked_ids = linked_merge_request_ids(&t, &i.auth.workspace_id, &i.ticket_id)?;
|
||||
let subject_ids = i
|
||||
.ticket_merge_request_subjects
|
||||
.iter()
|
||||
.map(|subject| subject.merge_request_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if linked_ids != subject_ids {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"linked Merge Request set changed before review request".into(),
|
||||
));
|
||||
}
|
||||
let child:Option<(String,String,String)>=t.query_row("SELECT parent_runtime_id,child_session_id,reviewer_profile FROM merge_request_reviewer_child_sessions WHERE workspace_id=?1 AND child_session_id=?2 AND parent_runtime_id=?3 AND parent_worker_id=?4 AND status='active'",params![i.auth.workspace_id,i.child_session_id,i.auth.runtime_id,i.auth.worker_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?))).optional()?;
|
||||
let Some((rr, rw, p)) = child else {
|
||||
return Err(MergeRequestError::Unauthorized(
|
||||
@@ -560,6 +619,7 @@ impl MergeRequestStore {
|
||||
sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?,
|
||||
subject_ref: i.subject_ref,
|
||||
ticket_item_revision: i.ticket_item_revision,
|
||||
ticket_merge_request_subjects: i.ticket_merge_request_subjects,
|
||||
requested_by: WorkerIdentity {
|
||||
runtime_id: i.auth.runtime_id,
|
||||
worker_id: i.auth.worker_id,
|
||||
@@ -654,11 +714,14 @@ impl MergeRequestStore {
|
||||
"review grant invalid".into(),
|
||||
));
|
||||
};
|
||||
let ticket_item_revision = load_mr(&t, &ws, &mr)?
|
||||
let (ticket_item_revision, ticket_merge_request_subjects) = load_mr(&t, &ws, &mr)?
|
||||
.and_then(|request| {
|
||||
request.thread.into_iter().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(event) if event.event_id == req => {
|
||||
Some(event.ticket_item_revision)
|
||||
Some((
|
||||
event.ticket_item_revision,
|
||||
event.ticket_merge_request_subjects,
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
@@ -690,6 +753,7 @@ impl MergeRequestStore {
|
||||
request_event_id: req,
|
||||
subject_ref: subject,
|
||||
ticket_item_revision,
|
||||
ticket_merge_request_subjects,
|
||||
decision: i.decision,
|
||||
body: i.body,
|
||||
findings: i.findings,
|
||||
@@ -760,6 +824,18 @@ impl MergeRequestStore {
|
||||
if mr.selector_from.is_none() {
|
||||
b.push("selector_from requires repair".into())
|
||||
}
|
||||
if let Some(review) = &review {
|
||||
let connection = self.lock()?;
|
||||
let current_revision =
|
||||
current_ticket_revision(&connection, &i.auth.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if review.ticket_item_revision != current_revision {
|
||||
b.push(
|
||||
"approval predates the current Ticket revision; fresh review is required"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
match (&i.current_subject_ref, &review) {
|
||||
(None, _) => b.push("selector_from could not be resolved".into()),
|
||||
(Some(subject_ref), None) => {
|
||||
@@ -840,8 +916,16 @@ impl MergeRequestStore {
|
||||
"target ref did not change".into(),
|
||||
));
|
||||
}
|
||||
let state: Option<String> = self
|
||||
.lock()?
|
||||
let connection = self.lock()?;
|
||||
let current_revision =
|
||||
current_ticket_revision(&connection, &mr.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if review.ticket_item_revision != current_revision {
|
||||
return Err(MergeRequestError::NotReady(
|
||||
"approval predates the current Ticket revision; fresh review is required".into(),
|
||||
));
|
||||
}
|
||||
let state: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![mr.workspace_id, i.ticket_id],
|
||||
@@ -913,6 +997,14 @@ impl MergeRequestStore {
|
||||
"current effective review does not approve the source ref".into(),
|
||||
));
|
||||
}
|
||||
let current_revision =
|
||||
current_ticket_revision(&transaction, &mr.workspace_id, &i.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if review.ticket_item_revision != current_revision {
|
||||
return Err(MergeRequestError::NotReady(
|
||||
"approval predates the current Ticket revision; fresh review is required".into(),
|
||||
));
|
||||
}
|
||||
let state: Option<String> = transaction
|
||||
.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
@@ -1008,6 +1100,10 @@ impl MergeRequestStore {
|
||||
) -> Result<TicketCompletionEvent, MergeRequestError> {
|
||||
nonempty("operation_id", &input.operation_id)?;
|
||||
nonempty("item_revision", &input.item_revision)?;
|
||||
nonempty(
|
||||
"requirement_approval_event_id",
|
||||
&input.requirement_approval_event_id,
|
||||
)?;
|
||||
if input.merge_request_ids.is_empty() {
|
||||
return Err(MergeRequestError::NotReady(
|
||||
"Ticket completion requires at least one linked Merge Request result".into(),
|
||||
@@ -1044,6 +1140,7 @@ impl MergeRequestStore {
|
||||
let event: TicketCompletionEvent = json(&payload)?;
|
||||
if event.item_revision == input.item_revision
|
||||
&& event.merge_request_ids == input.merge_request_ids
|
||||
&& event.requirement_approval_event_id == input.requirement_approval_event_id
|
||||
&& event.completed_by == input.auth.actor()
|
||||
{
|
||||
return Ok(event);
|
||||
@@ -1088,23 +1185,15 @@ impl MergeRequestStore {
|
||||
"Ticket completion assignment changed before commit".into(),
|
||||
));
|
||||
}
|
||||
let actual_ids = {
|
||||
let mut statement = transaction.prepare(
|
||||
"SELECT merge_request_id FROM merge_request_ticket_relations
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND relation_kind='implements'
|
||||
ORDER BY merge_request_id",
|
||||
)?;
|
||||
statement
|
||||
.query_map(params![input.auth.workspace_id, input.ticket_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
let actual_ids =
|
||||
linked_merge_request_ids(&transaction, &input.auth.workspace_id, &input.ticket_id)?;
|
||||
if actual_ids != input.merge_request_ids {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"linked Merge Request set changed; refresh Ticket completion evidence".into(),
|
||||
));
|
||||
}
|
||||
let mut requests = Vec::with_capacity(actual_ids.len());
|
||||
let mut merged_subjects = Vec::with_capacity(actual_ids.len());
|
||||
for merge_request_id in &actual_ids {
|
||||
let request = load_mr(&transaction, &input.auth.workspace_id, merge_request_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
@@ -1141,6 +1230,31 @@ impl MergeRequestStore {
|
||||
"Merge Request `{merge_request_id}` lacks approval bound to the current Ticket revision"
|
||||
)));
|
||||
}
|
||||
merged_subjects.push(MergeRequestReviewSubject {
|
||||
merge_request_id: merge_request_id.clone(),
|
||||
subject_ref: merge.approved_source_ref.clone(),
|
||||
});
|
||||
requests.push(request);
|
||||
}
|
||||
let has_requirement_attestation = requests.iter().any(|request| {
|
||||
request.thread.iter().rev().any(|event| {
|
||||
let MergeRequestThreadEvent::Review(review) = event else {
|
||||
return false;
|
||||
};
|
||||
review.event_id == input.requirement_approval_event_id
|
||||
&& review.decision == ReviewDecision::Approve
|
||||
&& review.ticket_item_revision == input.item_revision
|
||||
&& review.ticket_merge_request_subjects == merged_subjects
|
||||
&& request
|
||||
.effective_review(&review.subject_ref)
|
||||
.is_some_and(|effective| effective.event_id == review.event_id)
|
||||
})
|
||||
});
|
||||
if !has_requirement_attestation {
|
||||
return Err(MergeRequestError::NotReady(
|
||||
"no effective Reviewer approval attests the current Ticket revision and exact merged source set"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let updated = transaction.execute(
|
||||
"UPDATE typed_tickets
|
||||
@@ -1176,6 +1290,7 @@ impl MergeRequestStore {
|
||||
ticket_id: input.ticket_id.clone(),
|
||||
item_revision: input.item_revision,
|
||||
merge_request_ids: input.merge_request_ids,
|
||||
requirement_approval_event_id: input.requirement_approval_event_id,
|
||||
completed_by: input.auth.actor(),
|
||||
created_at: input.now,
|
||||
};
|
||||
@@ -1376,24 +1491,6 @@ impl MergeRequestStore {
|
||||
limit.clamp(1, 200),
|
||||
)
|
||||
}
|
||||
fn require_current_ticket_revision(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
expected: &str,
|
||||
) -> Result<(), MergeRequestError> {
|
||||
nonempty("ticket_item_revision", expected)?;
|
||||
let connection = self.lock()?;
|
||||
let current = current_ticket_revision(&connection, workspace_id, ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if current != expected {
|
||||
return Err(MergeRequestError::Conflict(format!(
|
||||
"Ticket item revision changed from `{expected}` to `{current}`; review the current requirements"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assigned(&self, a: &MergeRequestAuth, t: &str, r: &str) -> Result<(), MergeRequestError> {
|
||||
self.repo(a, r)?;
|
||||
let x = self
|
||||
@@ -1517,6 +1614,22 @@ fn insert_event<T: Serialize>(
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn linked_merge_request_ids(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<String>, MergeRequestError> {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT merge_request_id FROM merge_request_ticket_relations
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND relation_kind='implements'
|
||||
ORDER BY merge_request_id",
|
||||
)?;
|
||||
statement
|
||||
.query_map(params![workspace_id, ticket_id], |row| row.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn current_ticket_revision(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
@@ -1659,8 +1772,9 @@ fn ticket_completion_event(
|
||||
),
|
||||
event.created_at.to_rfc3339(),
|
||||
format!(
|
||||
"Current Ticket revision `{}` was approved and completed with Merge Requests: {}.",
|
||||
"Current Ticket revision `{}` was approved by Review `{}` and completed with Merge Requests: {}.",
|
||||
event.item_revision,
|
||||
event.requirement_approval_event_id,
|
||||
event.merge_request_ids.join(", ")
|
||||
)
|
||||
],
|
||||
@@ -1668,6 +1782,10 @@ fn ticket_completion_event(
|
||||
for (key, value) in [
|
||||
("operation_id", event.operation_id.clone()),
|
||||
("item_revision", event.item_revision.clone()),
|
||||
(
|
||||
"requirement_approval_event_id",
|
||||
event.requirement_approval_event_id.clone(),
|
||||
),
|
||||
(
|
||||
"merge_request_ids",
|
||||
serde_json::to_string(&event.merge_request_ids)
|
||||
|
||||
@@ -86,9 +86,30 @@ fn request_for(
|
||||
now: at(2),
|
||||
})
|
||||
.unwrap();
|
||||
let ticket_merge_request_subjects = s
|
||||
.list_for_ticket("W", "T")
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|request| {
|
||||
let other_subject = match request.merge_request_id.as_str() {
|
||||
"MR" => "subject-one",
|
||||
"MR-2" => "subject-two",
|
||||
_ => subject,
|
||||
};
|
||||
MergeRequestReviewSubject {
|
||||
subject_ref: if request.merge_request_id == merge_request_id {
|
||||
subject.into()
|
||||
} else {
|
||||
other_subject.into()
|
||||
},
|
||||
merge_request_id: request.merge_request_id,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: merge_request_id.into(),
|
||||
ticket_item_revision: "t".into(),
|
||||
ticket_merge_request_subjects,
|
||||
ticket_id: "T".into(),
|
||||
subject_ref: subject.into(),
|
||||
child_session_id: format!("child-{token}"),
|
||||
@@ -211,6 +232,7 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||
operation_id: "ticket-op".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into()],
|
||||
requirement_approval_event_id: merged.approval_event_id.clone(),
|
||||
auth: auth(),
|
||||
now: at(7),
|
||||
})
|
||||
@@ -235,6 +257,7 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||
operation_id: "ticket-op".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into()],
|
||||
requirement_approval_event_id: merged.approval_event_id.clone(),
|
||||
auth: auth(),
|
||||
now: at(8),
|
||||
})
|
||||
@@ -343,7 +366,7 @@ fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_o
|
||||
current_subject_ref: "source-2".into(),
|
||||
target_ref_before: "target-2".into(),
|
||||
target_ref_after: "integrated-target-2".into(),
|
||||
approval_event_id: second.event_id,
|
||||
approval_event_id: second.event_id.clone(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
@@ -723,6 +746,151 @@ fn mr_operations_and_review_capabilities_are_bound_to_explicit_identity() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
|
||||
let (dir, store) = fixture();
|
||||
open(&store);
|
||||
let stale = approve(&store, "subject", "token-stale");
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_event_attributes VALUES('W','T',1,'event_id','t2')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
|
||||
let readiness = store
|
||||
.readiness(ReadinessCheck {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("subject".into()),
|
||||
auth: auth(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!readiness.ready);
|
||||
assert!(
|
||||
readiness
|
||||
.blockers
|
||||
.iter()
|
||||
.any(|blocker| blocker.contains("Ticket revision"))
|
||||
);
|
||||
|
||||
let stale_completion = CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "merge-stale".into(),
|
||||
approval_event_id: stale.event_id,
|
||||
current_subject_ref: "subject".into(),
|
||||
target_ref_before: "target-before".into(),
|
||||
target_ref_after: "target-after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(5),
|
||||
};
|
||||
assert!(matches!(
|
||||
store.validate_completion(&stale_completion),
|
||||
Err(MergeRequestError::NotReady(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.complete(stale_completion),
|
||||
Err(MergeRequestError::NotReady(_))
|
||||
));
|
||||
|
||||
store
|
||||
.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||
workspace_id: "W".into(),
|
||||
parent_runtime_id: "runtime".into(),
|
||||
parent_worker_id: "coder".into(),
|
||||
child_session_id: "child-fresh".into(),
|
||||
reviewer_profile: "builtin:reviewer".into(),
|
||||
now: at(6),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
ticket_item_revision: "t2".into(),
|
||||
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: "subject".into(),
|
||||
}],
|
||||
subject_ref: "subject".into(),
|
||||
child_session_id: "child-fresh".into(),
|
||||
capability_token: "token-fresh".into(),
|
||||
auth: auth(),
|
||||
now: at(7),
|
||||
})
|
||||
.unwrap();
|
||||
let fresh = store
|
||||
.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "subject".into(),
|
||||
capability_token: "token-fresh".into(),
|
||||
decision: ReviewDecision::Approve,
|
||||
body: "fresh requirements approved".into(),
|
||||
findings: vec![],
|
||||
now: at(8),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "merge-fresh".into(),
|
||||
approval_event_id: fresh.event_id,
|
||||
current_subject_ref: "subject".into(),
|
||||
target_ref_before: "target-before".into(),
|
||||
target_ref_after: "target-after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(9),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() {
|
||||
let (_dir, store) = fixture();
|
||||
open_for(&store, "MR", "R");
|
||||
open_for(&store, "MR-2", "R2");
|
||||
store
|
||||
.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||
workspace_id: "W".into(),
|
||||
parent_runtime_id: "runtime".into(),
|
||||
parent_worker_id: "coder".into(),
|
||||
child_session_id: "child-incomplete".into(),
|
||||
reviewer_profile: "builtin:reviewer".into(),
|
||||
now: at(3),
|
||||
})
|
||||
.unwrap();
|
||||
let result = store.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
ticket_item_revision: "t".into(),
|
||||
ticket_merge_request_subjects: vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: "subject-one".into(),
|
||||
}],
|
||||
subject_ref: "subject-one".into(),
|
||||
child_session_id: "child-incomplete".into(),
|
||||
capability_token: "token-incomplete".into(),
|
||||
auth: auth(),
|
||||
now: at(4),
|
||||
});
|
||||
assert!(matches!(result, Err(MergeRequestError::Conflict(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_completion() {
|
||||
let (dir, store) = fixture();
|
||||
@@ -762,6 +930,7 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple
|
||||
operation_id: "ticket-complete".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into(), "MR-2".into()],
|
||||
requirement_approval_event_id: second.event_id.clone(),
|
||||
auth: auth(),
|
||||
now: at(6),
|
||||
}),
|
||||
@@ -773,7 +942,7 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple
|
||||
merge_request_id: "MR-2".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "merge-two".into(),
|
||||
approval_event_id: second.event_id,
|
||||
approval_event_id: second.event_id.clone(),
|
||||
current_subject_ref: "subject-two".into(),
|
||||
target_ref_before: "target-two-before".into(),
|
||||
target_ref_after: "target-two-after".into(),
|
||||
@@ -789,30 +958,57 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple
|
||||
operation_id: "ticket-complete".into(),
|
||||
item_revision: "stale".into(),
|
||||
merge_request_ids: vec!["MR".into(), "MR-2".into()],
|
||||
requirement_approval_event_id: second.event_id.clone(),
|
||||
auth: auth(),
|
||||
now: at(8),
|
||||
}),
|
||||
Err(MergeRequestError::Conflict(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-complete-missing-attestation".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into(), "MR-2".into()],
|
||||
requirement_approval_event_id: "missing-review".into(),
|
||||
auth: auth(),
|
||||
now: at(8),
|
||||
}),
|
||||
Err(MergeRequestError::NotReady(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-complete".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into()],
|
||||
requirement_approval_event_id: second.event_id.clone(),
|
||||
auth: auth(),
|
||||
now: at(8),
|
||||
}),
|
||||
Err(MergeRequestError::Conflict(_))
|
||||
));
|
||||
store
|
||||
let completed = store
|
||||
.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-complete".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR-2".into(), "MR".into()],
|
||||
requirement_approval_event_id: second.event_id.clone(),
|
||||
auth: auth(),
|
||||
now: at(9),
|
||||
})
|
||||
.unwrap();
|
||||
let replay = store
|
||||
.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-complete".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into(), "MR-2".into()],
|
||||
requirement_approval_event_id: second.event_id.clone(),
|
||||
auth: auth(),
|
||||
now: at(10),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(replay, completed);
|
||||
}
|
||||
|
||||
@@ -6291,6 +6291,14 @@ pub struct MergeRequestWorkerIdentity {
|
||||
pub worker_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MergeRequestReviewSubject {
|
||||
pub merge_request_id: String,
|
||||
pub subject_ref: String,
|
||||
}
|
||||
|
||||
macro_rules! merge_request_event {
|
||||
($name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -6310,6 +6318,7 @@ macro_rules! merge_request_event {
|
||||
merge_request_event!(ReviewRequestedEvent {
|
||||
subject_ref: String,
|
||||
ticket_item_revision: String,
|
||||
ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
requested_by: MergeRequestWorkerIdentity,
|
||||
reviewer: MergeRequestWorkerIdentity,
|
||||
});
|
||||
@@ -6317,6 +6326,7 @@ merge_request_event!(ReviewEvent {
|
||||
request_event_id: String,
|
||||
subject_ref: String,
|
||||
ticket_item_revision: String,
|
||||
ticket_merge_request_subjects: Vec<MergeRequestReviewSubject>,
|
||||
decision: ReviewDecision,
|
||||
body: String,
|
||||
findings: Vec<ReviewFinding>,
|
||||
@@ -6501,6 +6511,7 @@ pub struct RegisterReviewerChildSessionRequest {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RegisterMergeRequestReviewCapabilityRequest {
|
||||
pub ticket_id: String,
|
||||
pub child_session_id: String,
|
||||
pub capability_token: String,
|
||||
}
|
||||
@@ -6533,6 +6544,7 @@ pub struct CompleteTicketRequest {
|
||||
pub operation_id: String,
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub requirement_approval_event_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -6542,6 +6554,7 @@ pub struct TicketCompletionEvent {
|
||||
pub ticket_id: String,
|
||||
pub item_revision: String,
|
||||
pub merge_request_ids: Vec<String>,
|
||||
pub requirement_approval_event_id: String,
|
||||
pub completed_by: MergeRequestWorkerIdentity,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ struct CompleteTicketInput {
|
||||
operation_id: String,
|
||||
item_revision: String,
|
||||
merge_request_ids: Vec<String>,
|
||||
requirement_approval_event_id: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -213,6 +214,7 @@ impl Tool for MergeRequestTool {
|
||||
"operation_id": v.operation_id,
|
||||
"item_revision": v.item_revision,
|
||||
"merge_request_ids": v.merge_request_ids,
|
||||
"requirement_approval_event_id": v.requirement_approval_event_id,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -613,7 +613,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
child_registry
|
||||
.attach_parent_protocol(session.protocol_sender(), session.session_id_string());
|
||||
|
||||
if let Some((_ticket_id, merge_request_id, capability_token)) = &reviewer_capability {
|
||||
if let Some((ticket_id, merge_request_id, capability_token)) = &reviewer_capability {
|
||||
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("review capability lost Workspace identity".to_string())
|
||||
})?;
|
||||
@@ -643,6 +643,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
)));
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"ticket_id": ticket_id,
|
||||
"child_session_id": child_session_id,
|
||||
"capability_token": capability_token,
|
||||
});
|
||||
|
||||
@@ -2806,6 +2806,10 @@ mod tests {
|
||||
sequence: 1,
|
||||
subject_ref: "commit-1".to_string(),
|
||||
ticket_item_revision: "revision-1".to_string(),
|
||||
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
|
||||
merge_request_id: "mr-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
}],
|
||||
requested_by: reviewer.clone(),
|
||||
reviewer: reviewer.clone(),
|
||||
created_at: requested_at,
|
||||
@@ -2816,6 +2820,10 @@ mod tests {
|
||||
request_event_id: "request-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
ticket_item_revision: "revision-1".to_string(),
|
||||
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
|
||||
merge_request_id: "mr-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
}],
|
||||
decision,
|
||||
body: "review body".to_string(),
|
||||
findings: Vec::new(),
|
||||
|
||||
@@ -12167,6 +12167,14 @@ fn public_review_event(event: merge_request::ReviewEvent) -> server_api::ReviewE
|
||||
request_event_id: event.request_event_id,
|
||||
subject_ref: event.subject_ref,
|
||||
ticket_item_revision: event.ticket_item_revision,
|
||||
ticket_merge_request_subjects: event
|
||||
.ticket_merge_request_subjects
|
||||
.into_iter()
|
||||
.map(|subject| server_api::MergeRequestReviewSubject {
|
||||
merge_request_id: subject.merge_request_id,
|
||||
subject_ref: subject.subject_ref,
|
||||
})
|
||||
.collect(),
|
||||
decision: public_review_decision(event.decision),
|
||||
body: event.body,
|
||||
findings: event
|
||||
@@ -12217,6 +12225,7 @@ fn public_ticket_completion_event(
|
||||
ticket_id: event.ticket_id,
|
||||
item_revision: event.item_revision,
|
||||
merge_request_ids: event.merge_request_ids,
|
||||
requirement_approval_event_id: event.requirement_approval_event_id,
|
||||
completed_by: public_merge_request_worker_identity(event.completed_by),
|
||||
created_at: event.created_at.to_rfc3339(),
|
||||
}
|
||||
@@ -12270,6 +12279,14 @@ fn public_merge_request_thread_event(
|
||||
sequence: event.sequence,
|
||||
subject_ref: event.subject_ref,
|
||||
ticket_item_revision: event.ticket_item_revision,
|
||||
ticket_merge_request_subjects: event
|
||||
.ticket_merge_request_subjects
|
||||
.into_iter()
|
||||
.map(|subject| server_api::MergeRequestReviewSubject {
|
||||
merge_request_id: subject.merge_request_id,
|
||||
subject_ref: subject.subject_ref,
|
||||
})
|
||||
.collect(),
|
||||
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(),
|
||||
@@ -12813,6 +12830,14 @@ async fn scoped_register_merge_request_review_capability(
|
||||
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 requested_ticket_id =
|
||||
resolve_workspace_ticket_reference(&api, &workspace_id, &input.ticket_id)?;
|
||||
if requested_ticket_id != ticket_id {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Reviewer handoff Ticket does not match the addressed Merge Request".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
@@ -12846,11 +12871,52 @@ async fn scoped_register_merge_request_review_capability(
|
||||
&source_observation.revision_ref,
|
||||
)?;
|
||||
let subject_ref = source_observation.revision_ref;
|
||||
let mut ticket_merge_request_subjects = Vec::new();
|
||||
for linked in store.list_for_ticket(&workspace_id, &ticket_id)? {
|
||||
let linked_subject_ref = if linked.state == merge_request::MergeRequestState::Merged {
|
||||
linked
|
||||
.thread
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|event| match event {
|
||||
merge_request::MergeRequestThreadEvent::Merge(event) => {
|
||||
Some(event.approved_source_ref.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
Error::MergeRequest(merge_request::MergeRequestError::Corrupt(format!(
|
||||
"merged Merge Request `{}` has no MergeResult",
|
||||
linked.merge_request_id
|
||||
)))
|
||||
})?
|
||||
} else {
|
||||
let linked_selector = linked.selector_from.as_deref().ok_or_else(|| {
|
||||
Error::InvalidInput(format!(
|
||||
"Merge Request `{}` selector_from requires repair",
|
||||
linked.merge_request_id
|
||||
))
|
||||
})?;
|
||||
observe_published_source_ref(
|
||||
&api,
|
||||
&workspace_id,
|
||||
&assignment.worker.runtime_id,
|
||||
&linked.repository_id,
|
||||
linked_selector,
|
||||
)?
|
||||
.revision_ref
|
||||
};
|
||||
ticket_merge_request_subjects.push(merge_request::MergeRequestReviewSubject {
|
||||
merge_request_id: linked.merge_request_id,
|
||||
subject_ref: linked_subject_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,
|
||||
ticket_merge_request_subjects,
|
||||
subject_ref,
|
||||
child_session_id: input.child_session_id,
|
||||
capability_token: input.capability_token,
|
||||
@@ -13077,24 +13143,24 @@ async fn scoped_complete_ticket(
|
||||
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
|
||||
let assignment_id = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||
})?;
|
||||
.map(|assignment| assignment.assignment_id)
|
||||
.unwrap_or_default();
|
||||
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,
|
||||
requirement_approval_event_id: input.requirement_approval_event_id,
|
||||
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,
|
||||
assignment_id,
|
||||
},
|
||||
now: Utc::now(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user