feat: separate Ticket case state and immutable closure judgments

This commit is contained in:
2026-10-11 01:41:46 +09:00
parent 6a12b6cc34
commit ba3ebaa68c
65 changed files with 7625 additions and 6844 deletions
+236 -133
View File
@@ -306,14 +306,11 @@ impl_openapi_schema!(
SkillDetailResponse,
SkillActivationResponse,
BrowserAppendTicketEventRequest,
BrowserCloseTicketRequest,
BrowserEditTicketRequest,
BrowserQueueTicketRequest,
BrowserTransitionTicketStateRequest,
CancelTicketImplementationRequest,
ClearTicketRoleAssignmentQuery,
CompleteMergeRequestRequest,
CompleteTicketRequest,
CreateTicketOrchestrationPlanRequest,
CreateTicketRecordRequest,
CreateTicketRelationRequest,
@@ -347,9 +344,10 @@ impl_openapi_schema!(
SetTicketRoleAssignmentRequest,
SubmitMergeRequestReviewRequest,
TextResponse,
TicketCloseRecordRequest,
TicketDependencyCheckResponse,
TicketDetail,
TicketCaseUpdateRequest,
TicketSummary,
TicketDoctorResponse,
TicketIntakeSummaryRequest,
TicketListHttpQuery,
@@ -372,8 +370,6 @@ impl_openapi_schema!(
TicketRoleAssignmentMutationResponse,
TicketRoleAssignmentsResponse,
TicketShowRequest,
TicketStateChangeRequest,
TicketStateUpdateRequest,
TicketSummarySearchQuery,
TicketThreadEventRequest,
RuntimeManagementApiError,
@@ -3051,14 +3047,6 @@ 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, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_change_add(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/intake-summaries", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_intake_summary_add(
&self,
@@ -3067,31 +3055,6 @@ 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, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_field_set(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[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, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_workflow_state_set(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[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,
@@ -3189,22 +3152,14 @@ 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, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_complete(
#[post("/workspaces/{workspace_id}/tickets/{ticket_id}/case", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_case_update(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: CompleteTicketRequest,
) -> 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,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketCloseRecordRequest,
) -> Result<(), RepositoryApiError>;
#[path] ticket_id: String,
#[body] request: TicketCaseUpdateRequest,
) -> Result<TicketSummary, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_relation_view(
&self,
@@ -3292,15 +3247,6 @@ 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, 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,
) -> Result<TicketDetail, RepositoryApiError>;
#[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,
@@ -3327,14 +3273,6 @@ pub trait ServerApi {
#[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, 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,
) -> Result<TicketDetail, RepositoryApiError>;
#[get("/api/objectives", status = 200, error_status = 400, additional_error_statuses = [500], bearer_auth = true, browser_auth = true)]
async fn objective_list_alias(
&self,
@@ -7263,13 +7201,23 @@ pub struct TicketListItemSummary {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub state: BrowserTicketState,
pub priority: String,
pub updated_at: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub workspace_action_priority: String,
pub record_source: String,
/// Last closure/correction key, retained across reopening for ABA-safe CAS.
pub closure_operation_key: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))]
pub current_closure: Option<ticket::TicketClosureJudgment>,
/// Original migration handoff; not another current state authority.
#[cfg_attr(
feature = "typescript",
ts(type = "TicketLegacyWorkflowSnapshot | null")
)]
pub legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -7553,20 +7501,49 @@ pub struct TicketEvidenceSummary {
pub missing: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum TicketAttention {
UnresolvedReview,
MissingCommit,
Blocked,
Unblocked,
AwaitingReview,
UnresolvedChanges,
StaleAfterRescope,
MissingEvidence,
}
impl TicketAttention {
pub const fn as_str(&self) -> &'static str {
match self {
Self::UnresolvedReview => "unresolved_review",
Self::MissingCommit => "missing_commit",
Self::Blocked => "blocked",
Self::Unblocked => "unblocked",
Self::AwaitingReview => "awaiting_review",
Self::UnresolvedChanges => "unresolved_changes",
Self::StaleAfterRescope => "stale_after_rescope",
Self::MissingEvidence => "missing_evidence",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketQueryRequest {
pub query: Option<String>,
#[serde(default)]
pub states: Vec<String>,
pub states: Vec<BrowserTicketState>,
#[serde(default)]
pub event_kinds: Vec<String>,
#[serde(default)]
pub evidence: Vec<String>,
pub review_status: Option<String>,
#[serde(default)]
pub attention: Vec<String>,
pub attention: Vec<TicketAttention>,
pub related_ticket_id: Option<String>,
pub relation_kind: Option<String>,
pub linked_objective_id: Option<String>,
@@ -7585,7 +7562,7 @@ pub struct TicketQueryItem {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub state: BrowserTicketState,
pub readiness: Option<String>,
pub priority: String,
pub created_at: Option<String>,
@@ -7613,6 +7590,16 @@ pub struct TicketQueryItem {
pub merge_requests: Vec<TicketMergeRequestSummary>,
pub merge_request: Option<TicketMergeRequestSummary>,
pub current_worker: Option<TicketAssignmentSummary>,
/// Last closure/correction key, retained across reopening for ABA-safe CAS.
pub closure_operation_key: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))]
pub current_closure: Option<ticket::TicketClosureJudgment>,
/// Original migration handoff; not another current state authority.
#[cfg_attr(
feature = "typescript",
ts(type = "TicketLegacyWorkflowSnapshot | null")
)]
pub legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -7640,7 +7627,7 @@ pub struct TicketDetail {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub state: BrowserTicketState,
pub readiness: Option<String>,
pub priority: String,
pub created_at: Option<String>,
@@ -7674,6 +7661,18 @@ pub struct TicketDetail {
pub evidence: TicketEvidenceSummary,
pub resolution: Option<String>,
pub record_source: String,
/// Last closure/correction key, retained across reopening for ABA-safe CAS.
pub closure_operation_key: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))]
pub current_closure: Option<ticket::TicketClosureJudgment>,
/// Original migration handoff; not another current state authority.
#[cfg_attr(
feature = "typescript",
ts(type = "TicketLegacyWorkflowSnapshot | null")
)]
pub legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
#[cfg_attr(feature = "typescript", ts(type = "Array<TicketCaseHistoryEntry>"))]
pub closure_history: Vec<ticket::TicketCaseHistoryEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -7703,6 +7702,34 @@ macro_rules! transparent_ticket_dto {
}
transparent_ticket_dto!(TicketRecord, ticket::Ticket);
transparent_ticket_dto!(TicketSummary, ticket::TicketSummary);
/// Authorized case judgment. Author comes from authenticated Workspace authority,
/// never from the request. All three actions share content/state/closure-key CAS.
fn deserialize_case_closure_observation<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)
}
fn case_closure_observation_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
generator.subschema_for::<Option<String>>()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketCaseUpdateRequest {
pub expected_content_digest: String,
pub expected_state: ticket::TicketState,
#[serde(deserialize_with = "deserialize_case_closure_observation")]
#[schemars(required, schema_with = "case_closure_observation_schema")]
pub expected_closure_operation_key: Option<String>,
pub operation_key: String,
pub action: ticket::TicketCaseAction,
pub explanation: String,
#[serde(default)]
pub references: Vec<ticket::TicketReference>,
}
transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef);
transparent_ticket_dto!(TicketRecordSummaryList, Vec<ticket::TicketSummary>);
transparent_ticket_dto!(TicketDependencyCheckResponse, ticket::TicketDependencyCheck);
@@ -7722,10 +7749,7 @@ 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::TicketStateUpdate);
transparent_ticket_dto!(TicketStateUpdateRequest, ticket::TicketStateUpdate);
transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary);
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::TicketCompletion);
transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation);
transparent_ticket_dto!(
CreateTicketOrchestrationPlanRequest,
@@ -7837,15 +7861,29 @@ pub enum BrowserTicketTargetEdit {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum BrowserTicketWorkflowState {
Planning,
Ready,
Queued,
Inprogress,
Done,
pub enum BrowserTicketState {
Open,
Closed,
}
impl BrowserTicketState {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
Self::Closed => "closed",
}
}
}
impl From<ticket::TicketState> for BrowserTicketState {
fn from(state: ticket::TicketState) -> Self {
match state {
ticket::TicketState::Open => Self::Open,
ticket::TicketState::Closed => Self::Closed,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -7860,18 +7898,6 @@ pub struct BrowserEditTicketRequest {
pub author: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct BrowserTransitionTicketStateRequest {
pub state: BrowserTicketWorkflowState,
pub operation_key: String,
pub expected_content_digest: String,
pub expected_state: BrowserTicketWorkflowState,
pub reason: String,
pub body: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
@@ -7896,16 +7922,6 @@ pub struct BrowserAppendTicketEventRequest {
#[serde(deny_unknown_fields)]
pub struct BrowserQueueTicketRequest {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct BrowserCloseTicketRequest {
pub operation_key: String,
pub expected_content_digest: String,
pub expected_state: BrowserTicketWorkflowState,
pub resolution: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ObjectiveListQuery {
@@ -8278,17 +8294,6 @@ pub struct CompleteMergeRequestRequest {
pub resolution: ConflictResolution,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CompleteTicketRequest {
pub operation_key: String,
pub expected_content_digest: String,
pub expected_state: ticket::TicketWorkflowState,
pub reason: String,
#[serde(default)]
pub references: Vec<ticket::TicketReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuntimeManagementApiError {
@@ -11550,14 +11555,9 @@ mod tests {
);
for (operation_id, method, path) in [
(
"ticket_state_update",
"ticket_case_update",
HttpMethod::Post,
"/api/w/{workspace_id}/tickets/{id}/state-update",
),
(
"ticket_complete",
HttpMethod::Post,
"/api/w/{workspace_id}/tickets/{id}/complete",
"/workspaces/{workspace_id}/tickets/{ticket_id}/case",
),
(
"ticket_relation_query",
@@ -12747,31 +12747,134 @@ mod tests {
}
#[test]
fn ticket_completion_wire_requires_cas_and_judgment_not_merge_evidence() {
fn ticket_case_wire_requires_cas_and_judgment_without_client_author() {
let request = serde_json::json!({
"operation_key": "investigation-done",
"operation_key": "question-answered",
"expected_content_digest": "ticket:1",
"expected_state": "planning",
"reason": "Question answered in the thread"
"expected_state": "open",
"expected_closure_operation_key": null,
"action": {"action": "close", "reason": "completed"},
"explanation": "Question answered in the thread"
});
let parsed: CompleteTicketRequest = serde_json::from_value(request.clone()).unwrap();
let parsed: TicketCaseUpdateRequest = serde_json::from_value(request.clone()).unwrap();
assert!(parsed.references.is_empty());
assert_eq!(parsed.expected_state, ticket::TicketState::Open);
for field in [
"operation_key",
"expected_content_digest",
"expected_state",
"reason",
"expected_closure_operation_key",
"action",
"explanation",
] {
let mut missing = request.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<CompleteTicketRequest>(missing).is_err(),
serde_json::from_value::<TicketCaseUpdateRequest>(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());
for (field, value) in [
("author", serde_json::json!("untrusted-author")),
(
"requirement_approval_event_id",
serde_json::json!("not-a-judgment"),
),
("expected_state", serde_json::json!("done")),
] {
let mut rejected = request.clone();
rejected[field] = value;
assert!(
serde_json::from_value::<TicketCaseUpdateRequest>(rejected).is_err(),
"{field}"
);
}
}
#[test]
fn ticket_case_wire_preserves_actions_and_externally_tagged_other_reason() {
for action in [
serde_json::json!({"action": "close", "reason": "not_planned"}),
serde_json::json!({"action": "reopen"}),
serde_json::json!({"action": "correct_closure", "reason": {"other": "Deferred by user"}}),
] {
let request = serde_json::json!({
"operation_key": "case-decision",
"expected_content_digest": "ticket:2",
"expected_state": "closed",
"expected_closure_operation_key": "previous-close",
"action": action,
"explanation": "Explicit user decision",
"references": [{"kind": "ticket", "target": "T-2"}]
});
let parsed: TicketCaseUpdateRequest = serde_json::from_value(request.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), request);
}
}
#[test]
fn ticket_query_accepts_only_case_states_and_current_attention_filters() {
let parsed: TicketQueryRequest = serde_json::from_value(serde_json::json!({
"states": ["open", "closed"], "attention": ["blocked", "missing_evidence"]
}))
.unwrap();
assert_eq!(parsed.states[0], BrowserTicketState::Open);
for state in [
"planning",
"ready",
"queued",
"inprogress",
"in_progress",
"done",
] {
assert!(
serde_json::from_value::<TicketQueryRequest>(
serde_json::json!({"states": [state]})
)
.is_err(),
"{state}"
);
}
for attention in ["done_not_closed", "ready"] {
assert!(
serde_json::from_value::<TicketQueryRequest>(
serde_json::json!({"attention": [attention]})
)
.is_err(),
"{attention}"
);
}
}
#[test]
fn ticket_case_route_replaces_all_legacy_case_judgment_bypasses() {
let operations = ServerApiMetadata::OPERATIONS;
for removed in [
"ticket_complete",
"ticket_close",
"ticket_close_record",
"ticket_state_update",
"ticket_state_transition",
"ticket_state_change_add",
"ticket_state_field_set",
"ticket_workflow_state_set",
] {
assert!(
!operations
.iter()
.any(|operation| operation.operation_id == removed),
"{removed}"
);
}
let operation = operations
.iter()
.find(|operation| operation.operation_id == "ticket_case_update")
.unwrap();
assert_eq!(operation.method, HttpMethod::Post);
assert_eq!(
operation.path,
"/workspaces/{workspace_id}/tickets/{ticket_id}/case"
);
}
#[cfg(feature = "typescript")]
@@ -466,11 +466,11 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/state",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case",
method: "post",
operation_id: "ticket_state_transition",
request: Some("BrowserTransitionTicketStateRequest"),
successes: &[("200", "TicketDetail")],
operation_id: "ticket_case_update",
request: Some("TicketCaseUpdateRequest"),
successes: &[("200", "TicketSummary")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/ready",
@@ -493,13 +493,6 @@ fn validate_ticket_browser_operations(
request: Some("BrowserQueueTicketRequest"),
successes: &[("200", "TicketQueueOutcome")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/close",
method: "post",
operation_id: "ticket_close",
request: Some("BrowserCloseTicketRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/objectives",
method: "get",
@@ -1891,6 +1884,54 @@ mod tests {
assert!(!detail.contains(" target:"));
}
#[test]
fn ticket_browser_generator_exposes_case_judgment_and_closure_projections() {
let openapi = crate::canonical_openapi_document()
.unwrap()
.to_json()
.unwrap();
let output = generate_ticket_browser_typescript(&openapi).unwrap();
assert!(output.contains("export type BrowserTicketState = \"open\" | \"closed\";"));
let request = output
.lines()
.find(|line| line.starts_with("export type TicketCaseUpdateRequest ="))
.unwrap();
for field in [
"expected_content_digest: string",
"expected_state: TicketState",
"expected_closure_operation_key: string | null",
"operation_key: string",
"action: TicketCaseAction",
"explanation: string",
] {
assert!(request.contains(field), "missing {field}: {request}");
}
assert!(!request.contains("author"));
assert!(output.contains("export type TicketClosureReason = \"completed\" | \"not_planned\" | \"duplicate\" | \"unknown\" | { other: string; };"));
let detail = output
.lines()
.find(|line| line.starts_with("export type TicketDetail ="))
.unwrap();
for field in [
"state: BrowserTicketState",
"closure_operation_key?: string | null",
"current_closure?: TicketClosureJudgment | null",
"closure_history: Array<TicketCaseHistoryEntry>",
"legacy_handoff?: TicketLegacyWorkflowSnapshot | null",
] {
assert!(detail.contains(field), "missing {field}: {detail}");
}
for removed in [
"BrowserTransitionTicketStateRequest",
"BrowserCloseTicketRequest",
"CompleteTicketRequest",
"BrowserTicketWorkflowState",
"done_not_closed",
] {
assert!(!output.contains(removed), "{removed}");
}
}
#[test]
fn checked_in_ticket_browser_typescript_is_current() {
assert_eq!(