feat(ticket): decouple audited lifecycle decisions from MR integration
T-718: unify state, completion, close and reopen with explicit CAS and operation receipts; keep MR integration proofs independent. Queue only the explicitly selected Ticket and fence target edits against active Worker grants. Align REST, Worker/WIP, evidence projection and generated contracts. Validation: root cargo check; full ticket, merge-request, client, worker, yoi-workspace-server and server-api tests; server-api typescript feature; OpenAPI and six generated API checkers; Svelte check and 36 narrow Web tests; fmt and diff checks.
This commit is contained in:
@@ -155,14 +155,40 @@ impl BackendWorkspaceProductClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn decision_from_state_change(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
) -> Result<ticket::TicketStateUpdate, BackendWorkspaceClientError> {
|
||||
let current = self.show_ticket(id)?;
|
||||
let parse = |state: &str| {
|
||||
ticket::TicketWorkflowState::parse(state).ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(format!("invalid Ticket state {state}"))
|
||||
})
|
||||
};
|
||||
Ok(ticket::TicketStateUpdate {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_item_revision: ticket::ticket_item_revision(¤t),
|
||||
expected_state: parse(&change.from)?,
|
||||
state: parse(&change.to)?,
|
||||
reason: if change.body.as_str().is_empty() {
|
||||
change.reason.clone()
|
||||
} else {
|
||||
format!("{}\n\n{}", change.reason, change.body.as_str())
|
||||
},
|
||||
references: change.references.clone(),
|
||||
author: change.author.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_workflow_state(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let change = self.decision_from_state_change(id, change)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let change = change.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_workflow_state_set(
|
||||
@@ -179,15 +205,23 @@ impl BackendWorkspaceProductClient {
|
||||
id: &TicketIdOrSlug,
|
||||
resolution: &MarkdownText,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let current = self.show_ticket(id)?;
|
||||
let request = ticket::TicketCompletion {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_item_revision: ticket::ticket_item_revision(¤t),
|
||||
expected_state: current.meta.workflow_state,
|
||||
reason: resolution.as_str().to_string(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
};
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let resolution = resolution.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_close_record(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketCloseRecordRequest(resolution),
|
||||
server_api::TicketCloseRecordRequest(request),
|
||||
)
|
||||
.await
|
||||
})
|
||||
@@ -633,6 +667,9 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
@@ -672,6 +709,14 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
field: &str,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
if field != "state" {
|
||||
return Err(TicketError::Conflict(
|
||||
"unsupported Ticket state field".into(),
|
||||
));
|
||||
}
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
let field = field.to_string();
|
||||
@@ -697,6 +742,26 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn update_state(
|
||||
&self,
|
||||
ticket: &str,
|
||||
request: ticket::TicketStateUpdate,
|
||||
) -> ticket::Result<Ticket> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket.to_owned();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_update(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateUpdateRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result<Ticket> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
@@ -903,8 +968,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn response_sequence_server(
|
||||
responses: Vec<(&'static str, &'static str)>,
|
||||
responses: Vec<(&str, &str)>,
|
||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
||||
let responses = responses
|
||||
.into_iter()
|
||||
.map(|(status, body)| (status.to_owned(), body.to_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
@@ -927,6 +996,117 @@ mod tests {
|
||||
(format!("http://{address}"), receiver, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_adapter_uses_snapshot_revision_without_overriding_stale_expected_state() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend.create(NewTicket::new("Adapter snapshot")).unwrap();
|
||||
let snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
let body = serde_json::to_string(&snapshot).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
|
||||
.unwrap();
|
||||
client
|
||||
.set_ticket_workflow_state(
|
||||
&reference.id.clone().into(),
|
||||
&TicketStateChange::new("ready", "done", "Judgment", "Results in the thread"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(requests.recv().unwrap().starts_with("GET "));
|
||||
let request = requests.recv().unwrap();
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body["expected_item_revision"],
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
);
|
||||
assert_eq!(body["expected_state"], "ready");
|
||||
assert_eq!(body["state"], "done");
|
||||
assert!(!body["operation_key"].as_str().unwrap().is_empty());
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_close_adapter_forwards_snapshot_cas_and_resolution_as_judgment() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend
|
||||
.create(NewTicket::new("Close adapter snapshot"))
|
||||
.unwrap();
|
||||
let snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
let body = serde_json::to_string(&snapshot).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
|
||||
.unwrap();
|
||||
client
|
||||
.close_ticket(
|
||||
&reference.id.into(),
|
||||
&MarkdownText::new("The request was withdrawn"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(requests.recv().unwrap().starts_with("GET "));
|
||||
let request = requests.recv().unwrap();
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body["expected_item_revision"],
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
);
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "The request was withdrawn");
|
||||
assert!(!body["operation_key"].as_str().unwrap().is_empty());
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
|
||||
let (base_url, request, handle) = one_response_server(
|
||||
"409 Conflict",
|
||||
r#"{"error":"ticket_conflict","message":"stale revision","diagnostics":[]}"#,
|
||||
);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = TicketBackend::complete(
|
||||
&client,
|
||||
"T-718",
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "research-done".into(),
|
||||
expected_item_revision: "ticket:1".into(),
|
||||
expected_state: ticket::TicketWorkflowState::Planning,
|
||||
reason: "Research findings recorded".into(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let request = request.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/tickets/T-718/state-update "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["state"], "done");
|
||||
assert_eq!(body["operation_key"], "research-done");
|
||||
assert_eq!(body["expected_item_revision"], "ticket:1");
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "Research findings recorded");
|
||||
assert!(body.get("merge_request_ids").is_none());
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_document_uses_shared_workspace_scoped_response() {
|
||||
let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##;
|
||||
|
||||
@@ -598,16 +598,8 @@ pub struct CompleteMergeRequest {
|
||||
pub auth: MergeRequestAuth,
|
||||
pub now: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompleteTicket {
|
||||
pub ticket_id: String,
|
||||
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>,
|
||||
}
|
||||
/// Historical MR-owned Ticket completion payload, retained for reading existing events.
|
||||
/// Canonical Ticket completion is owned by the Ticket domain.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketCompletionEvent {
|
||||
pub operation_id: String,
|
||||
@@ -1332,164 +1324,6 @@ impl MergeRequestStore {
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
pub fn complete_ticket(
|
||||
&self,
|
||||
mut input: CompleteTicket,
|
||||
) -> 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(),
|
||||
));
|
||||
}
|
||||
input.merge_request_ids.sort();
|
||||
if input
|
||||
.merge_request_ids
|
||||
.windows(2)
|
||||
.any(|ids| ids[0] == ids[1])
|
||||
{
|
||||
return Err(MergeRequestError::Validation(
|
||||
"merge_request_ids must not contain duplicates".into(),
|
||||
));
|
||||
}
|
||||
let mut connection = self.lock()?;
|
||||
let transaction = connection.transaction()?;
|
||||
let existing: Option<String> = transaction
|
||||
.query_row(
|
||||
"SELECT payload.value
|
||||
FROM typed_ticket_event_attributes operation
|
||||
JOIN typed_ticket_event_attributes payload
|
||||
ON payload.workspace_id=operation.workspace_id
|
||||
AND payload.ticket_id=operation.ticket_id
|
||||
AND payload.event_index=operation.event_index
|
||||
AND payload.key='ticket_completion'
|
||||
WHERE operation.workspace_id=?1 AND operation.ticket_id=?2
|
||||
AND operation.key='operation_id' AND operation.value=?3",
|
||||
params![input.auth.workspace_id, input.ticket_id, input.operation_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(payload) = existing {
|
||||
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);
|
||||
}
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket completion operation fingerprint mismatch".into(),
|
||||
));
|
||||
}
|
||||
let current_revision =
|
||||
current_ticket_revision(&transaction, &input.auth.workspace_id, &input.ticket_id)?
|
||||
.ok_or(MergeRequestError::NotFound)?;
|
||||
if current_revision != input.item_revision {
|
||||
return Err(MergeRequestError::Conflict(format!(
|
||||
"Ticket item revision changed from `{}` to `{current_revision}`; repeat requirement review before completion",
|
||||
input.item_revision
|
||||
)));
|
||||
}
|
||||
let state: String = transaction.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![input.auth.workspace_id, input.ticket_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if state != "inprogress" {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket must be inprogress".into(),
|
||||
));
|
||||
}
|
||||
let assignment_is_current: bool = transaction.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM ticket_active_worker_assignments
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
|
||||
)",
|
||||
params![
|
||||
input.auth.workspace_id,
|
||||
input.ticket_id,
|
||||
input.auth.assignment_id
|
||||
],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !assignment_is_current {
|
||||
return Err(MergeRequestError::Unauthorized(
|
||||
"Ticket completion assignment changed before commit".into(),
|
||||
));
|
||||
}
|
||||
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)?;
|
||||
request.integration_approval().map_err(|error| {
|
||||
let message = format!("Merge Request `{merge_request_id}`: {}", error.as_str());
|
||||
if error == MergeRequestEvidenceError::MergeResultMissing {
|
||||
MergeRequestError::Corrupt(message)
|
||||
} else {
|
||||
MergeRequestError::NotReady(message)
|
||||
}
|
||||
})?;
|
||||
let merge = request
|
||||
.merged_result()
|
||||
.map_err(|error| MergeRequestError::Corrupt(error.as_str().into()))?;
|
||||
merged_subjects.push(MergeRequestReviewSubject {
|
||||
merge_request_id: merge_request_id.clone(),
|
||||
subject_ref: merge.approved_source_ref.clone(),
|
||||
});
|
||||
requests.push(request);
|
||||
}
|
||||
requirement_approval(
|
||||
&requests,
|
||||
&input.item_revision,
|
||||
&merged_subjects,
|
||||
Some(&input.requirement_approval_event_id),
|
||||
)
|
||||
.map_err(|error| MergeRequestError::NotReady(error.as_str().into()))?;
|
||||
let updated = transaction.execute(
|
||||
"UPDATE typed_tickets
|
||||
SET workflow_state='done',workflow_state_explicit=1,updated_at=?3
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'",
|
||||
params![
|
||||
input.auth.workspace_id,
|
||||
input.ticket_id,
|
||||
input.now.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
if updated != 1 {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket state changed during completion".into(),
|
||||
));
|
||||
}
|
||||
// Terminal state ends unfinished work through the Workspace authority's
|
||||
// lifecycle trigger. Keep the responsibility identity and its history.
|
||||
let event = TicketCompletionEvent {
|
||||
operation_id: input.operation_id,
|
||||
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,
|
||||
};
|
||||
ticket_completion_event(&transaction, &input.auth.workspace_id, &event)?;
|
||||
transaction.commit()?;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
pub fn repair_selector_from(
|
||||
&self,
|
||||
i: RepairSelectorFrom,
|
||||
@@ -1935,62 +1769,6 @@ fn ticket_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ticket_completion_event(
|
||||
transaction: &Transaction<'_>,
|
||||
workspace_id: &str,
|
||||
event: &TicketCompletionEvent,
|
||||
) -> Result<(), MergeRequestError> {
|
||||
let event_index: i64 = transaction.query_row(
|
||||
"SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![workspace_id, event.ticket_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)
|
||||
VALUES(?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Ticket requirements completed',?6)",
|
||||
params![
|
||||
workspace_id,
|
||||
event.ticket_id,
|
||||
event_index,
|
||||
format!(
|
||||
"worker:{}:{}",
|
||||
event.completed_by.runtime_id, event.completed_by.worker_id
|
||||
),
|
||||
event.created_at.to_rfc3339(),
|
||||
format!(
|
||||
"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(", ")
|
||||
)
|
||||
],
|
||||
)?;
|
||||
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)
|
||||
.map_err(|error| MergeRequestError::Operation(error.to_string()))?,
|
||||
),
|
||||
(
|
||||
"ticket_completion",
|
||||
serde_json::to_string(event)
|
||||
.map_err(|error| MergeRequestError::Operation(error.to_string()))?,
|
||||
),
|
||||
] {
|
||||
transaction.execute(
|
||||
"INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)",
|
||||
params![workspace_id, event.ticket_id, event_index, key, value],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
|
||||
match schema_state(c)? {
|
||||
SchemaState::Fresh => fresh(c),
|
||||
|
||||
+159
-160
@@ -166,7 +166,7 @@ fn review_submission_authorization_rejects_invalid_grants_before_side_effects()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||
fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_merge() {
|
||||
let (d, s) = fixture();
|
||||
open(&s);
|
||||
let review = approve(&s, "opaque-source-ref", "token");
|
||||
@@ -226,45 +226,14 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(replayed, merged);
|
||||
let completed = s
|
||||
.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
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),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(completed.merge_request_ids, vec!["MR"]);
|
||||
let connection = Connection::open(d.path().join("db")).unwrap();
|
||||
let (state, assignment_exists): (String, bool) = connection
|
||||
.query_row(
|
||||
"SELECT workflow_state, EXISTS(
|
||||
SELECT 1 FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id='W' AND ticket_id='T'
|
||||
) FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state, "done");
|
||||
assert!(assignment_exists);
|
||||
let active: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T')", [], |row| row.get(0)).unwrap();
|
||||
assert!(!active);
|
||||
let replayed_completion = s
|
||||
.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
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),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(replayed_completion, completed);
|
||||
let (state, active): (String, bool) = connection.query_row(
|
||||
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).unwrap();
|
||||
assert_eq!(state, "inprogress");
|
||||
assert!(active);
|
||||
let json = serde_json::to_string(&mr).unwrap();
|
||||
for banned in [
|
||||
"revision_id",
|
||||
@@ -866,12 +835,12 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
let (dir, store) = fixture();
|
||||
open(&store);
|
||||
let integration_approval = approve(&store, "subject", "token-integration");
|
||||
store
|
||||
let merge = store
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "merge".into(),
|
||||
approval_event_id: integration_approval.event_id,
|
||||
approval_event_id: integration_approval.event_id.clone(),
|
||||
current_subject_ref: "subject".into(),
|
||||
target_ref_before: "target-before".into(),
|
||||
target_ref_after: "target-after".into(),
|
||||
@@ -896,6 +865,26 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
|
||||
let snapshot = vec![MergeRequestReviewSubject {
|
||||
merge_request_id: "MR".into(),
|
||||
subject_ref: merge.approved_source_ref.clone(),
|
||||
}];
|
||||
let before_refresh = store.list_for_ticket("W", "T").unwrap();
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&before_refresh,
|
||||
"T:1",
|
||||
&snapshot,
|
||||
Some(&integration_approval.event_id),
|
||||
),
|
||||
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
|
||||
);
|
||||
assert_eq!(before_refresh[0].merged_result().unwrap(), &merge);
|
||||
assert_eq!(
|
||||
before_refresh[0].integration_approval().unwrap(),
|
||||
&integration_approval
|
||||
);
|
||||
|
||||
store
|
||||
.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||
workspace_id: "W".into(),
|
||||
@@ -922,7 +911,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
now: at(7),
|
||||
})
|
||||
.unwrap();
|
||||
let requirement_approval = store
|
||||
let requirement_review = store
|
||||
.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
@@ -934,17 +923,33 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
now: at(8),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "ticket-complete-post-rescope".into(),
|
||||
item_revision: "T:1".into(),
|
||||
merge_request_ids: vec!["MR".into()],
|
||||
requirement_approval_event_id: requirement_approval.event_id,
|
||||
auth: auth(),
|
||||
now: at(9),
|
||||
})
|
||||
.unwrap();
|
||||
let refreshed = store.list_for_ticket("W", "T").unwrap();
|
||||
assert_eq!(
|
||||
requirement_approval(
|
||||
&refreshed,
|
||||
"T:1",
|
||||
&snapshot,
|
||||
Some(&requirement_review.event_id),
|
||||
)
|
||||
.unwrap(),
|
||||
&requirement_review
|
||||
);
|
||||
assert_eq!(
|
||||
requirement_approval(&refreshed, "T:1", &snapshot, None).unwrap(),
|
||||
&requirement_review
|
||||
);
|
||||
assert_eq!(refreshed[0].merged_result().unwrap(), &merge);
|
||||
assert_eq!(
|
||||
refreshed[0].integration_approval().unwrap(),
|
||||
&integration_approval
|
||||
);
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
let merge_payload: String = connection.query_row(
|
||||
"SELECT payload_json FROM merge_request_thread_events WHERE workspace_id='W' AND merge_request_id='MR' AND kind='merge'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(merge_payload, serde_json::to_string(&merge).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -980,19 +985,19 @@ fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_completion() {
|
||||
fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
|
||||
let (dir, store) = fixture();
|
||||
open_for(&store, "MR", "R");
|
||||
open_for(&store, "MR-2", "R2");
|
||||
let first = approve_for(&store, "MR", "R", "subject-one", "token-one");
|
||||
let second = approve_for(&store, "MR-2", "R2", "subject-two", "token-two");
|
||||
|
||||
store
|
||||
let first_merge = store
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "merge-one".into(),
|
||||
approval_event_id: first.event_id,
|
||||
approval_event_id: first.event_id.clone(),
|
||||
current_subject_ref: "subject-one".into(),
|
||||
target_ref_before: "target-one-before".into(),
|
||||
target_ref_after: "target-one-after".into(),
|
||||
@@ -1002,30 +1007,33 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple
|
||||
now: at(5),
|
||||
})
|
||||
.unwrap();
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
let (state, assigned): (String, bool) = connection
|
||||
.query_row(
|
||||
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state, "inprogress");
|
||||
assert!(assigned);
|
||||
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(), "MR-2".into()],
|
||||
requirement_approval_event_id: second.event_id.clone(),
|
||||
auth: auth(),
|
||||
now: at(6),
|
||||
}),
|
||||
Err(MergeRequestError::NotReady(_))
|
||||
));
|
||||
let partial = store.get_by_id("W", "MR-2").unwrap();
|
||||
assert_eq!(
|
||||
partial.integration_approval(),
|
||||
Err(MergeRequestEvidenceError::NotMerged)
|
||||
);
|
||||
|
||||
store
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
let assert_ticket_unchanged = || {
|
||||
let (state, assigned, active, state_changes): (String, bool, bool, i64) = connection
|
||||
.query_row(
|
||||
"SELECT workflow_state,
|
||||
EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T'),
|
||||
EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T'),
|
||||
(SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='W' AND ticket_id='T' AND kind='state_changed')
|
||||
FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state, "inprogress");
|
||||
assert!(assigned);
|
||||
assert!(active);
|
||||
assert_eq!(state_changes, 0);
|
||||
};
|
||||
assert_ticket_unchanged();
|
||||
|
||||
let second_merge = store
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: "MR-2".into(),
|
||||
ticket_id: "T".into(),
|
||||
@@ -1040,69 +1048,84 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple
|
||||
now: at(7),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
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(_))
|
||||
));
|
||||
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),
|
||||
assert_ticket_unchanged();
|
||||
let requests = store.list_for_ticket("W", "T").unwrap();
|
||||
let snapshot = requests
|
||||
.iter()
|
||||
.map(|request| MergeRequestReviewSubject {
|
||||
merge_request_id: request.merge_request_id.clone(),
|
||||
subject_ref: request.merged_result().unwrap().approved_source_ref.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
requirement_approval(&requests, "t", &snapshot, Some(&second.event_id)).unwrap(),
|
||||
&second
|
||||
);
|
||||
let first_request = requests
|
||||
.iter()
|
||||
.find(|request| request.merge_request_id == "MR")
|
||||
.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),
|
||||
})
|
||||
let second_request = requests
|
||||
.iter()
|
||||
.find(|request| request.merge_request_id == "MR-2")
|
||||
.unwrap();
|
||||
assert_eq!(replay, completed);
|
||||
assert_eq!(first_request.merged_result().unwrap(), &first_merge);
|
||||
assert_eq!(second_request.merged_result().unwrap(), &second_merge);
|
||||
assert_eq!(first_request.integration_approval().unwrap(), &first);
|
||||
assert_eq!(second_request.integration_approval().unwrap(), &second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_merged_result_requires_matching_nonrevoked_requested_approval_before_completion() {
|
||||
fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migration() {
|
||||
let (dir, _store) = fixture();
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
let payload = r#"{
|
||||
"operation_id": "historical-operation",
|
||||
"ticket_id": "T",
|
||||
"item_revision": "historical-revision",
|
||||
"merge_request_ids": ["MR-2", "MR"],
|
||||
"requirement_approval_event_id": "historical-review",
|
||||
"completed_by": {"runtime_id": "runtime", "worker_id": "coder"},
|
||||
"created_at": "2026-07-26T12:00:07Z"
|
||||
}"#;
|
||||
connection.execute_batch(
|
||||
"UPDATE typed_tickets SET workflow_state='done' WHERE workspace_id='W' AND ticket_id='T';
|
||||
INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body');"
|
||||
).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_event_attributes VALUES('W','T',0,'ticket_completion',?1)",
|
||||
[payload],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate(&connection).unwrap();
|
||||
|
||||
let (saved_payload, kind, from_state, to_state, heading, body, state): (String, String, String, String, String, String, String) = connection.query_row(
|
||||
"SELECT attribute.value,event.kind,event.from_state,event.to_state,event.heading,event.body,ticket.workflow_state
|
||||
FROM typed_ticket_event_attributes attribute
|
||||
JOIN typed_ticket_events event USING(workspace_id,ticket_id,event_index)
|
||||
JOIN typed_tickets ticket USING(workspace_id,ticket_id)
|
||||
WHERE attribute.workspace_id='W' AND attribute.ticket_id='T' AND attribute.key='ticket_completion'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?,row.get(5)?,row.get(6)?)),
|
||||
).unwrap();
|
||||
assert_eq!(saved_payload, payload);
|
||||
assert_eq!(kind, "state_changed");
|
||||
assert_eq!(from_state, "inprogress");
|
||||
assert_eq!(to_state, "done");
|
||||
assert_eq!(heading, "Ticket requirements completed");
|
||||
assert_eq!(body, "historical body");
|
||||
assert_eq!(state, "done");
|
||||
let historical: TicketCompletionEvent = serde_json::from_str(&saved_payload).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&historical).unwrap(),
|
||||
serde_json::from_str::<serde_json::Value>(payload).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_integration_evidence_rejects_missing_mismatched_revoked_or_late_approval() {
|
||||
for (case, expected) in [
|
||||
(
|
||||
"merge-missing",
|
||||
@@ -1207,30 +1230,6 @@ fn persisted_merged_result_requires_matching_nonrevoked_requested_approval_befor
|
||||
}
|
||||
let reloaded = store.get_by_id("W", "MR").unwrap();
|
||||
assert_eq!(reloaded.integration_approval(), Err(expected), "{case}");
|
||||
let rejected = store
|
||||
.complete_ticket(CompleteTicket {
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "complete-ticket".into(),
|
||||
item_revision: "t".into(),
|
||||
merge_request_ids: vec!["MR".into()],
|
||||
requirement_approval_event_id: review.event_id.clone(),
|
||||
auth: auth(),
|
||||
now: at(7),
|
||||
})
|
||||
.unwrap_err();
|
||||
match rejected {
|
||||
MergeRequestError::Corrupt(message)
|
||||
if expected == MergeRequestEvidenceError::MergeResultMissing =>
|
||||
{
|
||||
assert!(message.contains(expected.as_str()), "{case}: {message}");
|
||||
}
|
||||
MergeRequestError::NotReady(message)
|
||||
if expected != MergeRequestEvidenceError::MergeResultMissing =>
|
||||
{
|
||||
assert!(message.contains(expected.as_str()), "{case}: {message}");
|
||||
}
|
||||
other => panic!("{case}: unexpected completion error {other}"),
|
||||
}
|
||||
let (state, assigned): (String, bool) = connection.query_row(
|
||||
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
|
||||
@@ -324,7 +324,6 @@ impl_openapi_schema!(
|
||||
SubmitMergeRequestReviewRequest,
|
||||
TextResponse,
|
||||
TicketCloseRecordRequest,
|
||||
TicketCompletionEvent,
|
||||
TicketDependencyCheckResponse,
|
||||
TicketDetail,
|
||||
TicketDoctorResponse,
|
||||
@@ -350,6 +349,7 @@ impl_openapi_schema!(
|
||||
TicketRoleAssignmentsResponse,
|
||||
TicketShowRequest,
|
||||
TicketStateChangeRequest,
|
||||
TicketStateUpdateRequest,
|
||||
TicketSummarySearchQuery,
|
||||
TicketThreadEventRequest,
|
||||
RuntimeManagementApiError,
|
||||
@@ -2917,7 +2917,7 @@ pub trait ServerApi {
|
||||
#[path] id: String,
|
||||
#[body] request: TicketThreadEventRequest,
|
||||
) -> Result<(), RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_state_change_add(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
@@ -2933,7 +2933,7 @@ pub trait ServerApi {
|
||||
#[path] id: String,
|
||||
#[body] request: TicketIntakeSummaryRequest,
|
||||
) -> Result<(), RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_state_field_set(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
@@ -2942,7 +2942,7 @@ pub trait ServerApi {
|
||||
#[path] field: String,
|
||||
#[body] request: TicketStateChangeRequest,
|
||||
) -> Result<(), RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_workflow_state_set(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
@@ -2950,6 +2950,14 @@ pub trait ServerApi {
|
||||
#[path] id: String,
|
||||
#[body] request: TicketStateChangeRequest,
|
||||
) -> Result<(), RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/state-update", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_state_update(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
#[body] request: TicketStateUpdateRequest,
|
||||
) -> Result<TicketRecord, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_mark_ready_record(
|
||||
&self,
|
||||
@@ -3047,15 +3055,15 @@ pub trait ServerApi {
|
||||
#[path] id: String,
|
||||
#[body] request: CompleteMergeRequestRequest,
|
||||
) -> Result<MergeEvent, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_complete(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
#[body] request: CompleteTicketRequest,
|
||||
) -> Result<TicketCompletionEvent, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
) -> Result<TicketRecord, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_close_record(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
@@ -3150,9 +3158,10 @@ pub trait ServerApi {
|
||||
#[body] request: CancelTicketImplementationRequest,
|
||||
) -> Result<TicketDetail, RepositoryApiError>;
|
||||
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_state_transition(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
#[body] request: BrowserTransitionTicketStateRequest,
|
||||
@@ -3161,6 +3170,7 @@ pub trait ServerApi {
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_ready(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
#[body] request: TicketMarkReadyRequest,
|
||||
@@ -3177,14 +3187,16 @@ pub trait ServerApi {
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/queue", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_queue(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
#[body] request: BrowserQueueTicketRequest,
|
||||
) -> Result<TicketQueueResponse, RepositoryApiError>;
|
||||
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
||||
#[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_close(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] id: String,
|
||||
#[body] request: BrowserCloseTicketRequest,
|
||||
@@ -7647,9 +7659,10 @@ transparent_ticket_dto!(
|
||||
transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket);
|
||||
transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit);
|
||||
transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent);
|
||||
transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateChange);
|
||||
transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateUpdate);
|
||||
transparent_ticket_dto!(TicketStateUpdateRequest, ticket::TicketStateUpdate);
|
||||
transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary);
|
||||
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::MarkdownText);
|
||||
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::TicketCompletion);
|
||||
transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation);
|
||||
transparent_ticket_dto!(
|
||||
CreateTicketOrchestrationPlanRequest,
|
||||
@@ -7774,9 +7787,11 @@ pub struct BrowserEditTicketRequest {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BrowserTransitionTicketStateRequest {
|
||||
pub state: BrowserTicketWorkflowState,
|
||||
pub reason: Option<String>,
|
||||
pub operation_key: String,
|
||||
pub expected_item_revision: String,
|
||||
pub expected_state: BrowserTicketWorkflowState,
|
||||
pub reason: String,
|
||||
pub body: Option<String>,
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -7807,6 +7822,9 @@ pub struct BrowserQueueTicketRequest {}
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BrowserCloseTicketRequest {
|
||||
pub operation_key: String,
|
||||
pub expected_item_revision: String,
|
||||
pub expected_state: BrowserTicketWorkflowState,
|
||||
pub resolution: String,
|
||||
}
|
||||
|
||||
@@ -8185,22 +8203,12 @@ pub struct CompleteMergeRequestRequest {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketCompletionEvent {
|
||||
pub operation_id: String,
|
||||
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,
|
||||
pub operation_key: String,
|
||||
pub expected_item_revision: String,
|
||||
pub expected_state: ticket::TicketWorkflowState,
|
||||
pub reason: String,
|
||||
#[serde(default)]
|
||||
pub references: Vec<ticket::TicketReference>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -11461,13 +11469,6 @@ mod tests {
|
||||
#[test]
|
||||
fn ticket_objective_and_merge_request_operations_are_in_server_api_metadata() {
|
||||
let operations = ServerApiMetadata::OPERATIONS;
|
||||
assert_eq!(
|
||||
operations
|
||||
.iter()
|
||||
.filter(|operation| operation.operation_id.starts_with("ticket_"))
|
||||
.count(),
|
||||
38
|
||||
);
|
||||
assert_eq!(
|
||||
operations
|
||||
.iter()
|
||||
@@ -11483,6 +11484,16 @@ mod tests {
|
||||
11
|
||||
);
|
||||
for (operation_id, method, path) in [
|
||||
(
|
||||
"ticket_state_update",
|
||||
HttpMethod::Post,
|
||||
"/api/w/{workspace_id}/tickets/{id}/state-update",
|
||||
),
|
||||
(
|
||||
"ticket_complete",
|
||||
HttpMethod::Post,
|
||||
"/api/w/{workspace_id}/tickets/{id}/complete",
|
||||
),
|
||||
(
|
||||
"ticket_relation_query",
|
||||
HttpMethod::Post,
|
||||
@@ -12540,6 +12551,34 @@ mod tests {
|
||||
assert!(serde_json::from_value::<RuntimeConnectionTestResponse>(unknown).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_completion_wire_requires_cas_and_judgment_not_merge_evidence() {
|
||||
let request = serde_json::json!({
|
||||
"operation_key": "investigation-done",
|
||||
"expected_item_revision": "ticket:1",
|
||||
"expected_state": "planning",
|
||||
"reason": "Question answered in the thread"
|
||||
});
|
||||
let parsed: CompleteTicketRequest = serde_json::from_value(request.clone()).unwrap();
|
||||
assert!(parsed.references.is_empty());
|
||||
for field in [
|
||||
"operation_key",
|
||||
"expected_item_revision",
|
||||
"expected_state",
|
||||
"reason",
|
||||
] {
|
||||
let mut missing = request.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<CompleteTicketRequest>(missing).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
let mut old = request;
|
||||
old["requirement_approval_event_id"] = serde_json::json!("not-a-judgment");
|
||||
assert!(serde_json::from_value::<CompleteTicketRequest>(old).is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "typescript")]
|
||||
#[test]
|
||||
fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() {
|
||||
@@ -13388,7 +13427,6 @@ mod openapi_artifact_tests {
|
||||
"merge_request_review_revoke",
|
||||
"merge_request_review_submit",
|
||||
"merge_request_reviewer_child_register",
|
||||
"ticket_complete",
|
||||
"runtime_resource_fetch",
|
||||
"worker_control_cancel",
|
||||
"worker_control_input",
|
||||
|
||||
+1653
-819
File diff suppressed because it is too large
Load Diff
+46
-86
@@ -134,18 +134,15 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body`
|
||||
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
||||
"Append a typed Ticket implementation_report event. `body` is Markdown.";
|
||||
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
|
||||
The backend atomically validates and normalizes every persisted repository target, requires at least one \
|
||||
read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional.";
|
||||
Targets are optional; provided targets are validated against the Workspace catalog without Git resolution. \
|
||||
Records one typed state_changed event and transitions planning -> ready. `reason` is optional.";
|
||||
const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \
|
||||
The backend applies the same targets validation and lock as TicketMarkReady and commits the summary, \
|
||||
The backend applies the same optional catalog targets validation as TicketMarkReady and commits the summary, \
|
||||
state_changed event, effective targets, and planning -> ready transition atomically.";
|
||||
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
||||
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
|
||||
requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
|
||||
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
||||
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
||||
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
|
||||
const QUEUE_DESCRIPTION: &str = "Queue only the explicitly requested Ticket for Orchestrator routing through the typed \
|
||||
Ticket backend, regardless of state or dependencies. Dependency diagnostics are retained; no dependencies are auto-queued.";
|
||||
const WORKFLOW_STATE_DESCRIPTION: &str = "Change Ticket state with a bounded audited state_changed event. \
|
||||
State and dependency metadata do not impose an implementation phase path. Reopening does not revive released work identities.";
|
||||
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
|
||||
backend. The backend sets the Ticket state to closed and appends a close event.";
|
||||
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
|
||||
@@ -321,6 +318,18 @@ impl TicketBackend for TicketToolBackend {
|
||||
self.backend.set_workflow_state(id, change)
|
||||
}
|
||||
|
||||
fn update_state(
|
||||
&self,
|
||||
ticket: &str,
|
||||
request: crate::TicketStateUpdate,
|
||||
) -> TicketResult<Ticket> {
|
||||
self.backend.update_state(ticket, request)
|
||||
}
|
||||
|
||||
fn complete(&self, ticket: &str, request: crate::TicketCompletion) -> TicketResult<Ticket> {
|
||||
self.backend.complete(ticket, request)
|
||||
}
|
||||
|
||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult<Ticket> {
|
||||
self.backend.mark_ready(id, request)
|
||||
}
|
||||
@@ -1953,7 +1962,7 @@ mod tests {
|
||||
) -> crate::Result<crate::ResolvedTicketTarget> {
|
||||
Ok(crate::ResolvedTicketTarget {
|
||||
repository_key: repository_key.to_owned(),
|
||||
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
|
||||
ref_selector: Some(ref_selector.unwrap_or("develop").to_owned()),
|
||||
access: crate::TicketTargetAccess::ReadOnly,
|
||||
})
|
||||
}
|
||||
@@ -2061,15 +2070,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_tool_description_explains_queued_acceptance() {
|
||||
fn state_tool_description_explains_phase_independence() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let definition = ticket_tools(backend(&temp))
|
||||
.into_iter()
|
||||
.find(|definition| definition().0.name == "TicketWorkflowState")
|
||||
.expect("state tool exists");
|
||||
let (meta, _) = definition();
|
||||
assert!(meta.description.contains("queued -> inprogress"));
|
||||
assert!(meta.description.contains("implementation side effects"));
|
||||
assert!(
|
||||
meta.description
|
||||
.contains("do not impose an implementation phase path")
|
||||
);
|
||||
assert!(
|
||||
meta.description
|
||||
.contains("does not revive released work identities")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2668,10 +2683,10 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(queued.summary.contains("2 ticket(s)"));
|
||||
assert!(queued.summary.contains("1 ticket(s)"));
|
||||
let queued_content = queued.content.unwrap();
|
||||
assert!(queued_content.contains(&target_key));
|
||||
assert!(queued_content.contains(&dependency_key));
|
||||
assert!(!queued_content.contains(&dependency_key));
|
||||
assert!(!queued_content.contains(&target.id));
|
||||
assert!(!queued_content.contains(&dependency.id));
|
||||
|
||||
@@ -2931,79 +2946,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tool_rejects_disallowed_transition_graph_edges() {
|
||||
async fn ticket_workflow_tool_allows_direct_state_changes_without_phase_path() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
|
||||
|
||||
let mut ready_input = NewTicket::new("Ready Bypass");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
let ready_error = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": ready.id,
|
||||
"from": "ready",
|
||||
"to": "inprogress",
|
||||
"reason": "bypass_queue",
|
||||
"body": "Should not bypass Queue.\n"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
ready_error
|
||||
.to_string()
|
||||
.contains("invalid ticket workflow transition")
|
||||
);
|
||||
|
||||
let mut done_input = NewTicket::new("Backward Bypass");
|
||||
done_input.workflow_state = Some(TicketWorkflowState::Done);
|
||||
let done = backend.create(done_input).unwrap();
|
||||
let backward_error = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": done.id,
|
||||
"from": "done",
|
||||
"to": "planning",
|
||||
"reason": "backwards",
|
||||
"body": "Should not move backwards.\n"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
backward_error
|
||||
.to_string()
|
||||
.contains("invalid ticket workflow transition")
|
||||
);
|
||||
|
||||
let mut queued_input = NewTicket::new("Skip Bypass");
|
||||
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||
let queued = backend.create(queued_input).unwrap();
|
||||
let skip_error = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": queued.id,
|
||||
"from": "queued",
|
||||
"to": "done",
|
||||
"reason": "skip_inprogress",
|
||||
"body": "Should not skip inprogress.\n"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
skip_error
|
||||
.to_string()
|
||||
.contains("invalid ticket workflow transition")
|
||||
);
|
||||
for (from, to) in [
|
||||
(TicketWorkflowState::Ready, TicketWorkflowState::InProgress),
|
||||
(TicketWorkflowState::Done, TicketWorkflowState::Planning),
|
||||
(TicketWorkflowState::Queued, TicketWorkflowState::Done),
|
||||
] {
|
||||
let mut input = NewTicket::new(format!("{from} to {to}"));
|
||||
input.workflow_state = Some(from);
|
||||
let ticket = backend.create(input).unwrap();
|
||||
workflow.execute(&json!({"ticket": ticket.id, "from": from.as_str(), "to": to.as_str(), "reason": "explicit_update", "body": "State metadata updated."}).to_string(), Default::default()).await.unwrap();
|
||||
assert_eq!(
|
||||
backend.show(ticket.id.into()).unwrap().meta.workflow_state,
|
||||
to
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -41,13 +41,12 @@ fn workflow_instruction() -> FeatureInstructionDeclaration {
|
||||
.expect("static Merge Request workflow instruction declaration is valid")
|
||||
}
|
||||
|
||||
const ALL_KINDS: [Kind; 6] = [
|
||||
const ALL_KINDS: [Kind; 5] = [
|
||||
Kind::Show,
|
||||
Kind::Open,
|
||||
Kind::Review,
|
||||
Kind::Readiness,
|
||||
Kind::Complete,
|
||||
Kind::CompleteTicket,
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -56,7 +55,6 @@ enum Kind {
|
||||
Readiness,
|
||||
Open,
|
||||
Complete,
|
||||
CompleteTicket,
|
||||
Review,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
@@ -98,14 +96,6 @@ struct CompleteMergeRequestInput {
|
||||
resolution: MergeResolutionInput,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct CompleteTicketInput {
|
||||
ticket: String,
|
||||
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")]
|
||||
enum MergeStrategyInput {
|
||||
FastForward,
|
||||
@@ -150,7 +140,7 @@ impl Kind {
|
||||
Self::Open => config.open,
|
||||
Self::Review => config.review,
|
||||
Self::Readiness => config.readiness_check,
|
||||
Self::Complete | Self::CompleteTicket => config.complete,
|
||||
Self::Complete => config.complete,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +150,6 @@ impl Kind {
|
||||
Self::Readiness => "CheckMergeRequestReadiness",
|
||||
Self::Open => "OpenMergeRequest",
|
||||
Self::Complete => "CompleteMergeRequest",
|
||||
Self::CompleteTicket => "CompleteTicket",
|
||||
Self::Review => "ReviewMergeRequest",
|
||||
}
|
||||
}
|
||||
@@ -170,16 +159,12 @@ impl Kind {
|
||||
Self::Readiness => json!(schemars::schema_for!(MergeRequestInput)),
|
||||
Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
|
||||
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
|
||||
Self::CompleteTicket => json!(schemars::schema_for!(CompleteTicketInput)),
|
||||
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
|
||||
}
|
||||
}
|
||||
|
||||
fn mutating(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Open | Self::Review | Self::Complete | Self::CompleteTicket
|
||||
)
|
||||
matches!(self, Self::Open | Self::Review | Self::Complete)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -240,23 +225,6 @@ impl Tool for MergeRequestTool {
|
||||
),
|
||||
)
|
||||
}
|
||||
Kind::CompleteTicket => {
|
||||
let v: CompleteTicketInput = parse(input)?;
|
||||
nonempty_named("ticket", &v.ticket)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"/api/w/{ws}/tickets/{}/complete",
|
||||
encode_path_segment(&v.ticket)
|
||||
),
|
||||
Some(json!({
|
||||
"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,
|
||||
})),
|
||||
)
|
||||
}
|
||||
Kind::Review => {
|
||||
let v: ReviewMergeRequestInput = parse(input)?;
|
||||
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
||||
@@ -499,11 +467,6 @@ fn native_merge_request_operation(kind: Kind) -> NativeMergeRequestOperation {
|
||||
surface: NativeMergeRequestSurface::Item,
|
||||
identity_field: Some("merge_request_id"),
|
||||
},
|
||||
Kind::CompleteTicket => NativeMergeRequestOperation {
|
||||
operation: "complete_ticket",
|
||||
surface: NativeMergeRequestSurface::Collection,
|
||||
identity_field: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,8 +547,8 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
|
||||
let collection_descriptor = merge_request_descriptor(
|
||||
&collection_tools,
|
||||
"Open Merge Requests and complete Tickets through existing scoped authority",
|
||||
"Only collection-scoped Merge Request Feature operations enabled for this Worker are published. Opening retains repository and selector inputs; Ticket completion retains its full guarded result-set inputs.",
|
||||
"Open Merge Requests through existing scoped authority",
|
||||
"Only collection-scoped Merge Request Feature operations enabled for this Worker are published. Opening retains repository and selector inputs; Ticket completion is a separate Ticket operation.",
|
||||
)?;
|
||||
registry.mount(WipProjection {
|
||||
route: collection_route.clone(),
|
||||
@@ -617,7 +580,7 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
let item_descriptor = merge_request_descriptor(
|
||||
&item_tools,
|
||||
"Inspect and operate on the Merge Request bound to this object route",
|
||||
"The subject Merge Request identity comes exclusively from the target route. Repository selectors, exact review capability/candidate, Ticket result snapshot, approval, target refs, strategy, and resolution remain ordinary Backend-validated preconditions.",
|
||||
"The subject Merge Request identity comes exclusively from the target route. Repository selectors, exact review capability/candidate, approval, target refs, strategy, and resolution remain ordinary Backend-validated preconditions.",
|
||||
)?;
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
@@ -665,18 +628,6 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
|
||||
.get(operation)
|
||||
.ok_or_else(merge_request_operation_not_found)?;
|
||||
let input = native_merge_request_input(arguments, None, tool.projection.identity_field)?;
|
||||
let affected_merge_requests = if tool.kind == Kind::CompleteTicket {
|
||||
input
|
||||
.get("merge_request_ids")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let output =
|
||||
execute_native_merge_request_tool(tool, &self.permissions, input, None, context)
|
||||
.await?;
|
||||
@@ -699,18 +650,6 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
|
||||
);
|
||||
}
|
||||
}
|
||||
Kind::CompleteTicket => {
|
||||
for reference in affected_merge_requests {
|
||||
if is_merge_request_route_reference(&reference) {
|
||||
record_merge_request_observation(
|
||||
&self.revisions,
|
||||
&reference,
|
||||
&response,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(WipOperationOutput::native(
|
||||
@@ -1148,9 +1087,6 @@ pub fn description(n: &str) -> Option<&'static str> {
|
||||
"CompleteMergeRequest" => Some(
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
|
||||
),
|
||||
"CompleteTicket" => Some(
|
||||
"Complete the Ticket only after reviewing its current item revision and exact linked Merge Request result set; this ends unfinished work atomically while retaining responsibility and assignment history.",
|
||||
),
|
||||
"ReviewMergeRequest" => Some(
|
||||
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
||||
),
|
||||
@@ -1220,7 +1156,6 @@ mod tests {
|
||||
"CheckMergeRequestReadiness",
|
||||
"OpenMergeRequest",
|
||||
"CompleteMergeRequest",
|
||||
"CompleteTicket",
|
||||
"ReviewMergeRequest",
|
||||
] {
|
||||
assert!(description(name).is_some(), "missing operation {name}");
|
||||
@@ -1369,8 +1304,7 @@ mod tests {
|
||||
[
|
||||
"ShowMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"CompleteMergeRequest",
|
||||
"CompleteTicket"
|
||||
"CompleteMergeRequest"
|
||||
]
|
||||
);
|
||||
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
||||
@@ -1427,7 +1361,6 @@ mod tests {
|
||||
(NativeMergeRequestSurface::Item, "read"),
|
||||
(NativeMergeRequestSurface::Item, "check_readiness"),
|
||||
(NativeMergeRequestSurface::Item, "complete"),
|
||||
(NativeMergeRequestSurface::Collection, "complete_ticket"),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -176,6 +176,8 @@ struct ModelMergeRequest {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvidence {
|
||||
/// MR evidence is inapplicable without a linked MR, independent of Ticket decisions.
|
||||
integration_applicable: bool,
|
||||
has_merge_request: bool,
|
||||
has_current_subject_ref: bool,
|
||||
has_review_request: bool,
|
||||
@@ -535,8 +537,10 @@ fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
|
||||
|
||||
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
|
||||
let evidence = object(value, "Ticket evidence")?;
|
||||
Ok(ModelTicketEvidence {
|
||||
has_merge_request: bool_field(evidence, "has_merge_request")?,
|
||||
let has_merge_request = bool_field(evidence, "has_merge_request")?;
|
||||
let projected = ModelTicketEvidence {
|
||||
integration_applicable: has_merge_request,
|
||||
has_merge_request,
|
||||
has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
|
||||
has_review_request: bool_field(evidence, "has_review_request")?,
|
||||
has_commit: bool_field(evidence, "has_commit")?,
|
||||
@@ -546,7 +550,20 @@ fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
|
||||
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
|
||||
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
|
||||
missing: string_array(evidence, "missing")?,
|
||||
})
|
||||
};
|
||||
if !has_merge_request {
|
||||
// Empty MR sets are neither missing evidence nor vacuous approval.
|
||||
return Ok(ModelTicketEvidence {
|
||||
review_status: None,
|
||||
approved_current_subject: false,
|
||||
review_after_rescope: false,
|
||||
unresolved_request_changes: false,
|
||||
complete_for_integration: false,
|
||||
missing: Vec::new(),
|
||||
..projected
|
||||
});
|
||||
}
|
||||
Ok(projected)
|
||||
}
|
||||
|
||||
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
|
||||
@@ -677,6 +694,38 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-718", "item_revision": "rev-1", "title": "No Git required", "body": "Decision",
|
||||
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
|
||||
"linked_objectives": [], "assignments": [], "implementation_reports": [],
|
||||
"merge_requests": [], "merge_request": null,
|
||||
"evidence": {
|
||||
"has_merge_request": false, "has_current_subject_ref": false, "has_review_request": false, "has_commit": false,
|
||||
"review_status": "approved", "approved_current_subject": true, "review_after_rescope": true,
|
||||
"unresolved_request_changes": false, "complete_for_integration": true, "missing": ["merge_request"]
|
||||
}
|
||||
})).unwrap();
|
||||
let projected = serde_json::to_value(projected).unwrap();
|
||||
assert_eq!(projected["state"], state);
|
||||
assert_eq!(projected["thread"][0]["kind"], "completed");
|
||||
assert_eq!(projected["evidence"]["integration_applicable"], false);
|
||||
assert_eq!(projected["evidence"]["review_status"], Value::Null);
|
||||
assert_eq!(projected["evidence"]["approved_current_subject"], false);
|
||||
assert_eq!(projected["evidence"]["complete_for_integration"], false);
|
||||
assert_eq!(projected["evidence"]["missing"], json!([]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_projection_exposes_only_resource_references() {
|
||||
let projected = project_objective_detail(json!({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6119,17 +6119,6 @@ mod tests {
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({
|
||||
"operation_id": "complete-ticket-1",
|
||||
"ticket_id": "ticket-internal",
|
||||
"item_revision": "ticket-rev-1",
|
||||
"merge_request_ids": ["MR-1"],
|
||||
"requirement_approval_event_id": "review-1"
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
]));
|
||||
let orchestrator_config = MergeRequestFeatureConfig {
|
||||
readiness_check: true,
|
||||
@@ -6202,51 +6191,22 @@ mod tests {
|
||||
.inspect("yoi.merge-request/collection/v1".into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let before_ticket_completion = orchestrator
|
||||
.host
|
||||
.projection(&item)
|
||||
.unwrap()
|
||||
.object
|
||||
.validator;
|
||||
orchestrator
|
||||
let rejected = orchestrator
|
||||
.call(
|
||||
collection.into(),
|
||||
"yoi.merge-request/collection/v1".into(),
|
||||
"complete_ticket".into(),
|
||||
json!({
|
||||
"ticket": "T-685",
|
||||
"operation_id": "complete-ticket-1",
|
||||
"item_revision": "ticket-rev-1",
|
||||
"merge_request_ids": ["MR-1"],
|
||||
"requirement_approval_event_id": "review-1"
|
||||
}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
before_ticket_completion,
|
||||
orchestrator
|
||||
.host
|
||||
.projection(&item)
|
||||
.unwrap()
|
||||
.object
|
||||
.validator,
|
||||
"Ticket completion must stale every Merge Request in its exact result set"
|
||||
);
|
||||
let stale = orchestrator
|
||||
.call(
|
||||
item,
|
||||
"yoi.merge-request/item/v1".into(),
|
||||
"check_readiness".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.expect_err("affected Merge Request observation must be stale");
|
||||
assert!(stale.to_string().contains("ValidatorMismatch"));
|
||||
.expect_err("Ticket completion is not a Merge Request operation");
|
||||
assert!(
|
||||
rejected.to_string().contains("operation is not declared"),
|
||||
"{rejected}"
|
||||
);
|
||||
let orchestrator_requests = orchestrator_client.requests();
|
||||
assert_eq!(orchestrator_requests.len(), 3);
|
||||
assert_eq!(orchestrator_requests.len(), 2);
|
||||
assert_eq!(
|
||||
orchestrator_requests[0].path,
|
||||
"/api/w/workspace/merge-requests/MR-1/readiness"
|
||||
@@ -6260,10 +6220,6 @@ mod tests {
|
||||
assert_eq!(completion_body["approval_event_id"], "review-1");
|
||||
assert_eq!(completion_body["target_ref_before"], "target-1");
|
||||
assert_eq!(completion_body["target_ref_after"], "result-1");
|
||||
assert_eq!(
|
||||
orchestrator_requests[2].path,
|
||||
"/api/w/workspace/tickets/T-685/complete"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -801,27 +801,6 @@ impl SqliteWorkspaceAuthority {
|
||||
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 {
|
||||
let assignments = self
|
||||
.store
|
||||
.list_active_ticket_role_assignments(&self.workspace_id, ticket_id)?;
|
||||
if !assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator)
|
||||
{
|
||||
queue_assignment_blockers.push(format!(
|
||||
"Ticket {ticket_id} requires an active Orchestrator assignment"
|
||||
));
|
||||
}
|
||||
if assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Coder)
|
||||
{
|
||||
queue_assignment_blockers
|
||||
.push(format!("Ticket {ticket_id} has an active Coder assignment"));
|
||||
}
|
||||
}
|
||||
let mut assignment_diagnostics = Vec::new();
|
||||
if let Some(legacy_assignee) = ticket
|
||||
.meta
|
||||
@@ -833,19 +812,7 @@ impl SqliteWorkspaceAuthority {
|
||||
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
|
||||
));
|
||||
}
|
||||
let mut action_blockers = Vec::new();
|
||||
if !has_target {
|
||||
action_blockers.push("Ticket target is required".to_string());
|
||||
}
|
||||
if !dependency_check.queue_guard.can_queue_for_orchestrator {
|
||||
if let Some(reason) = dependency_check.queue_guard.blocked_reason.clone() {
|
||||
action_blockers.push(reason);
|
||||
} else if let Some(reason) = dependency_check.queue_guard.reason.clone() {
|
||||
action_blockers.push(reason);
|
||||
}
|
||||
}
|
||||
let queue_assignments_valid = queue_assignment_blockers.is_empty();
|
||||
action_blockers.extend(queue_assignment_blockers);
|
||||
let action_blockers = Vec::new();
|
||||
let action_eligibility = TicketActionEligibility {
|
||||
can_assign_orchestrator: matches!(
|
||||
ticket.meta.workflow_state,
|
||||
@@ -857,12 +824,7 @@ impl SqliteWorkspaceAuthority {
|
||||
ticket.meta.workflow_state,
|
||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready
|
||||
),
|
||||
can_queue: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
||||
&& has_orchestrator
|
||||
&& !has_coder
|
||||
&& has_target
|
||||
&& dependency_check.queue_guard.can_queue_for_orchestrator
|
||||
&& queue_assignments_valid,
|
||||
can_queue: true,
|
||||
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
||||
&& !has_orchestrator
|
||||
&& !has_coder
|
||||
@@ -1901,16 +1863,13 @@ fn ticket_requirement_approved(
|
||||
}
|
||||
|
||||
fn ticket_evidence_summary(
|
||||
ticket_write_repository_keys: &[&str],
|
||||
_ticket_write_repository_keys: &[&str],
|
||||
merge_requests: &[TicketMergeRequestSummary],
|
||||
requirement_approved: bool,
|
||||
) -> TicketEvidenceSummary {
|
||||
let relevant = merge_requests
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
request.state != "closed"
|
||||
&& ticket_write_repository_keys.contains(&request.repository_key.as_str())
|
||||
})
|
||||
.filter(|request| request.state != "closed")
|
||||
.collect::<Vec<_>>();
|
||||
let has_merge_request = !relevant.is_empty();
|
||||
let has_current_subject_ref = has_merge_request
|
||||
@@ -1956,9 +1915,6 @@ fn ticket_evidence_summary(
|
||||
Some("pending".into())
|
||||
};
|
||||
let mut missing = Vec::new();
|
||||
if !has_merge_request {
|
||||
missing.push("merge_request".into());
|
||||
}
|
||||
if observation_unavailable {
|
||||
missing.push("source_ref_unavailable".into());
|
||||
}
|
||||
@@ -1968,16 +1924,16 @@ fn ticket_evidence_summary(
|
||||
{
|
||||
missing.push("integration_evidence".into());
|
||||
}
|
||||
if !has_current_subject_ref {
|
||||
if has_merge_request && !has_current_subject_ref {
|
||||
missing.push("current_subject_ref".into());
|
||||
}
|
||||
if !has_commit {
|
||||
if has_merge_request && !has_commit {
|
||||
missing.push("commit".into());
|
||||
}
|
||||
if unresolved_request_changes {
|
||||
missing.push("unresolved_request_changes".into());
|
||||
}
|
||||
if !approved_current_subject {
|
||||
if has_merge_request && !approved_current_subject {
|
||||
missing.push("approved_current_subject".into());
|
||||
}
|
||||
if approved_current_subject && !requirement_approved {
|
||||
@@ -1992,7 +1948,7 @@ fn ticket_evidence_summary(
|
||||
approved_current_subject,
|
||||
review_after_rescope,
|
||||
unresolved_request_changes,
|
||||
complete_for_integration: missing.is_empty(),
|
||||
complete_for_integration: has_merge_request && missing.is_empty(),
|
||||
missing,
|
||||
}
|
||||
}
|
||||
@@ -2202,7 +2158,7 @@ fn ticket_attention_matches(
|
||||
.iter()
|
||||
.any(|reason| reason == "review_after_rescope")
|
||||
}
|
||||
"missing_evidence" => !evidence.complete_for_integration,
|
||||
"missing_evidence" => evidence.has_merge_request && !evidence.complete_for_integration,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -3227,7 +3183,7 @@ mod tests {
|
||||
assert!(
|
||||
ticket_evidence_summary(&["main", "other"], &summaries, true).complete_for_integration
|
||||
);
|
||||
assert!(!ticket_evidence_summary(&["docs"], &summaries, true).has_merge_request);
|
||||
assert!(ticket_evidence_summary(&["docs"], &summaries, true).has_merge_request);
|
||||
let mut closed = reviewed_merge_request(ReviewDecision::Approve, false);
|
||||
closed.state = MergeRequestState::Closed;
|
||||
assert!(
|
||||
@@ -3413,19 +3369,16 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket");
|
||||
assert_eq!(tickets.items[0].id, "00000000001J2");
|
||||
assert_eq!(tickets.items[0].state, "ready");
|
||||
assert_eq!(tickets.items[0].workspace_action_priority, "background");
|
||||
assert_eq!(
|
||||
tickets.items[0].workspace_action_priority,
|
||||
"ready_for_queue"
|
||||
);
|
||||
let ticket_by_key = authority.ticket(&tickets.items[0].resource_key).unwrap();
|
||||
assert_eq!(ticket_by_key.id, tickets.items[0].id);
|
||||
|
||||
let ticket = authority.ticket("00000000001J2").unwrap();
|
||||
assert!(!ticket.action_eligibility.can_queue);
|
||||
assert!(
|
||||
ticket
|
||||
.action_eligibility
|
||||
.blockers
|
||||
.iter()
|
||||
.any(|reason| reason.contains("00000000001J6"))
|
||||
);
|
||||
assert!(ticket.action_eligibility.can_queue);
|
||||
assert!(ticket.action_eligibility.blockers.is_empty());
|
||||
assert!(ticket.body.contains("Ticket body"));
|
||||
assert!(ticket.body_truncated);
|
||||
assert!(!ticket.body.contains("Deep Ticket marker"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,9 @@
|
||||
use super::*;
|
||||
|
||||
use merge_request::{
|
||||
CompleteMergeRequest, CompleteTicket, ConflictResolution, MergeRequestAuth,
|
||||
MergeRequestReviewSubject, MergeRequestStore, MergeStrategy, OpenMergeRequest,
|
||||
RegisterReviewerChildSession, RequestMergeRequestReview, ReviewDecision, ReviewEvent,
|
||||
SubmitMergeRequestReview,
|
||||
CompleteMergeRequest, ConflictResolution, MergeRequestAuth, MergeRequestReviewSubject,
|
||||
MergeRequestStore, MergeStrategy, OpenMergeRequest, RegisterReviewerChildSession,
|
||||
RequestMergeRequestReview, ReviewDecision, ReviewEvent, SubmitMergeRequestReview,
|
||||
};
|
||||
use rusqlite::{Connection, params};
|
||||
use server_api::{
|
||||
@@ -192,15 +191,14 @@ impl EvidenceApiFixture {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn completion(&self, approval: &ReviewEvent) -> CompleteTicket {
|
||||
CompleteTicket {
|
||||
ticket_id: self.ticket_id.clone(),
|
||||
operation_id: "evidence-ticket-completion".into(),
|
||||
item_revision: self.revision(),
|
||||
merge_request_ids: vec![EVIDENCE_MR.into()],
|
||||
requirement_approval_event_id: approval.event_id.clone(),
|
||||
auth: self.auth.clone(),
|
||||
now: evidence_time(),
|
||||
fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion {
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "evidence-ticket-completion".into(),
|
||||
expected_item_revision: self.revision(),
|
||||
expected_state: TicketWorkflowState::InProgress,
|
||||
reason: "Implementation judged complete independently of MR attestation".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("fixture".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,10 +303,10 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
|
||||
let merge = fixture.integrate(&approval);
|
||||
let revision = fixture.revision();
|
||||
let completion = fixture
|
||||
.store
|
||||
.complete_ticket(fixture.completion(&approval))
|
||||
.backend
|
||||
.complete(&fixture.ticket_id, fixture.completion(&approval))
|
||||
.unwrap();
|
||||
assert_eq!(completion.item_revision, revision);
|
||||
assert_eq!(ticket::ticket_item_revision(&completion), revision);
|
||||
assert!(
|
||||
fixture
|
||||
.api
|
||||
@@ -398,16 +396,13 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
);
|
||||
fixture.assert_filters(false, true, true).await;
|
||||
let mut outdated_completion = fixture.completion(&integration);
|
||||
outdated_completion.item_revision = integration.ticket_item_revision.clone();
|
||||
assert!(matches!(
|
||||
fixture.store.complete_ticket(outdated_completion),
|
||||
Err(merge_request::MergeRequestError::Conflict(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
fixture.store.complete_ticket(fixture.completion(&integration)),
|
||||
Err(merge_request::MergeRequestError::NotReady(message))
|
||||
if message == merge_request::MergeRequestEvidenceError::ItemRevisionMismatch.as_str()
|
||||
));
|
||||
outdated_completion.expected_item_revision = integration.ticket_item_revision.clone();
|
||||
assert!(
|
||||
fixture
|
||||
.backend
|
||||
.complete(&fixture.ticket_id, outdated_completion)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(fixture.show().await.state, "inprogress");
|
||||
|
||||
let latest = fixture.approve("latest-revision");
|
||||
@@ -425,11 +420,6 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
Some("Requirement approval integration")
|
||||
);
|
||||
fixture.assert_filters(true, false, false).await;
|
||||
assert!(matches!(
|
||||
fixture.store.complete_ticket(fixture.completion(&integration)),
|
||||
Err(merge_request::MergeRequestError::NotReady(message))
|
||||
if message == merge_request::MergeRequestEvidenceError::ApprovalNotEffective.as_str()
|
||||
));
|
||||
// A current revision alone is insufficient: simulate a stored attestation
|
||||
// against a different result set using typed serialized payloads in this
|
||||
// temporary fixture database, never a live Workspace database.
|
||||
@@ -477,11 +467,6 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
assert!(wrong.evidence.approved_current_subject);
|
||||
assert!(!wrong.evidence.complete_for_integration);
|
||||
fixture.assert_filters(false, true, true).await;
|
||||
assert!(matches!(
|
||||
fixture.store.complete_ticket(fixture.completion(&latest)),
|
||||
Err(merge_request::MergeRequestError::NotReady(message))
|
||||
if message == merge_request::MergeRequestEvidenceError::SourceSnapshotMismatch.as_str()
|
||||
));
|
||||
assert_eq!(fixture.show().await.state, "inprogress");
|
||||
|
||||
let repaired = fixture.approve("exact-snapshot");
|
||||
@@ -502,11 +487,10 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
|
||||
assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE));
|
||||
assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET));
|
||||
let completion = fixture
|
||||
.store
|
||||
.complete_ticket(fixture.completion(&repaired))
|
||||
.backend
|
||||
.complete(&fixture.ticket_id, fixture.completion(&repaired))
|
||||
.unwrap();
|
||||
assert_eq!(completion.item_revision, revision);
|
||||
assert_eq!(completion.requirement_approval_event_id, repaired.event_id);
|
||||
assert_eq!(ticket::ticket_item_revision(&completion), revision);
|
||||
assert_eq!(fixture.show().await.state, "done");
|
||||
fixture.assert_filters(true, false, false).await;
|
||||
}
|
||||
@@ -651,3 +635,187 @@ fn source_observation_diagnostics_preserve_provider_failure_codes() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_state() {
|
||||
let fixture = EvidenceApiFixture::new().await;
|
||||
let before = fixture
|
||||
.store
|
||||
.get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR)
|
||||
.unwrap();
|
||||
fixture
|
||||
.backend
|
||||
.update_state(
|
||||
&fixture.ticket_id,
|
||||
ticket::TicketStateUpdate {
|
||||
operation_key: "close-with-open-mr".into(),
|
||||
expected_item_revision: fixture.revision(),
|
||||
expected_state: TicketWorkflowState::InProgress,
|
||||
state: TicketWorkflowState::Closed,
|
||||
reason: "The request was withdrawn; no integration or approval is asserted".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("workspace-user".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let after = fixture
|
||||
.store
|
||||
.get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(after).unwrap(),
|
||||
serde_json::to_value(before).unwrap()
|
||||
);
|
||||
let shown = fixture.show().await;
|
||||
assert_eq!(shown.state, "closed");
|
||||
assert!(!shown.evidence.approved_current_subject);
|
||||
assert_eq!(shown.merge_requests[0].state, "open");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_evidence() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api_with_remote_repository(temp.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let reference = backend
|
||||
.create(ticket::NewTicket::new("Investigation with no repository"))
|
||||
.unwrap();
|
||||
let ticket = backend.show(reference.id.clone().into()).unwrap();
|
||||
backend
|
||||
.complete(
|
||||
&reference.id,
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "research-result".into(),
|
||||
expected_item_revision: ticket::ticket_item_revision(&ticket),
|
||||
expected_state: TicketWorkflowState::Planning,
|
||||
reason: "Answer recorded in the Ticket thread".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("workspace-user".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let app = build_inner_router(api);
|
||||
let shown: TicketDetail = serde_json::from_value(
|
||||
get_json(
|
||||
app.clone(),
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(shown.state, "done");
|
||||
assert!(!shown.evidence.has_merge_request);
|
||||
assert!(!shown.evidence.approved_current_subject);
|
||||
assert!(!shown.evidence.complete_for_integration);
|
||||
assert!(shown.evidence.missing.is_empty());
|
||||
assert!(shown.evidence.review_status.is_none());
|
||||
for filter in [
|
||||
json!({"attention":["missing_evidence"]}),
|
||||
json!({"evidence":["approved_review"]}),
|
||||
] {
|
||||
let query: TicketQueryResponse = serde_json::from_value(
|
||||
request_json(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"),
|
||||
Some(filter),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(query.items.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_receipts() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api_with_remote_repository(temp.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let reference = backend
|
||||
.create(ticket::NewTicket::new("Transport decisions"))
|
||||
.unwrap();
|
||||
let before = backend.show(reference.id.clone().into()).unwrap();
|
||||
let app = build_inner_router(api);
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id);
|
||||
for suffix in ["workflow-state", "state-changes", "state-fields/state"] {
|
||||
request_json(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("{path}/{suffix}"),
|
||||
Some(json!({
|
||||
"from":"planning","to":"done","reason":"old unguarded request","body":""
|
||||
})),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
request_json(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("{path}/workflow/close"),
|
||||
Some(json!("unguarded close")),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
.await;
|
||||
let close = json!({
|
||||
"operation_key":"guarded-close","expected_item_revision":ticket::ticket_item_revision(&before),
|
||||
"expected_state":"planning","reason":"No further work required","author":"spoofed-client-author"
|
||||
});
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("{path}/workflow/close"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(close.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
let closed = backend.show(reference.id.clone().into()).unwrap();
|
||||
assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed);
|
||||
let receipt = closed.events.last().unwrap();
|
||||
assert_eq!(receipt.author.as_deref(), Some("workspace-user"));
|
||||
assert_eq!(receipt.from.as_deref(), Some("planning"));
|
||||
assert_eq!(
|
||||
receipt.attributes.get("operation_key"),
|
||||
Some(&"guarded-close".to_string())
|
||||
);
|
||||
let reopen = json!({
|
||||
"operation_key":"reopen","expected_item_revision":ticket::ticket_item_revision(&closed),
|
||||
"expected_state":"closed","state":"planning","reason":"A new question arrived"
|
||||
});
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("{path}/workflow-state"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(reopen.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
// Exact replay of the old close is a receipt lookup, not another close.
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("{path}/workflow/close"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(close.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
let reopened = backend.show(reference.id.into()).unwrap();
|
||||
assert_eq!(reopened.meta.workflow_state, TicketWorkflowState::Planning);
|
||||
assert_eq!(reopened.events.len(), closed.events.len() + 1);
|
||||
}
|
||||
|
||||
@@ -89,14 +89,7 @@ impl TicketItemCheckFindingCategory {
|
||||
}
|
||||
|
||||
pub fn item_revision(ticket: &ticket::Ticket) -> String {
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|event| matches!(event.kind.as_str(), "create" | "item_edit"))
|
||||
.and_then(|event| event.attributes.get("event_id").cloned())
|
||||
.or_else(|| ticket.meta.updated_at.clone())
|
||||
.unwrap_or_else(|| format!("{}:0", ticket.meta.id))
|
||||
ticket::ticket_item_revision(ticket)
|
||||
}
|
||||
|
||||
pub fn request(
|
||||
|
||||
Reference in New Issue
Block a user