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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user