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:
2026-10-08 19:12:39 +09:00
parent e939cd4e4b
commit df390f02e9
25 changed files with 4596 additions and 2292 deletions
+184 -4
View File
@@ -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(&current),
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(&current),
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"}"##;
+2 -224
View File
@@ -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
View File
@@ -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'",
[],
+75 -37
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+46 -86
View File
@@ -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
+7 -51
View File
@@ -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]
+16 -63
View File
@@ -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(
+5 -6
View File
@@ -24,7 +24,6 @@ Collection interface `yoi.merge-request/collection/v1`:
| Native operation | Existing Tool | Preserved authority inputs |
| --- | --- | --- |
| `open` | `OpenMergeRequest` | Ticket, repository key, source selector, target selector, summary |
| `complete_ticket` | `CompleteTicket` | Ticket, operation ID, current item revision, exact linked-MR result set, requirement approval event |
Item interface `yoi.merge-request/item/v1`:
@@ -39,9 +38,9 @@ Typical role surfaces are therefore:
- Coder: collection `open`; item `read`.
- Reviewer: item `read` and `review`.
- Orchestrator: collection `complete_ticket`; item `read`, `check_readiness`, and `complete`.
- Orchestrator: item `read`, `check_readiness`, and `complete`.
`complete_ticket` is deliberately collection-scoped because its subject is a Ticket and an exact set of linked Merge Request results rather than one route-bound MR. It keeps those identities explicit. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run.
`CompleteTicket` belongs to the Ticket Feature and route-bound Ticket item `complete`, alongside state and close decisions. It takes current item/state CAS, a replay key, reason, and optional references; it requires neither an MR result set nor a Reviewer approval. The former MR collection `complete_ticket` operation is not published. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run.
## Preserved review and integration gates
@@ -55,8 +54,8 @@ The native projection delegates to the same ordinary tools and REST endpoints. I
- source movement invalidating source approval while target-only movement preserves unchanged-source review;
- unresolved request-change and revoked/stale approval rejection;
- current target observation, target-before/after proof, strategy, and conflict-resolution checks;
- Orchestrator-only Merge Request and Ticket completion;
- immutable integration/result evidence and exact linked-result-set Ticket completion.
- Orchestrator-only Merge Request integration;
- immutable integration/result evidence and exact linked-result-set requirement attestation.
The Worldspace adapter performs no Git or provider integration. A visible `complete` operation remains unusable without the ordinary Backend's authenticated online Orchestrator source and integration evidence.
@@ -64,7 +63,7 @@ The Worldspace adapter performs no Git or provider integration. A visible `compl
`read` exposes the existing bounded thread page with optional `after` and a `1..=200` limit. Responses keep linked Tickets as references/data, so Ticket or Objective native projections are not prerequisites. Descriptors never expand repository diffs, unbounded thread history, credentials, or capability material.
A successful item read records a response fingerprint. A successful item mutation advances a local mutation generation. Opening records the returned MR and adds its native path to the result. Ticket completion invalidates every MR route named in its exact result set because releasing the assignment can change later ref observations. The common WIP Host rejects calls made with stale object/interface validators; callers must rediscover before deciding whether to call again.
A successful item read records a response fingerprint. A successful item mutation advances a local mutation generation. Opening records the returned MR and adds its native path to the result. Ticket decisions advance the Ticket provider's object generation, not the MR integration state or proofs. Ending Ticket work can affect later live source observation, so clients reread MR authority before MR actions; Backend checks remain authoritative. The common WIP Host rejects calls made with stale object/interface validators; callers must rediscover before deciding whether to call again.
Optional fields preserve ordinary JSON-input semantics: omission remains omission and explicit WIP unit/null remains JSON `null` for schema and permission matching. The reconstructed object is validated against the original Tool JSON Schema before the Tool executes.
+4 -2
View File
@@ -12,11 +12,13 @@ A merged MR's review status/timestamps describe **integration approval**, not a
## Current requirements
`merge_request::requirement_approval` is shared by CompleteTicket and Ticket reads. It validates an effective Reviewer approval of the current item revision and the **exact complete linked MR/source snapshot**, including the hosting MR's own source and corresponding ReviewRequested event. There is no primary-MR fallback, comment-derived approval, or timestamp-only freshness rule. A post-integration review may attest a new revision/snapshot without replacing MergeResult or its integration approval.
`merge_request::requirement_approval` evaluates Ticket read-side MR evidence; it is not a Ticket completion gate. It validates an effective Reviewer approval of the current item revision and the **exact complete linked MR/source snapshot**, including the hosting MR's own source and corresponding ReviewRequested event. There is no primary-MR fallback, comment-derived approval, or timestamp-only freshness rule. A post-integration review may attest a new revision/snapshot without replacing MergeResult or its integration approval.
`approved_current_subject` describes valid source/integration approvals. `review_after_rescope` retains its wire name but now describes exact current requirement attestation, not ordering of wall-clock timestamps. `approved_review` query filtering requires that current attestation. `stale_after_rescope` requires substantive item-edit history and a known source-approved snapshot lacking current attestation; unknown observations are only `missing_evidence`.
`complete_for_integration` means the summary has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. CompleteTicket additionally requires all linked results merged and validates explicit approval identity, lifecycle, and assignment transactionally.
`complete_for_integration` means a nonempty linked MR set has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. An MRless Ticket has no applicable MR evidence: `missing` is empty, `review_status` is null, and approval/integration flags remain false. It does not match `missing_evidence` or `approved_review` merely because its state is done.
CompleteTicket belongs to the Ticket domain. It records the authenticated actor's reason/result and optional references using current item revision/state CAS and an operation receipt. It needs no MR or Reviewer approval, and does not create or modify those proofs. State setting, completion, close, and reopen share the same transactional update boundary. Exact replay returns current Ticket authority without reapplying a historical decision or appending an event. Historical MR-based `ticket_completion` records remain readable and distinct from new judgment receipts.
Writable targets authorize deliverables, not mandatory MRs for every target. Every linked MR remains part of the exact requirement snapshot; no arbitrary latest-review or single-repository shortcut is used.
@@ -0,0 +1,15 @@
# T-718: SubWorker の cwd・参照コンテキストに関する障壁
## 観測
T-718 の実装を `crates/ticket` / `crates/worker` / `crates/merge-request` に分担した。SubWorkerSpawn の cwd を各 crate に設定すると、子のファイル tool は `src/lib.rs` 等で成功する一方、親で使える repository-root-relative な `crates/<crate>/src/...` は crate prefix が二重になって失敗した。cwd 未指定の Glob は一部の子で transient bash-output 側のパスを logical path として扱い失敗した。
また builtin default の子には ShowTicket が公開されず、別途 read scope に含めたテスト規則の親 root-relative path も子の cwd から参照できなかった。親が authoritative Ticket とテスト規則を先に読み、要件・規則・インターフェースを本文で転送して作業を継続した。
## 改善案
- 子の startup context に実際の Workdir attachment alias・logical root・tool cwd を明示する。
- cwd と path root の関係を tool schema / Spawn 説明と一致させ、cwd 外に委譲した read-only scope の参照方法も示す。
- Ticket implementation の子へ typed Ticket read を委譲できない場合は、親に authoritative item revision と本文を渡す責任があることを明示する。
本件では委譲 scope を広げたり Backend storage を直接操作する回避はしていない。製品要件の追加ではなく、分担時の tooling/context で発生した調整コストの記録である。
+434 -27
View File
@@ -301,11 +301,23 @@
"BrowserCloseTicketRequest": {
"additionalProperties": false,
"properties": {
"expected_item_revision": {
"type": "string"
},
"expected_state": {
"$ref": "#/components/schemas/BrowserTicketWorkflowState"
},
"operation_key": {
"type": "string"
},
"resolution": {
"type": "string"
}
},
"required": [
"operation_key",
"expected_item_revision",
"expected_state",
"resolution"
],
"type": "object"
@@ -459,30 +471,34 @@
"BrowserTransitionTicketStateRequest": {
"additionalProperties": false,
"properties": {
"author": {
"type": [
"string",
"null"
]
},
"body": {
"type": [
"string",
"null"
]
},
"expected_item_revision": {
"type": "string"
},
"expected_state": {
"$ref": "#/components/schemas/BrowserTicketWorkflowState"
},
"operation_key": {
"type": "string"
},
"reason": {
"type": [
"string",
"null"
]
"type": "string"
},
"state": {
"$ref": "#/components/schemas/BrowserTicketWorkflowState"
}
},
"required": [
"state"
"state",
"operation_key",
"expected_item_revision",
"expected_state",
"reason"
],
"type": "object"
},
@@ -1075,6 +1091,37 @@
],
"type": "object"
},
"CompleteTicketRequest": {
"additionalProperties": false,
"properties": {
"expected_item_revision": {
"type": "string"
},
"expected_state": {
"$ref": "#/components/schemas/TicketWorkflowState"
},
"operation_key": {
"type": "string"
},
"reason": {
"type": "string"
},
"references": {
"default": [],
"items": {
"$ref": "#/components/schemas/TicketReference"
},
"type": "array"
}
},
"required": [
"operation_key",
"expected_item_revision",
"expected_state",
"reason"
],
"type": "object"
},
"CompletionContext": {
"properties": {
"argument": {
@@ -10199,6 +10246,45 @@
],
"type": "object"
},
"TicketCompletion": {
"additionalProperties": false,
"description": "Audited completion independent of repositories, Git, or Merge Requests.",
"properties": {
"author": {
"default": null,
"type": [
"string",
"null"
]
},
"expected_item_revision": {
"type": "string"
},
"expected_state": {
"$ref": "#/components/schemas/TicketWorkflowState"
},
"operation_key": {
"type": "string"
},
"reason": {
"type": "string"
},
"references": {
"default": [],
"items": {
"$ref": "#/components/schemas/TicketReference"
},
"type": "array"
}
},
"required": [
"expected_item_revision",
"expected_state",
"operation_key",
"reason"
],
"type": "object"
},
"TicketDependencyCheck": {
"properties": {
"blockers": {
@@ -12126,39 +12212,46 @@
}
]
},
"TicketStateChange": {
"TicketStateUpdate": {
"additionalProperties": false,
"description": "External state mutation with item/state CAS and an immutable operation receipt.\nExact replay returns the current authoritative Ticket without a new event or\nreapplying the recorded state, even after later edits or reopening.\nSetting `state` to Closed also stores `reason` as the resolution; reopening\nchanges Ticket metadata only and never revives released work identities.",
"properties": {
"author": {
"default": null,
"type": [
"string",
"null"
]
},
"body": {
"$ref": "#/components/schemas/MarkdownText"
"expected_item_revision": {
"type": "string"
},
"from": {
"expected_state": {
"$ref": "#/components/schemas/TicketWorkflowState"
},
"operation_key": {
"type": "string"
},
"reason": {
"type": "string"
},
"references": {
"default": [],
"items": {
"$ref": "#/components/schemas/TicketReference"
},
"type": "array"
},
"to": {
"type": "string"
"state": {
"$ref": "#/components/schemas/TicketWorkflowState"
}
},
"required": [
"from",
"to",
"reason",
"body",
"references"
"expected_item_revision",
"expected_state",
"state",
"operation_key",
"reason"
],
"type": "object"
},
@@ -15538,7 +15631,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8"
"x-yoi-source-digest": "sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6"
},
"openapi": "3.1.0",
"paths": {
@@ -31998,6 +32091,143 @@
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/tickets/{id}/complete": {
"post": {
"operationId": "ticket_complete",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CompleteTicketRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Ticket"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
@@ -33557,6 +33787,16 @@
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
@@ -33607,7 +33847,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketStateChange"
"$ref": "#/components/schemas/TicketStateUpdate"
}
}
},
@@ -33667,6 +33907,16 @@
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
@@ -33727,7 +33977,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketStateChange"
"$ref": "#/components/schemas/TicketStateUpdate"
}
}
},
@@ -33787,6 +34037,143 @@
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/tickets/{id}/state-update": {
"post": {
"operationId": "ticket_state_update",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketStateUpdate"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Ticket"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
@@ -33947,7 +34334,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketStateChange"
"$ref": "#/components/schemas/TicketStateUpdate"
}
}
},
@@ -34007,6 +34394,16 @@
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
@@ -34057,7 +34454,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MarkdownText"
"$ref": "#/components/schemas/TicketCompletion"
}
}
},
@@ -34117,6 +34514,16 @@
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,13 +1,13 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
export type BrowserCloseTicketRequest = { resolution: string; };
export type BrowserCloseTicketRequest = { expected_item_revision: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; };
export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; };
@@ -19,7 +19,7 @@ export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "impleme
export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed";
export type BrowserTransitionTicketStateRequest = { author?: string | null; body?: string | null; reason?: string | null; state: BrowserTicketWorkflowState; };
export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_item_revision: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; };
export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; };
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -361,7 +361,10 @@
if (
await mutate("state", "/state", {
state: nextState,
reason: transitionReason.trim() || null,
operation_key: crypto.randomUUID(),
expected_item_revision: ticket.item_revision,
expected_state: ticket.state,
reason: transitionReason.trim() || "Progress decision from the Ticket page",
})
) transitionReason = "";
}
@@ -381,7 +384,12 @@
event.preventDefault();
if (!resolution.trim()) return;
if (
await mutate("close", "/close", { resolution: resolution.trim() })
await mutate("close", "/close", {
resolution: resolution.trim(),
operation_key: crypto.randomUUID(),
expected_item_revision: ticket.item_revision,
expected_state: ticket.state,
})
) resolution = "";
}