Bind ticket completion to current review evidence

This commit is contained in:
2026-09-29 01:47:01 +09:00
parent 590abf3142
commit ad08110c96
16 changed files with 530 additions and 59 deletions
+159 -41
View File
@@ -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)
+199 -3
View File
@@ -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);
}
+13
View File
@@ -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,
})),
)
}
+2 -1
View File
@@ -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,
});
+8
View File
@@ -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(),
+71 -5
View File
@@ -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(),
},
+38 -1
View File
@@ -3808,6 +3808,22 @@
],
"type": "object"
},
"MergeRequestReviewSubject": {
"additionalProperties": false,
"properties": {
"merge_request_id": {
"type": "string"
},
"subject_ref": {
"type": "string"
}
},
"required": [
"merge_request_id",
"subject_ref"
],
"type": "object"
},
"MergeRequestState": {
"enum": [
"open",
@@ -3848,6 +3864,12 @@
},
"ticket_item_revision": {
"type": "string"
},
"ticket_merge_request_subjects": {
"items": {
"$ref": "#/components/schemas/MergeRequestReviewSubject"
},
"type": "array"
}
},
"required": [
@@ -3856,6 +3878,7 @@
"sequence",
"subject_ref",
"ticket_item_revision",
"ticket_merge_request_subjects",
"requested_by",
"reviewer",
"created_at"
@@ -3904,6 +3927,12 @@
},
"ticket_item_revision": {
"type": "string"
},
"ticket_merge_request_subjects": {
"items": {
"$ref": "#/components/schemas/MergeRequestReviewSubject"
},
"type": "array"
}
},
"required": [
@@ -3913,6 +3942,7 @@
"request_event_id",
"subject_ref",
"ticket_item_revision",
"ticket_merge_request_subjects",
"decision",
"body",
"findings",
@@ -6314,6 +6344,12 @@
},
"ticket_item_revision": {
"type": "string"
},
"ticket_merge_request_subjects": {
"items": {
"$ref": "#/components/schemas/MergeRequestReviewSubject"
},
"type": "array"
}
},
"required": [
@@ -6322,6 +6358,7 @@
"request_event_id",
"subject_ref",
"ticket_item_revision",
"ticket_merge_request_subjects",
"decision",
"body",
"findings",
@@ -13945,7 +13982,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822"
"x-yoi-source-digest": "sha256:7b14311c0ae7d40c3e1b423890c890b1b73de321ed199c223b771711effcd280"
},
"openapi": "3.1.0",
"paths": {
+1 -1
View File
@@ -16,7 +16,7 @@ Push the resulting `merge_to` branch through its configured normal push path. Pr
After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: keep the Ticket and assignment in progress while remaining linked MRs are implemented, reviewed, or integrated.
If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR set, verify the Reviewer evidence still judges the requirements satisfied, then call `CompleteTicket` with that snapshot. `CompleteTicket` alone moves the Ticket to `done` and releases the assignment atomically; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint.
If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR/source-result set, identify the effective Reviewer approval that attests that exact snapshot and judges the requirements satisfied, then call `CompleteTicket` with the item revision, MR IDs, and that requirement approval event ID. `CompleteTicket` alone moves the Ticket to `done` and releases the assignment atomically; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint.
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
+1 -1
View File
@@ -1,6 +1,6 @@
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured explicit Merge Request, `ReviewRequested.subject_ref`, and `ticket_item_revision` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. When a Ticket has multiple MRs, judge the selected MR in the context of the complete linked result set without reusing another MR's approval. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation.
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured explicit Merge Request, `ReviewRequested.subject_ref`, `ticket_item_revision`, and exact linked Merge Request subject snapshot against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. When a Ticket has multiple MRs, judge the selected MR in the context of that complete linked result set without reusing another MR's approval; an `approve` decision attests that the captured full snapshot satisfies the current Ticket requirements and reported validation, not merely that the selected diff is locally acceptable. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation.
Your prose response is not review authority. Before finishing, call `ReviewMergeRequest` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. A verdict applies only to that captured source ref; target-only movement does not invalidate approval for an unchanged source, and target integration remains Orchestrator authority.
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
// Input contract: 0.1.0 (sha256:7b14311c0ae7d40c3e1b423890c890b1b73de321ed199c223b771711effcd280)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
// Input contract: 0.1.0 (sha256:7b14311c0ae7d40c3e1b423890c890b1b73de321ed199c223b771711effcd280)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
// Input contract: 0.1.0 (sha256:7b14311c0ae7d40c3e1b423890c890b1b73de321ed199c223b771711effcd280)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
// Input contract: 0.1.0 (sha256:7b14311c0ae7d40c3e1b423890c890b1b73de321ed199c223b771711effcd280)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -47,9 +47,11 @@ export type MergeRequestRefDiagnostic = { code: string; message: string; };
export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; };
export type MergeRequestReviewSubject = { merge_request_id: string; subject_ref: string; };
export type MergeRequestState = "open" | "merged" | "closed";
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_item_revision: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fbaf40391d4ab81b9ffb558ff4c055efb8c58e26b6a88d3eefbbbeefc6ea0822)
// Input contract: 0.1.0 (sha256:7b14311c0ae7d40c3e1b423890c890b1b73de321ed199c223b771711effcd280)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -6,6 +6,7 @@ import type {
MergeRequestListResponse,
MergeRequestRefDiagnostic,
MergeRequestRefResponse,
MergeRequestReviewSubject,
MergeRequestThreadEvent,
MergeRequestWorkerIdentity,
ObjectiveDetail,
@@ -1055,6 +1056,21 @@ function parseMergeRequestWorker(
};
}
function parseMergeRequestReviewSubject(
value: unknown,
label: string,
): MergeRequestReviewSubject {
const item = object(value, label);
exact(item, ["merge_request_id", "subject_ref"], label);
return {
merge_request_id: string(
item.merge_request_id,
`${label}.merge_request_id`,
),
subject_ref: string(item.subject_ref, `${label}.subject_ref`),
};
}
function parseReviewFinding(value: unknown, label: string): ReviewFinding {
const item = object(value, label);
exact(item, ["body", "code", "line", "path", "severity"], label);
@@ -1096,6 +1112,7 @@ function parseMergeRequestThreadEvent(
"reviewer",
"subject_ref",
"ticket_item_revision",
"ticket_merge_request_subjects",
],
label,
);
@@ -1112,6 +1129,11 @@ function parseMergeRequestThreadEvent(
item.ticket_item_revision,
`${label}.ticket_item_revision`,
),
ticket_merge_request_subjects: array(
item.ticket_merge_request_subjects,
`${label}.ticket_merge_request_subjects`,
parseMergeRequestReviewSubject,
),
};
}
if (kind === "review") {
@@ -1124,6 +1146,7 @@ function parseMergeRequestThreadEvent(
"reviewer",
"subject_ref",
"ticket_item_revision",
"ticket_merge_request_subjects",
], label);
const decision = string(item.decision, `${label}.decision`);
if (!["approve", "request_changes"].includes(decision)) {
@@ -1145,6 +1168,11 @@ function parseMergeRequestThreadEvent(
item.ticket_item_revision,
`${label}.ticket_item_revision`,
),
ticket_merge_request_subjects: array(
item.ticket_merge_request_subjects,
`${label}.ticket_merge_request_subjects`,
parseMergeRequestReviewSubject,
),
};
}
if (kind === "review_revoked") {