From 515a19bbc30c77b4a6bd13606ffebc145e16bcc0 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 23 Sep 2026 10:51:50 +0900 Subject: [PATCH] feat: generate ticket browser contract from ServerApi --- .../examples/generate_ticket_api_types.rs | 59 + .../src/repository_openapi_typescript.rs | 219 ++- crates/workspace-server/Cargo.toml | 6 +- .../examples/generate_ticket_api_types.rs | 3 - crates/workspace-server/src/lib.rs | 2 - crates/workspace-server/src/records.rs | 100 -- web/workspace/deno.json | 6 +- web/workspace/src/lib/generated/ticket-api.ts | 128 +- web/workspace/src/lib/workspace/api/http.ts | 30 +- .../src/lib/workspace/api/merge-requests.ts | 49 +- .../lib/workspace/api/ticket-browser.test.ts | 143 ++ .../src/lib/workspace/api/ticket-browser.ts | 1264 +++++++++++++++++ .../console/worker-console.ui.test.ts | 9 +- .../src/lib/workspace/merge-request-status.ts | 35 +- .../src/lib/workspace/sidebar/types.ts | 83 +- .../workspace/tickets/ticket-panel.test.ts | 7 +- .../src/lib/workspace/tickets/ticket-panel.ts | 7 +- .../w/[workspaceId]/merge-requests/+page.ts | 45 +- .../[mergeRequestId]/+page.svelte | 14 +- .../merge-requests/[mergeRequestId]/+page.ts | 53 +- .../w/[workspaceId]/objectives/+page.ts | 10 +- .../objectives/[objectiveId]/+page.ts | 10 +- .../w/[workspaceId]/tickets/+page.svelte | 22 +- .../routes/w/[workspaceId]/tickets/+page.ts | 76 +- .../tickets/[ticketId]/+page.svelte | 35 +- .../[workspaceId]/tickets/[ticketId]/+page.ts | 16 +- .../test/merge-request-status.test.ts | 152 +- 27 files changed, 2121 insertions(+), 462 deletions(-) create mode 100644 crates/server-api/examples/generate_ticket_api_types.rs delete mode 100644 crates/workspace-server/examples/generate_ticket_api_types.rs create mode 100644 web/workspace/src/lib/workspace/api/ticket-browser.test.ts create mode 100644 web/workspace/src/lib/workspace/api/ticket-browser.ts diff --git a/crates/server-api/examples/generate_ticket_api_types.rs b/crates/server-api/examples/generate_ticket_api_types.rs new file mode 100644 index 000000000..a60f9120e --- /dev/null +++ b/crates/server-api/examples/generate_ticket_api_types.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::path::Path; + +use server_api::repository_openapi_typescript::{ + INPUT_PATH, TICKET_BROWSER_OUTPUT_PATH, generate_ticket_browser_typescript, +}; + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let mut check = false; + for argument in std::env::args().skip(1) { + match argument.as_str() { + "--check" => check = true, + "--help" | "-h" => { + println!( + "Generate the bounded Ticket Browser API TypeScript artifact.\n\n\ + Usage: cargo run -q -p server-api --example generate_ticket_api_types -- [--check]" + ); + return Ok(()); + } + other => return Err(format!("unknown argument `{other}`")), + } + } + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let input_path = root.join(INPUT_PATH); + let output_path = root.join(TICKET_BROWSER_OUTPUT_PATH); + let input = fs::read_to_string(&input_path) + .map_err(|error| format!("failed to read {}: {error}", input_path.display()))?; + let generated = generate_ticket_browser_typescript(&input) + .map_err(|error| format!("failed to generate Ticket Browser API types: {error}"))?; + + if check { + let checked_in = fs::read_to_string(&output_path) + .map_err(|error| format!("failed to read {}: {error}", output_path.display()))?; + if checked_in != generated { + return Err(format!( + "{} is stale; regenerate it without --check", + output_path.display() + )); + } + println!("{} is current", output_path.display()); + return Ok(()); + } + + let temporary_path = output_path.with_extension("ts.tmp"); + fs::write(&temporary_path, generated) + .map_err(|error| format!("failed to write {}: {error}", temporary_path.display()))?; + fs::rename(&temporary_path, &output_path) + .map_err(|error| format!("failed to replace {}: {error}", output_path.display()))?; + println!("generated {}", output_path.display()); + Ok(()) +} diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index dbbd6455a..cec65125a 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -28,6 +28,10 @@ pub const COMPANION_GENERATOR_VERSION: &str = "1"; pub const COMPANION_GENERATOR_OPTIONS: &str = "companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output"; pub const COMPANION_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/companion-api.ts"; +pub const TICKET_BROWSER_GENERATOR_NAME: &str = "yoi-ticket-browser-openapi-typescript"; +pub const TICKET_BROWSER_GENERATOR_VERSION: &str = "1"; +pub const TICKET_BROWSER_GENERATOR_OPTIONS: &str = "ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output"; +pub const TICKET_BROWSER_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/ticket-api.ts"; const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; const MIN_SAFE_INTEGER: f64 = -9_007_199_254_740_991.0; @@ -203,6 +207,19 @@ pub fn generate_companion_typescript(input: &str) -> Result Result { + generate_domain_typescript( + input, + "Ticket Browser", + validate_ticket_browser_operations, + TICKET_BROWSER_GENERATOR_NAME, + TICKET_BROWSER_GENERATOR_VERSION, + TICKET_BROWSER_GENERATOR_OPTIONS, + TICKET_BROWSER_OUTPUT_PATH, + "generate_ticket_api_types", + ) +} + #[allow(clippy::too_many_arguments)] fn generate_domain_typescript( input: &str, @@ -402,6 +419,112 @@ fn validate_companion_operations( validate_operation_contracts(paths, &operations) } +fn validate_ticket_browser_operations( + paths: &Map, +) -> Result, GenerationError> { + let operations = [ + OperationContract { + path: "/api/w/{workspace_id}/tickets", + method: "get", + operation_id: "ticket_list", + request: None, + successes: &[("200", "TicketListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}", + method: "get", + operation_id: "ticket_get", + request: None, + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}", + method: "patch", + operation_id: "ticket_edit", + request: Some("BrowserEditTicketRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}", + method: "put", + operation_id: "ticket_assignment_set", + request: Some("SetTicketRoleAssignmentRequest"), + successes: &[("200", "TicketRoleAssignmentMutationResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations", + method: "post", + operation_id: "ticket_implementation_cancel", + request: Some("CancelTicketImplementationRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/state", + method: "post", + operation_id: "ticket_state_transition", + request: Some("BrowserTransitionTicketStateRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/ready", + method: "post", + operation_id: "ticket_ready", + request: Some("TicketMarkReadyRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/events", + method: "post", + operation_id: "ticket_event_append", + request: Some("BrowserAppendTicketEventRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/queue", + method: "post", + operation_id: "ticket_queue", + 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", + operation_id: "objective_list", + request: None, + successes: &[("200", "ObjectiveListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/objectives/{objective_id}", + method: "get", + operation_id: "objective_get", + request: None, + successes: &[("200", "ObjectiveDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/merge-requests", + method: "get", + operation_id: "merge_request_list", + request: None, + successes: &[("200", "MergeRequestListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/merge-requests/{merge_request_id}", + method: "get", + operation_id: "merge_request_show", + request: None, + successes: &[("200", "MergeRequestDetailResponse")], + }, + ]; + validate_operation_contracts(paths, &operations) +} + fn validate_repository_operations( paths: &Map, ) -> Result, GenerationError> { @@ -1484,12 +1607,25 @@ fn render_typed_schema( None => (MIN_SAFE_INTEGER, MAX_SAFE_INTEGER), Some(_) => unreachable!("integer format validated above"), }; - if minimum != Some(expected_minimum) || maximum != Some(expected_maximum) { + let minimum = minimum.unwrap_or(expected_minimum); + let maximum = maximum.unwrap_or(expected_maximum); + if minimum.fract() != 0.0 + || maximum.fract() != 0.0 + || minimum < expected_minimum + || maximum > expected_maximum + || minimum > maximum + { return Err(GenerationError::invalid(format!( "{context} integer range does not match its JavaScript safe-number mapping" ))); } - Ok("number".to_owned()) + if minimum == expected_minimum && maximum == expected_maximum { + Ok("number".to_owned()) + } else { + Ok(format!( + "number & {{ readonly \"__yoiIntegerRange\": \"{minimum:.0}..{maximum:.0}\"; }}" + )) + } } "array" => { ensure_schema_keywords(schema, &["items", "maxItems", "minItems", "type"], context)?; @@ -1537,10 +1673,13 @@ fn render_typed_schema( } fn render_object(schema: &Map, context: &str) -> Result { - let properties = schema - .get("properties") - .and_then(Value::as_object) - .ok_or_else(|| GenerationError::invalid(format!("{context} object has no properties")))?; + let empty_properties = Map::new(); + let properties = match schema.get("properties") { + Some(value) => value.as_object().ok_or_else(|| { + GenerationError::invalid(format!("{context} object properties must be an object")) + })?, + None => &empty_properties, + }; let required = match schema.get("required") { Some(Value::Array(required)) => required .iter() @@ -1564,13 +1703,23 @@ fn render_object(schema: &Map, context: &str) -> Result {} + Some(additional) if properties.is_empty() && required.is_empty() => { + return Ok(format!( + "{{ [key: string]: {}; }}", + render_schema(additional, context)? + )); + } + Some(_) => { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported additionalProperties" + ))); + } + } + + if properties.is_empty() { + return Ok("{ [key: string]: never; }".to_owned()); } let mut fields = Vec::with_capacity(properties.len()); @@ -1649,6 +1798,42 @@ mod tests { include_str!("../../../web/workspace/src/lib/generated/repository-api.ts"); const RUNTIME_TYPESCRIPT: &str = include_str!("../../../web/workspace/src/lib/generated/runtime-api.ts"); + const TICKET_BROWSER_TYPESCRIPT: &str = + include_str!("../../../web/workspace/src/lib/generated/ticket-api.ts"); + + #[test] + fn checked_in_ticket_browser_typescript_is_current() { + assert_eq!( + generate_ticket_browser_typescript(OPENAPI).unwrap(), + TICKET_BROWSER_TYPESCRIPT, + "regenerate with `cargo run -q -p server-api --example generate_ticket_api_types`", + ); + } + + #[test] + fn ticket_browser_generator_validates_operation_ownership_and_schema_closure() { + let output = generate_ticket_browser_typescript(OPENAPI).unwrap(); + assert!(output.contains(&format!( + "@generated by {TICKET_BROWSER_GENERATOR_NAME}@{TICKET_BROWSER_GENERATOR_VERSION}" + ))); + assert!(output.contains("export type TicketListResponse =")); + assert!( + output.contains("limit: number & { readonly \"__yoiIntegerRange\": \"0..1000\"; }") + ); + assert!( + output.contains("export type BrowserQueueTicketRequest = { [key: string]: never; };") + ); + assert!(output.contains("attributes: { [key: string]: string; }")); + assert!(output.contains("export type ObjectiveDetail =")); + assert!(output.contains("export type MergeRequestDetailResponse =")); + assert!(!output.contains("export type RuntimeListResponse =")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["paths"]["/api/w/{workspace_id}/tickets"]["get"]["operationId"] = + serde_json::json!("manual_ticket_list"); + let error = generate_ticket_browser_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("ticket_list")); + } #[test] fn checked_in_runtime_typescript_is_current() { @@ -1711,7 +1896,7 @@ mod tests { } #[test] - fn generator_rejects_lossy_safe_integer_and_union_changes() { + fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() { let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = serde_json::json!(9_007_199_254_740_992_u64); @@ -1727,8 +1912,10 @@ mod tests { let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = serde_json::json!(9_007_199_254_740_990_u64); - let error = generate_repository_typescript(&document.to_string()).unwrap_err(); - assert!(error.to_string().contains("safe-number")); + let output = generate_repository_typescript(&document.to_string()).unwrap(); + assert!(output.contains( + "source_revision: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }" + )); let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] = diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 5b7330acc..b3a176f2b 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -10,10 +10,6 @@ autobins = false name = "yoi-server" path = "src/main.rs" -[features] -default = ["typescript"] -typescript = ["dep:ts-rs", "server-api/typescript"] - [dependencies] async-trait.workspace = true axum = { workspace = true, features = ["ws"] } @@ -47,7 +43,7 @@ worker-runtime.workspace = true toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -ts-rs = { version = "12.0.1", optional = true } +ts-rs = "12.0.1" url.workspace = true uuid = { workspace = true, features = ["v7"] } zeroize.workspace = true diff --git a/crates/workspace-server/examples/generate_ticket_api_types.rs b/crates/workspace-server/examples/generate_ticket_api_types.rs deleted file mode 100644 index f30c422ba..000000000 --- a/crates/workspace-server/examples/generate_ticket_api_types.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", yoi_workspace_server::ticket_api_typescript()); -} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index efab8ecbb..618947432 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -16,8 +16,6 @@ pub mod observation; pub mod profile_settings; pub mod prompt_settings; pub mod records; -#[cfg(feature = "typescript")] -pub use records::ticket_api_typescript; pub mod repositories; pub mod repository_access; pub mod repository_source; diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 8071de4f7..bc6b44d64 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -22,14 +22,12 @@ pub struct TicketSummaryPage { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct InvalidProjectRecord { pub label: String, pub reason: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketSummary { pub id: String, pub resource_key: String, @@ -54,7 +52,6 @@ pub struct TicketListPageRequest { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketListResponse { pub workspace_id: String, pub limit: usize, @@ -65,7 +62,6 @@ pub struct TicketListResponse { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketDetail { pub id: String, pub resource_key: String, @@ -102,7 +98,6 @@ pub struct TicketDetail { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketEventDetail { pub sequence: usize, pub event_ref: String, @@ -121,7 +116,6 @@ pub struct TicketEventDetail { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketRelation { pub ticket_id: String, pub kind: String, @@ -134,7 +128,6 @@ pub struct TicketRelation { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct DerivedTicketRelation { pub source_ticket: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -147,7 +140,6 @@ pub struct DerivedTicketRelation { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketRelationBlocker { pub blocking_ticket: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -159,7 +151,6 @@ pub struct TicketRelationBlocker { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketRelationNotice { pub related_ticket: String, pub kind: String, @@ -167,7 +158,6 @@ pub struct TicketRelationNotice { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketRelationView { pub outgoing: Vec, pub incoming: Vec, @@ -230,7 +220,6 @@ impl From for TicketRelationView { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct ObjectiveLinkSummary { pub id: String, pub resource_key: String, @@ -239,7 +228,6 @@ pub struct ObjectiveLinkSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketEvidenceEvent { pub event_ref: String, pub sequence: usize, @@ -250,7 +238,6 @@ pub struct TicketEvidenceEvent { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketAssignmentSummary { pub assignment_id: String, pub runtime_id: String, @@ -260,7 +247,6 @@ pub struct TicketAssignmentSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketRoleAssignmentSummary { pub assignment_id: String, pub role: String, @@ -271,8 +257,6 @@ pub struct TicketRoleAssignmentSummary { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))] pub enum TicketAssignmentPrincipalSummary { User { account_id: String, @@ -287,7 +271,6 @@ pub enum TicketAssignmentPrincipalSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketActionEligibility { pub can_assign_orchestrator: bool, pub can_unassign_orchestrator: bool, @@ -298,7 +281,6 @@ pub struct TicketActionEligibility { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketMergeRequestSummary { pub merge_request_id: String, pub repository_key: String, @@ -315,14 +297,12 @@ pub struct TicketMergeRequestSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct MergeRequestRefDiagnostic { pub code: String, pub message: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct MergeRequestListItem { pub summary: TicketMergeRequestSummary, pub ticket_ids: Vec, @@ -332,14 +312,12 @@ pub struct MergeRequestListItem { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct MergeRequestListResponse { pub items: Vec, pub next_cursor: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketEvidenceSummary { pub has_merge_request: bool, pub has_current_subject_ref: bool, @@ -354,7 +332,6 @@ pub struct TicketEvidenceSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketQueryRequest { pub query: Option, #[serde(default)] @@ -377,7 +354,6 @@ pub struct TicketQueryRequest { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketQueryItem { pub id: String, pub resource_key: String, @@ -393,7 +369,6 @@ pub struct TicketQueryItem { pub snippet: Option, pub matching_event: Option, pub linked_objective_ids: Vec, - #[ts(skip)] pub linked_objective_keys: Vec, pub relation_count: usize, pub blocker_count: usize, @@ -401,12 +376,10 @@ pub struct TicketQueryItem { pub unresolved_review_count: usize, pub evidence: TicketEvidenceSummary, pub merge_request: Option, - #[ts(skip)] pub current_coder: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketQueryResponse { pub items: Vec, pub page: QueryPage, @@ -414,7 +387,6 @@ pub struct TicketQueryResponse { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketShowRequest { pub event_limit: Option, pub event_cursor: Option, @@ -461,78 +433,6 @@ pub struct ObjectiveShowRequest { pub event_cursor: Option, } -#[cfg(feature = "typescript")] -pub fn ticket_api_typescript() -> String { - use ts_rs::TS; - - let config = ts_rs::Config::default(); - let declarations = [ - InvalidProjectRecord::decl(&config), - TicketSummary::decl(&config), - TicketListResponse::decl(&config), - QueryPage::decl(&config), - TicketEventDetail::decl(&config), - ObjectiveLinkSummary::decl(&config), - TicketEvidenceEvent::decl(&config), - TicketAssignmentSummary::decl(&config), - TicketRoleAssignmentSummary::decl(&config), - TicketAssignmentPrincipalSummary::decl(&config), - TicketActionEligibility::decl(&config), - TicketMergeRequestSummary::decl(&config), - MergeRequestRefDiagnostic::decl(&config), - MergeRequestListItem::decl(&config), - MergeRequestListResponse::decl(&config), - TicketEvidenceSummary::decl(&config), - TicketQueryRequest::decl(&config), - TicketQueryItem::decl(&config), - TicketQueryResponse::decl(&config), - TicketShowRequest::decl(&config), - TicketRelation::decl(&config), - DerivedTicketRelation::decl(&config), - TicketRelationBlocker::decl(&config), - TicketRelationNotice::decl(&config), - TicketRelationView::decl(&config), - TicketDetail::decl(&config), - ]; - format!( - "// Generated from yoi-workspace-server. Do not edit by hand.\n// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts\n\n{}\n", - declarations - .into_iter() - .map(|declaration| format!("export {declaration}")) - .collect::>() - .join("\n\n") - ) -} - -#[cfg(all(test, feature = "typescript"))] -mod typescript_tests { - #[test] - fn generated_ticket_api_contract_is_current() { - let expected = super::ticket_api_typescript(); - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../web/workspace/src/lib/generated/ticket-api.ts"); - let actual = std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); - assert_eq!( - normalize(&actual), - normalize(&expected), - "regenerate Ticket API TypeScript types with `cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts` and format the generated file", - ); - } - - fn normalize(value: &str) -> String { - value - .chars() - .filter_map(|character| match character { - character if character.is_whitespace() => None, - ',' => Some(';'), - character => Some(character), - }) - .collect::() - .replace(";}", "}") - } -} - pub(crate) fn summarize_body(body: &str) -> String { let summary = body .lines() diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 7eb9b9038..293b4ae3b 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,12 +6,14 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview", "generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types", - "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check" + "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check", + "generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types", + "check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check" }, "imports": { "$lib/": "./src/lib/", diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 1901ee158..fac0876fe 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,54 +1,126 @@ -// Generated from yoi-workspace-server. Do not edit by hand. -// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts +// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. +// Input: openapi/server-api.json +// Input contract: 0.1.0 (sha256:550383b600fe2a2e55630849c7de7a6a24463ad41d0c18dc5d254a3d14328f07) +// 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 InvalidProjectRecord = { label: string, reason: string, }; +export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; -export type TicketSummary = { id: string, resource_key: string, title: string, state: string, priority: string, updated_at: string | null, queued_by: string | null, queued_at: string | null, workspace_action_priority: string, record_source: string, }; +export type BrowserCloseTicketRequest = { resolution: string; }; -export type TicketListResponse = { workspace_id: string, limit: number, items: Array, page: QueryPage, invalid_records: Array, record_authority: 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; }; -export type QueryPage = { limit: number, returned: number, has_more: boolean, next_cursor: string | null, sort: string, source_limit: number | null, source_truncated: boolean, }; +export type BrowserQueueTicketRequest = { [key: string]: never; }; -export type TicketEventDetail = { sequence: number, event_ref: string, kind: string, author: string | null, at: string | null, status: string | null, from: string | null, to: string | null, reason: string | null, state_field: string | null, heading: string | null, body: string | null, attributes: { [key in string]: string }, references: Array, }; +export type BrowserTicketTargetEdit = { action: "set"; ref_selector?: string | null; repository_key: string; } | { action: "clear"; }; -export type ObjectiveLinkSummary = { id: string, resource_key: string, title: string, state: string, }; +export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report"; -export type TicketEvidenceEvent = { event_ref: string, sequence: number, kind: string, at: string | null, author: string | null, excerpt: string, }; +export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed"; -export type TicketAssignmentSummary = { assignment_id: string, runtime_id: string, worker_id: string, worker_resource_key?: string | null, }; +export type BrowserTransitionTicketStateRequest = { author?: string | null; body?: string | null; reason?: string | null; state: BrowserTicketWorkflowState; }; -export type TicketRoleAssignmentSummary = { assignment_id: string, role: string, principal: TicketAssignmentPrincipalSummary, assigned_by: string, assigned_at: string, }; +export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; }; -export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, }; +export type ConflictResolution = "none" | "clean" | "conflicts_resolved"; -export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, queue_tickets: Array, blockers: Array, }; +export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; -export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, }; +export type DiagnosticSeverity = "info" | "warning" | "error"; -export type MergeRequestRefDiagnostic = { code: string, message: string, }; +export type FindingSeverity = "blocker" | "major" | "minor" | "note"; -export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array, thread_event_count: number, ref_diagnostics?: Array, }; +export type InvalidProjectRecord = { label: string; reason: string; }; -export type MergeRequestListResponse = { items: Array, next_cursor: string | null, }; +export type MarkdownText = string; -export type TicketEvidenceSummary = { has_merge_request: boolean, has_current_subject_ref: boolean, has_review_request: boolean, has_commit: boolean, review_status: string | null, approved_current_subject: boolean, review_after_rescope: boolean, unresolved_request_changes: boolean, complete_for_integration: boolean, missing: Array, }; +export type MergeRequestDetailResponse = { created_at: string; linked_tickets: Array; merge_request_id: string; repository_key: string; selector_from?: string | null; selector_to: string; source: MergeRequestRefResponse; state: MergeRequestState; target: MergeRequestRefResponse; thread: Array; ticket_ids: Array; updated_at: string; workspace_id: string; }; -export type TicketQueryRequest = { query: string | null, states: Array, event_kinds: Array, evidence: Array, review_status: string | null, attention: Array, related_ticket_id: string | null, relation_kind: string | null, linked_objective_id: string | null, updated_after: string | null, updated_before: string | null, sort: string | null, limit: number | null, cursor: string | null, }; +export type MergeRequestLinkedTicketResponse = { key?: string | null; ticket_id: string; }; -export type TicketQueryItem = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, workspace_action_priority: string, matched_fields: Array, snippet: string | null, matching_event: TicketEvidenceEvent | null, linked_objective_ids: Array, relation_count: number, blocker_count: number, unresolved_blocker_count: number, unresolved_review_count: number, evidence: TicketEvidenceSummary, merge_request: TicketMergeRequestSummary | null, }; +export type MergeRequestListItem = { ref_diagnostics?: Array; summary: TicketMergeRequestSummary; thread_event_count: number; ticket_ids: Array; }; -export type TicketQueryResponse = { items: Array, page: QueryPage, record_authority: string, }; +export type MergeRequestListResponse = { items: Array; next_cursor?: string | null; }; -export type TicketShowRequest = { event_limit: number | null, event_cursor: string | null, }; +export type MergeRequestRefDiagnostic = { code: string; message: string; }; -export type TicketRelation = { ticket_id: string, kind: string, target: string, target_resource_key?: string | null, note: string | null, author: string, at: string, }; +export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; }; -export type DerivedTicketRelation = { source_ticket: string, source_resource_key?: string | null, inverse_kind: string, forward_kind: string, note: string | null, author: string, at: string, }; +export type MergeRequestState = "open" | "merged" | "closed"; -export type TicketRelationBlocker = { blocking_ticket: string, blocking_resource_key?: string | null, reason_kind: string, relation_kind: string, note: string | null, blocking_state: string, }; +export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; }; -export type TicketRelationNotice = { related_ticket: string, kind: string, message: string, }; +export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; }; -export type TicketRelationView = { outgoing: Array, incoming: Array, blockers: Array, notices: Array, }; +export type MergeStrategy = "fast_forward" | "merge"; -export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_key: string | null, ref_selector: string | null, risk_flags: Array, body: string, body_truncated: boolean, event_count: number, events: Array, event_page: QueryPage, artifact_count: number, artifacts: Array, relations: TicketRelationView, linked_objectives: Array, implementation_reports: Array, assignments: Array, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, }; +export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array; id: string; linked_ticket_summaries: Array; linked_tickets: Array; record_source: string; resource_key: string; resources: Array; revision: string; state: string; title: string; updated_at?: string | null; }; + +export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; }; + +export type ObjectiveLinkSummary = { id: string; resource_key: string; state: string; title: string; }; + +export type ObjectiveLinkedTicketSummary = { id: string; resource_key: string; state: string; title: string; }; + +export type ObjectiveListResponse = { invalid_records: Array; items: Array; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; record_authority: string; workspace_id: string; }; + +export type ObjectiveResourceSummary = { bytes: number; media_type?: string | null; path: string; updated_at: string; }; + +export type ObjectiveSummary = { created_at?: string | null; id: string; linked_tickets: Array; record_source: string; resource_key: string; state: string; summary: string; title: string; updated_at?: string | null; }; + +export type QueryPage = { has_more: boolean; limit: number; next_cursor?: string | null; returned: number; sort: string; source_limit?: number | null; source_truncated: boolean; }; + +export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; + +export type ReviewDecision = "approve" | "request_changes"; + +export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; }; + +export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; }; + +export type TicketActionEligibility = { blockers: Array; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_coder: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array; }; + +export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; }; + +export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contributor"; + +export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; + +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; item_revision: string; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; ref_selector?: string | null; relations: TicketDetailRelationView; repository_key?: string | null; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; title: string; updated_at?: string | null; }; + +export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; + +export type TicketDetailRelation = { at: string; author: string; kind: string; note?: string | null; target: string; target_resource_key?: string | null; ticket_id: string; }; + +export type TicketDetailRelationBlocker = { blocking_resource_key?: string | null; blocking_state: string; blocking_ticket: string; note?: string | null; reason_kind: string; relation_kind: string; }; + +export type TicketDetailRelationNotice = { kind: string; message: string; related_ticket: string; }; + +export type TicketDetailRelationView = { blockers: Array; incoming: Array; notices: Array; outgoing: Array; }; + +export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; event_ref: string; from?: string | null; heading?: string | null; kind: string; reason?: string | null; references: Array; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; }; + +export type TicketEvidenceEvent = { at?: string | null; author?: string | null; event_ref: string; excerpt: string; kind: string; sequence: number; }; + +export type TicketEvidenceSummary = { approved_current_subject: boolean; complete_for_integration: boolean; has_commit: boolean; has_current_subject_ref: boolean; has_merge_request: boolean; has_review_request: boolean; missing: Array; review_after_rescope: boolean; review_status?: string | null; unresolved_request_changes: boolean; }; + +export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array; }; + +export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; title: string; updated_at?: string | null; workspace_action_priority: string; }; + +export type TicketListResponse = { invalid_records: Array; items: Array; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; }; + +export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | null; operation_key: string; reason?: string | null; }; + +export type TicketMergeRequestSummary = { current_subject_ref?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; state: string; updated_at: string; }; + +export type TicketQueueOutcome = { queued_tickets: Array; requested_ticket: string; }; + +export type TicketReference = { kind: string; target: string; }; + +export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssignmentRecord | null; ticket_id: string; workspace_id: string; }; + +export type TicketRoleAssignmentRecord = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: TicketAssignmentRole; ticket_id: string; workspace_id: string; }; + +export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; }; diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 15e240d34..0ee2e483f 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -177,23 +177,41 @@ export async function readBoundedJson( return JSON.parse(text) as unknown; } -async function requireJson(response: Response, path: string): Promise { +async function requireJson( + response: Response, + path: string, + parse: (value: unknown) => T, + maxResponseBytes?: number, +): Promise { if (!response.ok) { + if (maxResponseBytes !== undefined) { + await response.body?.cancel(); + throw new Error(`${path} request failed (${response.status})`); + } const text = await response.text(); throw new Error(text || `${path} request failed (${response.status})`); } - return (await response.json()) as T; + const value: unknown = maxResponseBytes === undefined + ? await response.json() + : await readBoundedJson(response, maxResponseBytes); + return parse(value); } -export async function workspaceApiJson(path: string): Promise { - return requireJson(await fetch(path), path); +export async function workspaceApiJson( + path: string, + parse: (value: unknown) => T = (value) => value as T, + maxResponseBytes?: number, +): Promise { + return requireJson(await fetch(path), path, parse, maxResponseBytes); } export async function workspaceApiJsonWithBody( path: string, init: RequestInit, + parse: (value: unknown) => T = (value) => value as T, + maxResponseBytes?: number, ): Promise { - return requireJson( + return requireJson( await fetch(path, { headers: { "content-type": "application/json", @@ -202,6 +220,8 @@ export async function workspaceApiJsonWithBody( ...init, }), path, + parse, + maxResponseBytes, ); } diff --git a/web/workspace/src/lib/workspace/api/merge-requests.ts b/web/workspace/src/lib/workspace/api/merge-requests.ts index a8a68f37d..0373c51a6 100644 --- a/web/workspace/src/lib/workspace/api/merge-requests.ts +++ b/web/workspace/src/lib/workspace/api/merge-requests.ts @@ -1,48 +1,13 @@ -import type { MergeRequestListResponse } from "$lib/generated/ticket-api"; +import type { + MergeRequestDetailResponse, + MergeRequestListResponse, + MergeRequestThreadEvent, +} from "$lib/generated/ticket-api"; import { workspaceApiPath } from "./http"; -export type MergeRequestState = "open" | "merged" | "closed"; - -export type MergeRequestActor = { - runtime_id: string; - worker_id: string; - assignment_id: string; -}; - -export type MergeRequestThreadEvent = { - kind: string; - sequence: number; - at: string; - [key: string]: unknown; -}; - -export type MergeRequestRecord = { - merge_request_id: string; - workspace_id: string; - repository_key: string; - selector_from: string | null; - selector_to: string; - ticket_ids: string[]; - state: MergeRequestState; - opened_by: MergeRequestActor; - created_at: string; - updated_at: string; - thread: MergeRequestThreadEvent[]; -}; - -export type MergeRequestRefObservation = { - status: string; - ref: string | null; - observed_at: string; -}; - -export type MergeRequestDetail = MergeRequestRecord & { - source: MergeRequestRefObservation; - target: MergeRequestRefObservation; - linked_tickets: Array<{ ticket_id: string; key: string | null }>; -}; - +export type MergeRequestDetail = MergeRequestDetailResponse; export type MergeRequestListPage = MergeRequestListResponse; +export type { MergeRequestThreadEvent }; export function mergeRequestCollectionPath(workspaceId: string): string { return workspaceApiPath(workspaceId, "/merge-requests"); diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts new file mode 100644 index 000000000..0b78f303b --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -0,0 +1,143 @@ +declare const Deno: { + test(name: string, fn: () => Promise | void): void; +}; + +import { + parseMergeRequestDetailResponse, + parseObjectiveListResponse, + parseTicketListResponse, +} from "./ticket-browser.ts"; + +function assertEquals(actual: unknown, expected: unknown): void { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`Expected ${expectedJson}, received ${actualJson}`); + } +} + +function assertThrows(operation: () => unknown, message: string): void { + try { + operation(); + } catch (error) { + if (error instanceof Error && error.message.includes(message)) return; + throw error; + } + throw new Error(`Expected operation to throw: ${message}`); +} + +const page = { + has_more: false, + limit: 30, + next_cursor: null, + returned: 1, + sort: "updated_desc", + source_limit: null, + source_truncated: false, +}; + +Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => { + const parsed = parseTicketListResponse({ + workspace_id: "workspace-a", + limit: 30, + items: [{ + id: "ticket-id", + resource_key: "T-1", + title: "Ticket", + state: "ready", + priority: "P2", + updated_at: null, + queued_by: null, + queued_at: null, + workspace_action_priority: "ready", + record_source: "sqlite", + }], + page, + invalid_records: [], + record_authority: "sqlite", + }); + assertEquals(parsed.items[0]?.resource_key, "T-1"); +}); + +Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits", () => { + const fixture = { + workspace_id: "workspace-a", + limit: 30, + items: [], + page: { ...page, returned: 0 }, + invalid_records: [], + record_authority: "sqlite", + }; + assertThrows( + () => parseTicketListResponse({ ...fixture, stale: true }), + "unknown field stale", + ); + assertThrows( + () => parseTicketListResponse({ ...fixture, limit: 1001 }), + "0 through 1000", + ); +}); + +Deno.test("Ticket Browser parser accepts the generated Objective list shape", () => { + const parsed = parseObjectiveListResponse({ + workspace_id: "workspace-a", + limit: 100, + items: [{ + id: "objective-id", + resource_key: "O-1", + title: "Objective", + state: "active", + created_at: null, + updated_at: null, + summary: "Summary", + linked_tickets: ["T-1"], + record_source: "sqlite", + }], + invalid_records: [], + record_authority: "sqlite", + }); + assertEquals(parsed.items[0]?.resource_key, "O-1"); +}); + +Deno.test("Ticket Browser parser validates the canonical Merge Request thread", () => { + const parsed = parseMergeRequestDetailResponse({ + workspace_id: "workspace-a", + merge_request_id: "mr-1", + repository_key: "main", + state: "open", + selector_from: "work/T-1", + selector_to: "develop", + ticket_ids: ["ticket-id"], + created_at: "2026-09-22T00:00:00Z", + updated_at: "2026-09-22T00:00:00Z", + thread: [{ + kind: "review_requested", + event_id: "event-1", + sequence: 1, + subject_ref: "abc123", + requested_by: { runtime_id: "runtime-a", worker_id: "worker-a" }, + reviewer: { runtime_id: "runtime-a", worker_id: "reviewer-a" }, + created_at: "2026-09-22T00:00:00Z", + }], + source: { + status: "resolved", + ref: "abc123", + observed_at: "2026-09-22T00:00:00Z", + }, + target: { + status: "resolved", + ref: "def456", + observed_at: "2026-09-22T00:00:00Z", + }, + linked_tickets: [{ ticket_id: "ticket-id", key: "T-1" }], + }); + assertEquals(parsed.thread[0]?.kind, "review_requested"); + assertThrows( + () => + parseMergeRequestDetailResponse({ + ...parsed, + thread: [{ ...parsed.thread[0], unexpected: true }], + }), + "unknown field unexpected", + ); +}); diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts new file mode 100644 index 000000000..56cadc7ed --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -0,0 +1,1264 @@ +import type { + InvalidProjectRecord, + MergeRequestDetailResponse, + MergeRequestLinkedTicketResponse, + MergeRequestListItem, + MergeRequestListResponse, + MergeRequestRefDiagnostic, + MergeRequestRefResponse, + MergeRequestThreadEvent, + MergeRequestWorkerIdentity, + ObjectiveDetail, + ObjectiveEventDetail, + ObjectiveLinkedTicketSummary, + ObjectiveListResponse, + ObjectiveResourceSummary, + ObjectiveSummary, + QueryPage, + ReviewFinding, + TicketActionEligibility, + TicketAssignmentPrincipal, + TicketAssignmentSummary, + TicketDetail, + TicketDetailDerivedRelation, + TicketDetailRelation, + TicketDetailRelationBlocker, + TicketDetailRelationNotice, + TicketDetailRelationView, + TicketEventDetail, + TicketEvidenceEvent, + TicketEvidenceSummary, + TicketListItemSummary, + TicketListResponse, + TicketMergeRequestSummary, + TicketQueueOutcome, + TicketRoleAssignmentMutationResponse, + TicketRoleAssignmentRecord, + TicketRoleAssignmentSummary, +} from "$lib/generated/ticket-api.ts"; + +export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +export const TICKET_BROWSER_API_LOAD_POLICY = { + diagnosticLabel: "Ticket Browser API", + maxResponseBytes: TICKET_BROWSER_API_MAX_RESPONSE_BYTES, +} as const; + +const MAX_STRING_LENGTH = 262_144; +const MAX_COLLECTION_LENGTH = 4_096; +const MAX_MAP_ENTRIES = 4_096; + +function object(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + allowed: readonly string[], + label: string, +): void { + const unexpected = Object.keys(value).find((key) => !allowed.includes(key)); + if (unexpected !== undefined) { + throw new Error(`${label} contains unknown field ${unexpected}`); + } +} + +function string(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string`); + if (value.length > MAX_STRING_LENGTH) { + throw new Error(`${label} exceeds its length limit`); + } + return value; +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); + return value; +} + +function integer( + value: unknown, + label: string, + minimum = 0, + maximum = Number.MAX_SAFE_INTEGER, +): number { + if ( + typeof value !== "number" || !Number.isSafeInteger(value) || + value < minimum || value > maximum + ) { + throw new Error( + `${label} must be an integer from ${minimum} through ${maximum}`, + ); + } + return value; +} + +function nullableString(value: unknown, label: string): string | null { + return value === null ? null : string(value, label); +} + +function optionalNullableString( + value: unknown, + label: string, +): string | null | undefined { + return value === undefined ? undefined : nullableString(value, label); +} + +function array( + value: unknown, + label: string, + parse: (item: unknown, label: string) => T, +): T[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + if (value.length > MAX_COLLECTION_LENGTH) { + throw new Error(`${label} exceeds its item limit`); + } + return value.map((item, index) => parse(item, `${label}[${index}]`)); +} + +function strings(value: unknown, label: string): string[] { + return array(value, label, string); +} + +function stringMap(value: unknown, label: string): Record { + const source = object(value, label); + const entries = Object.entries(source); + if (entries.length > MAX_MAP_ENTRIES) { + throw new Error(`${label} exceeds its entry limit`); + } + return Object.fromEntries( + entries.map(([key, item]) => [ + string(key, `${label} key`), + string(item, `${label}.${key}`), + ]), + ); +} + +function parseQueryPage(value: unknown, label: string): QueryPage { + const item = object(value, label); + exact(item, [ + "has_more", + "limit", + "next_cursor", + "returned", + "sort", + "source_limit", + "source_truncated", + ], label); + return { + has_more: boolean(item.has_more, `${label}.has_more`), + limit: integer(item.limit, `${label}.limit`), + next_cursor: optionalNullableString( + item.next_cursor, + `${label}.next_cursor`, + ), + returned: integer(item.returned, `${label}.returned`), + sort: string(item.sort, `${label}.sort`), + source_limit: item.source_limit === undefined + ? undefined + : item.source_limit === null + ? null + : integer(item.source_limit, `${label}.source_limit`), + source_truncated: boolean( + item.source_truncated, + `${label}.source_truncated`, + ), + }; +} + +function parseInvalidProjectRecord( + value: unknown, + label: string, +): InvalidProjectRecord { + const item = object(value, label); + exact(item, ["label", "reason"], label); + return { + label: string(item.label, `${label}.label`), + reason: string(item.reason, `${label}.reason`), + }; +} + +function parseTicketListItem( + value: unknown, + label: string, +): TicketListItemSummary { + const item = object(value, label); + exact(item, [ + "id", + "priority", + "queued_at", + "queued_by", + "record_source", + "resource_key", + "state", + "title", + "updated_at", + "workspace_action_priority", + ], label); + return { + id: string(item.id, `${label}.id`), + priority: string(item.priority, `${label}.priority`), + queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), + queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), + record_source: string(item.record_source, `${label}.record_source`), + resource_key: string(item.resource_key, `${label}.resource_key`), + state: string(item.state, `${label}.state`), + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + workspace_action_priority: string( + item.workspace_action_priority, + `${label}.workspace_action_priority`, + ), + }; +} + +export function parseTicketListResponse(value: unknown): TicketListResponse { + const item = object(value, "Ticket list response"); + exact(item, [ + "invalid_records", + "items", + "limit", + "page", + "record_authority", + "workspace_id", + ], "Ticket list response"); + const limit = integer(item.limit, "Ticket list response.limit", 0, 1000); + return { + invalid_records: array( + item.invalid_records, + "Ticket list response.invalid_records", + parseInvalidProjectRecord, + ), + items: array( + item.items, + "Ticket list response.items", + parseTicketListItem, + ), + limit: limit as TicketListResponse["limit"], + page: parseQueryPage(item.page, "Ticket list response.page"), + record_authority: string( + item.record_authority, + "Ticket list response.record_authority", + ), + workspace_id: string( + item.workspace_id, + "Ticket list response.workspace_id", + ), + }; +} + +function parseTicketEvent(value: unknown, label: string): TicketEventDetail { + const item = object(value, label); + exact(item, [ + "at", + "attributes", + "author", + "body", + "event_ref", + "from", + "heading", + "kind", + "reason", + "references", + "sequence", + "state_field", + "status", + "to", + ], label); + return { + at: optionalNullableString(item.at, `${label}.at`), + attributes: stringMap(item.attributes, `${label}.attributes`), + author: optionalNullableString(item.author, `${label}.author`), + body: optionalNullableString(item.body, `${label}.body`), + event_ref: string(item.event_ref, `${label}.event_ref`), + from: optionalNullableString(item.from, `${label}.from`), + heading: optionalNullableString(item.heading, `${label}.heading`), + kind: string(item.kind, `${label}.kind`), + reason: optionalNullableString(item.reason, `${label}.reason`), + references: strings(item.references, `${label}.references`), + sequence: integer(item.sequence, `${label}.sequence`), + state_field: optionalNullableString( + item.state_field, + `${label}.state_field`, + ), + status: optionalNullableString(item.status, `${label}.status`), + to: optionalNullableString(item.to, `${label}.to`), + }; +} + +function parseTicketEvidenceEvent( + value: unknown, + label: string, +): TicketEvidenceEvent { + const item = object(value, label); + exact( + item, + ["at", "author", "event_ref", "excerpt", "kind", "sequence"], + label, + ); + return { + at: optionalNullableString(item.at, `${label}.at`), + author: optionalNullableString(item.author, `${label}.author`), + event_ref: string(item.event_ref, `${label}.event_ref`), + excerpt: string(item.excerpt, `${label}.excerpt`), + kind: string(item.kind, `${label}.kind`), + sequence: integer(item.sequence, `${label}.sequence`), + }; +} + +function parseTicketAssignmentPrincipal( + value: unknown, + label: string, +): TicketAssignmentPrincipal { + const item = object(value, label); + const kind = string(item.kind, `${label}.kind`); + if (kind === "user") { + exact(item, ["account_id", "kind"], label); + return { kind, account_id: string(item.account_id, `${label}.account_id`) }; + } + if (kind === "worker") { + exact(item, ["kind", "runtime_id", "worker_id"], label); + return { + kind, + runtime_id: string(item.runtime_id, `${label}.runtime_id`), + worker_id: string(item.worker_id, `${label}.worker_id`), + }; + } + if (kind === "workspace_agent") { + exact(item, ["agent_key", "kind"], label); + return { kind, agent_key: string(item.agent_key, `${label}.agent_key`) }; + } + throw new Error(`${label}.kind is invalid`); +} + +function parseTicketAssignmentSummary( + value: unknown, + label: string, +): TicketAssignmentSummary { + const item = object(value, label); + exact(item, [ + "assignment_id", + "runtime_id", + "worker_id", + "worker_resource_key", + ], label); + return { + assignment_id: string(item.assignment_id, `${label}.assignment_id`), + runtime_id: string(item.runtime_id, `${label}.runtime_id`), + worker_id: string(item.worker_id, `${label}.worker_id`), + worker_resource_key: optionalNullableString( + item.worker_resource_key, + `${label}.worker_resource_key`, + ), + }; +} + +function parseTicketRoleAssignmentSummary( + value: unknown, + label: string, +): TicketRoleAssignmentSummary { + const item = object(value, label); + exact(item, [ + "assigned_at", + "assigned_by", + "assignment_id", + "principal", + "role", + ], label); + return { + assigned_at: string(item.assigned_at, `${label}.assigned_at`), + assigned_by: string(item.assigned_by, `${label}.assigned_by`), + assignment_id: string(item.assignment_id, `${label}.assignment_id`), + principal: parseTicketAssignmentPrincipal( + item.principal, + `${label}.principal`, + ), + role: string(item.role, `${label}.role`), + }; +} + +function parseTicketRoleAssignmentRecord( + value: unknown, + label: string, +): TicketRoleAssignmentRecord { + const item = object(value, label); + exact(item, [ + "assigned_at", + "assigned_by", + "assignment_id", + "principal", + "role", + "ticket_id", + "workspace_id", + ], label); + const role = string(item.role, `${label}.role`); + if (!["orchestrator", "coder", "owner", "contributor"].includes(role)) { + throw new Error(`${label}.role is invalid`); + } + return { + assigned_at: string(item.assigned_at, `${label}.assigned_at`), + assigned_by: string(item.assigned_by, `${label}.assigned_by`), + assignment_id: string(item.assignment_id, `${label}.assignment_id`), + principal: parseTicketAssignmentPrincipal( + item.principal, + `${label}.principal`, + ), + role: role as TicketRoleAssignmentRecord["role"], + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + }; +} + +function parseActionEligibility( + value: unknown, + label: string, +): TicketActionEligibility { + const item = object(value, label); + exact(item, [ + "blockers", + "can_assign_orchestrator", + "can_queue", + "can_start_manual_coder", + "can_unassign_orchestrator", + "queue_tickets", + ], label); + return { + blockers: strings(item.blockers, `${label}.blockers`), + can_assign_orchestrator: boolean( + item.can_assign_orchestrator, + `${label}.can_assign_orchestrator`, + ), + can_queue: boolean(item.can_queue, `${label}.can_queue`), + can_start_manual_coder: boolean( + item.can_start_manual_coder, + `${label}.can_start_manual_coder`, + ), + can_unassign_orchestrator: boolean( + item.can_unassign_orchestrator, + `${label}.can_unassign_orchestrator`, + ), + queue_tickets: strings(item.queue_tickets, `${label}.queue_tickets`), + }; +} + +function parseTicketMergeRequestSummary( + value: unknown, + label: string, +): TicketMergeRequestSummary { + const item = object(value, label); + exact(item, [ + "current_subject_ref", + "merge_request_id", + "repository_key", + "review_excerpt", + "review_requested_at", + "review_status", + "review_subject_ref", + "review_submitted_at", + "selector_from", + "selector_to", + "state", + "updated_at", + ], label); + return { + current_subject_ref: optionalNullableString( + item.current_subject_ref, + `${label}.current_subject_ref`, + ), + merge_request_id: string( + item.merge_request_id, + `${label}.merge_request_id`, + ), + repository_key: string(item.repository_key, `${label}.repository_key`), + review_excerpt: optionalNullableString( + item.review_excerpt, + `${label}.review_excerpt`, + ), + review_requested_at: optionalNullableString( + item.review_requested_at, + `${label}.review_requested_at`, + ), + review_status: string(item.review_status, `${label}.review_status`), + review_subject_ref: optionalNullableString( + item.review_subject_ref, + `${label}.review_subject_ref`, + ), + review_submitted_at: optionalNullableString( + item.review_submitted_at, + `${label}.review_submitted_at`, + ), + selector_from: optionalNullableString( + item.selector_from, + `${label}.selector_from`, + ), + selector_to: string(item.selector_to, `${label}.selector_to`), + state: string(item.state, `${label}.state`), + updated_at: string(item.updated_at, `${label}.updated_at`), + }; +} + +function parseTicketEvidenceSummary( + value: unknown, + label: string, +): TicketEvidenceSummary { + const item = object(value, label); + exact(item, [ + "approved_current_subject", + "complete_for_integration", + "has_commit", + "has_current_subject_ref", + "has_merge_request", + "has_review_request", + "missing", + "review_after_rescope", + "review_status", + "unresolved_request_changes", + ], label); + return { + approved_current_subject: boolean( + item.approved_current_subject, + `${label}.approved_current_subject`, + ), + complete_for_integration: boolean( + item.complete_for_integration, + `${label}.complete_for_integration`, + ), + has_commit: boolean(item.has_commit, `${label}.has_commit`), + has_current_subject_ref: boolean( + item.has_current_subject_ref, + `${label}.has_current_subject_ref`, + ), + has_merge_request: boolean( + item.has_merge_request, + `${label}.has_merge_request`, + ), + has_review_request: boolean( + item.has_review_request, + `${label}.has_review_request`, + ), + missing: strings(item.missing, `${label}.missing`), + review_after_rescope: boolean( + item.review_after_rescope, + `${label}.review_after_rescope`, + ), + review_status: optionalNullableString( + item.review_status, + `${label}.review_status`, + ), + unresolved_request_changes: boolean( + item.unresolved_request_changes, + `${label}.unresolved_request_changes`, + ), + }; +} + +function parseRelation(value: unknown, label: string): TicketDetailRelation { + const item = object(value, label); + exact(item, [ + "at", + "author", + "kind", + "note", + "target", + "target_resource_key", + "ticket_id", + ], label); + return { + at: string(item.at, `${label}.at`), + author: string(item.author, `${label}.author`), + kind: string(item.kind, `${label}.kind`), + note: optionalNullableString(item.note, `${label}.note`), + target: string(item.target, `${label}.target`), + target_resource_key: optionalNullableString( + item.target_resource_key, + `${label}.target_resource_key`, + ), + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + }; +} + +function parseDerivedRelation( + value: unknown, + label: string, +): TicketDetailDerivedRelation { + const item = object(value, label); + exact(item, [ + "at", + "author", + "forward_kind", + "inverse_kind", + "note", + "source_resource_key", + "source_ticket", + ], label); + return { + at: string(item.at, `${label}.at`), + author: string(item.author, `${label}.author`), + forward_kind: string(item.forward_kind, `${label}.forward_kind`), + inverse_kind: string(item.inverse_kind, `${label}.inverse_kind`), + note: optionalNullableString(item.note, `${label}.note`), + source_resource_key: optionalNullableString( + item.source_resource_key, + `${label}.source_resource_key`, + ), + source_ticket: string(item.source_ticket, `${label}.source_ticket`), + }; +} + +function parseRelationBlocker( + value: unknown, + label: string, +): TicketDetailRelationBlocker { + const item = object(value, label); + exact(item, [ + "blocking_resource_key", + "blocking_state", + "blocking_ticket", + "note", + "reason_kind", + "relation_kind", + ], label); + return { + blocking_resource_key: optionalNullableString( + item.blocking_resource_key, + `${label}.blocking_resource_key`, + ), + blocking_state: string(item.blocking_state, `${label}.blocking_state`), + blocking_ticket: string(item.blocking_ticket, `${label}.blocking_ticket`), + note: optionalNullableString(item.note, `${label}.note`), + reason_kind: string(item.reason_kind, `${label}.reason_kind`), + relation_kind: string(item.relation_kind, `${label}.relation_kind`), + }; +} + +function parseRelationNotice( + value: unknown, + label: string, +): TicketDetailRelationNotice { + const item = object(value, label); + exact(item, ["kind", "message", "related_ticket"], label); + return { + kind: string(item.kind, `${label}.kind`), + message: string(item.message, `${label}.message`), + related_ticket: string(item.related_ticket, `${label}.related_ticket`), + }; +} + +function parseRelationView( + value: unknown, + label: string, +): TicketDetailRelationView { + const item = object(value, label); + exact(item, ["blockers", "incoming", "notices", "outgoing"], label); + return { + blockers: array(item.blockers, `${label}.blockers`, parseRelationBlocker), + incoming: array(item.incoming, `${label}.incoming`, parseDerivedRelation), + notices: array(item.notices, `${label}.notices`, parseRelationNotice), + outgoing: array(item.outgoing, `${label}.outgoing`, parseRelation), + }; +} + +function parseObjectiveLink(value: unknown, label: string) { + const item = object(value, label); + exact(item, ["id", "resource_key", "state", "title"], label); + return { + id: string(item.id, `${label}.id`), + resource_key: string(item.resource_key, `${label}.resource_key`), + state: string(item.state, `${label}.state`), + title: string(item.title, `${label}.title`), + }; +} + +export function parseTicketDetail(value: unknown): TicketDetail { + const label = "Ticket detail"; + const item = object(value, label); + exact(item, [ + "action_eligibility", + "artifact_count", + "artifacts", + "assignment_diagnostics", + "assignments", + "body", + "body_truncated", + "created_at", + "current_coder", + "event_count", + "event_page", + "events", + "evidence", + "id", + "implementation_reports", + "item_revision", + "linked_objectives", + "merge_request", + "priority", + "queued_at", + "queued_by", + "readiness", + "record_source", + "ref_selector", + "relations", + "repository_key", + "resolution", + "resource_key", + "risk_flags", + "state", + "title", + "updated_at", + ], label); + return { + action_eligibility: parseActionEligibility( + item.action_eligibility, + `${label}.action_eligibility`, + ), + artifact_count: integer(item.artifact_count, `${label}.artifact_count`), + artifacts: strings(item.artifacts, `${label}.artifacts`), + assignment_diagnostics: strings( + item.assignment_diagnostics, + `${label}.assignment_diagnostics`, + ), + assignments: array( + item.assignments, + `${label}.assignments`, + parseTicketRoleAssignmentSummary, + ), + body: string(item.body, `${label}.body`), + body_truncated: boolean(item.body_truncated, `${label}.body_truncated`), + created_at: optionalNullableString(item.created_at, `${label}.created_at`), + current_coder: item.current_coder === undefined + ? undefined + : item.current_coder === null + ? null + : parseTicketAssignmentSummary( + item.current_coder, + `${label}.current_coder`, + ), + event_count: integer(item.event_count, `${label}.event_count`), + event_page: parseQueryPage(item.event_page, `${label}.event_page`), + events: array(item.events, `${label}.events`, parseTicketEvent), + evidence: parseTicketEvidenceSummary(item.evidence, `${label}.evidence`), + id: string(item.id, `${label}.id`), + implementation_reports: array( + item.implementation_reports, + `${label}.implementation_reports`, + parseTicketEvidenceEvent, + ), + item_revision: string(item.item_revision, `${label}.item_revision`), + linked_objectives: array( + item.linked_objectives, + `${label}.linked_objectives`, + parseObjectiveLink, + ), + merge_request: item.merge_request === undefined + ? undefined + : item.merge_request === null + ? null + : parseTicketMergeRequestSummary( + item.merge_request, + `${label}.merge_request`, + ), + priority: string(item.priority, `${label}.priority`), + queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), + queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), + readiness: optionalNullableString(item.readiness, `${label}.readiness`), + record_source: string(item.record_source, `${label}.record_source`), + ref_selector: optionalNullableString( + item.ref_selector, + `${label}.ref_selector`, + ), + relations: parseRelationView(item.relations, `${label}.relations`), + repository_key: optionalNullableString( + item.repository_key, + `${label}.repository_key`, + ), + resolution: optionalNullableString(item.resolution, `${label}.resolution`), + resource_key: string(item.resource_key, `${label}.resource_key`), + risk_flags: strings(item.risk_flags, `${label}.risk_flags`), + state: string(item.state, `${label}.state`), + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + }; +} + +export function parseTicketQueueOutcome(value: unknown): TicketQueueOutcome { + const item = object(value, "Ticket queue outcome"); + exact(item, ["queued_tickets", "requested_ticket"], "Ticket queue outcome"); + return { + queued_tickets: strings( + item.queued_tickets, + "Ticket queue outcome.queued_tickets", + ), + requested_ticket: string( + item.requested_ticket, + "Ticket queue outcome.requested_ticket", + ), + }; +} + +export function parseTicketRoleAssignmentMutationResponse( + value: unknown, +): TicketRoleAssignmentMutationResponse { + const label = "Ticket assignment response"; + const item = object(value, label); + exact(item, ["assignment", "ticket_id", "workspace_id"], label); + return { + assignment: item.assignment === undefined + ? undefined + : item.assignment === null + ? null + : parseTicketRoleAssignmentRecord(item.assignment, `${label}.assignment`), + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + }; +} + +function parseObjectiveSummary( + value: unknown, + label: string, +): ObjectiveSummary { + const item = object(value, label); + exact(item, [ + "created_at", + "id", + "linked_tickets", + "record_source", + "resource_key", + "state", + "summary", + "title", + "updated_at", + ], label); + return { + created_at: optionalNullableString(item.created_at, `${label}.created_at`), + id: string(item.id, `${label}.id`), + linked_tickets: strings(item.linked_tickets, `${label}.linked_tickets`), + record_source: string(item.record_source, `${label}.record_source`), + resource_key: string(item.resource_key, `${label}.resource_key`), + state: string(item.state, `${label}.state`), + summary: string(item.summary, `${label}.summary`), + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + }; +} + +export function parseObjectiveListResponse( + value: unknown, +): ObjectiveListResponse { + const label = "Objective list response"; + const item = object(value, label); + exact(item, [ + "invalid_records", + "items", + "limit", + "record_authority", + "workspace_id", + ], label); + const limit = integer(item.limit, `${label}.limit`, 0, 1000); + return { + invalid_records: array( + item.invalid_records, + `${label}.invalid_records`, + parseInvalidProjectRecord, + ), + items: array(item.items, `${label}.items`, parseObjectiveSummary), + limit: limit as ObjectiveListResponse["limit"], + record_authority: string( + item.record_authority, + `${label}.record_authority`, + ), + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + }; +} + +function parseObjectiveEvent( + value: unknown, + label: string, +): ObjectiveEventDetail { + const item = object(value, label); + exact(item, ["body", "created_at", "event_ref", "kind"], label); + return { + body: optionalNullableString(item.body, `${label}.body`), + created_at: string(item.created_at, `${label}.created_at`), + event_ref: string(item.event_ref, `${label}.event_ref`), + kind: string(item.kind, `${label}.kind`), + }; +} + +function parseObjectiveLinkedTicket( + value: unknown, + label: string, +): ObjectiveLinkedTicketSummary { + return parseObjectiveLink(value, label); +} + +function parseObjectiveResource( + value: unknown, + label: string, +): ObjectiveResourceSummary { + const item = object(value, label); + exact(item, ["bytes", "media_type", "path", "updated_at"], label); + return { + bytes: integer(item.bytes, `${label}.bytes`), + media_type: optionalNullableString(item.media_type, `${label}.media_type`), + path: string(item.path, `${label}.path`), + updated_at: string(item.updated_at, `${label}.updated_at`), + }; +} + +export function parseObjectiveDetail(value: unknown): ObjectiveDetail { + const label = "Objective detail"; + const item = object(value, label); + exact(item, [ + "body", + "body_truncated", + "created_at", + "event_page", + "events", + "id", + "linked_ticket_summaries", + "linked_tickets", + "record_source", + "resource_key", + "resources", + "revision", + "state", + "title", + "updated_at", + ], label); + return { + body: string(item.body, `${label}.body`), + body_truncated: boolean(item.body_truncated, `${label}.body_truncated`), + created_at: optionalNullableString(item.created_at, `${label}.created_at`), + event_page: parseQueryPage(item.event_page, `${label}.event_page`), + events: array(item.events, `${label}.events`, parseObjectiveEvent), + id: string(item.id, `${label}.id`), + linked_ticket_summaries: array( + item.linked_ticket_summaries, + `${label}.linked_ticket_summaries`, + parseObjectiveLinkedTicket, + ), + linked_tickets: strings(item.linked_tickets, `${label}.linked_tickets`), + record_source: string(item.record_source, `${label}.record_source`), + resource_key: string(item.resource_key, `${label}.resource_key`), + resources: array( + item.resources, + `${label}.resources`, + parseObjectiveResource, + ), + revision: string(item.revision, `${label}.revision`), + state: string(item.state, `${label}.state`), + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + }; +} + +function parseMergeRequestDiagnostic( + value: unknown, + label: string, +): MergeRequestRefDiagnostic { + const item = object(value, label); + exact(item, ["code", "message"], label); + return { + code: string(item.code, `${label}.code`), + message: string(item.message, `${label}.message`), + }; +} + +function parseMergeRequestListItem( + value: unknown, + label: string, +): MergeRequestListItem { + const item = object(value, label); + exact(item, [ + "ref_diagnostics", + "summary", + "thread_event_count", + "ticket_ids", + ], label); + return { + ref_diagnostics: item.ref_diagnostics === undefined ? undefined : array( + item.ref_diagnostics, + `${label}.ref_diagnostics`, + parseMergeRequestDiagnostic, + ), + summary: parseTicketMergeRequestSummary(item.summary, `${label}.summary`), + thread_event_count: integer( + item.thread_event_count, + `${label}.thread_event_count`, + ), + ticket_ids: strings(item.ticket_ids, `${label}.ticket_ids`), + }; +} + +export function parseMergeRequestListResponse( + value: unknown, +): MergeRequestListResponse { + const label = "Merge Request list response"; + const item = object(value, label); + exact(item, ["items", "next_cursor"], label); + return { + items: array(item.items, `${label}.items`, parseMergeRequestListItem), + next_cursor: optionalNullableString( + item.next_cursor, + `${label}.next_cursor`, + ), + }; +} + +function parseMergeRequestWorker( + value: unknown, + label: string, +): MergeRequestWorkerIdentity { + const item = object(value, label); + exact(item, ["runtime_id", "worker_id"], label); + return { + runtime_id: string(item.runtime_id, `${label}.runtime_id`), + worker_id: string(item.worker_id, `${label}.worker_id`), + }; +} + +function parseReviewFinding(value: unknown, label: string): ReviewFinding { + const item = object(value, label); + exact(item, ["body", "code", "line", "path", "severity"], label); + const severity = string(item.severity, `${label}.severity`); + if (!["blocker", "major", "minor", "note"].includes(severity)) { + throw new Error(`${label}.severity is invalid`); + } + return { + body: string(item.body, `${label}.body`), + code: optionalNullableString(item.code, `${label}.code`), + line: item.line === undefined + ? undefined + : item.line === null + ? null + : integer(item.line, `${label}.line`, 0, 4_294_967_295), + path: optionalNullableString(item.path, `${label}.path`), + severity: severity as ReviewFinding["severity"], + }; +} + +function parseMergeRequestThreadEvent( + value: unknown, + label: string, +): MergeRequestThreadEvent { + const item = object(value, label); + const kind = string(item.kind, `${label}.kind`); + const base = ["created_at", "event_id", "kind", "sequence"]; + const eventBase = { + created_at: string(item.created_at, `${label}.created_at`), + event_id: string(item.event_id, `${label}.event_id`), + sequence: integer(item.sequence, `${label}.sequence`), + }; + if (kind === "review_requested") { + exact(item, [...base, "requested_by", "reviewer", "subject_ref"], label); + return { + ...eventBase, + kind, + requested_by: parseMergeRequestWorker( + item.requested_by, + `${label}.requested_by`, + ), + reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`), + subject_ref: string(item.subject_ref, `${label}.subject_ref`), + }; + } + if (kind === "review") { + exact(item, [ + ...base, + "body", + "decision", + "findings", + "request_event_id", + "reviewer", + "subject_ref", + ], label); + const decision = string(item.decision, `${label}.decision`); + if (!["approve", "request_changes"].includes(decision)) { + throw new Error(`${label}.decision is invalid`); + } + return { + ...eventBase, + kind, + body: string(item.body, `${label}.body`), + decision: decision as "approve" | "request_changes", + findings: array(item.findings, `${label}.findings`, parseReviewFinding), + request_event_id: string( + item.request_event_id, + `${label}.request_event_id`, + ), + reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`), + subject_ref: string(item.subject_ref, `${label}.subject_ref`), + }; + } + if (kind === "review_revoked") { + exact(item, [ + ...base, + "reason", + "review_event_id", + "revoked_by", + "subject_ref", + ], label); + return { + ...eventBase, + kind, + reason: string(item.reason, `${label}.reason`), + review_event_id: string(item.review_event_id, `${label}.review_event_id`), + revoked_by: parseMergeRequestWorker( + item.revoked_by, + `${label}.revoked_by`, + ), + subject_ref: string(item.subject_ref, `${label}.subject_ref`), + }; + } + if (kind === "review_cancelled") { + exact(item, [...base, "reason", "request_event_id", "subject_ref"], label); + return { + ...eventBase, + kind, + reason: string(item.reason, `${label}.reason`), + request_event_id: string( + item.request_event_id, + `${label}.request_event_id`, + ), + subject_ref: string(item.subject_ref, `${label}.subject_ref`), + }; + } + if (kind === "comment") { + exact(item, [...base, "author", "body"], label); + return { + ...eventBase, + kind, + author: parseMergeRequestWorker(item.author, `${label}.author`), + body: string(item.body, `${label}.body`), + }; + } + if (kind === "merge") { + exact(item, [ + ...base, + "approval_event_id", + "approved_source_ref", + "merged_by", + "operation_id", + "resolution", + "strategy", + "target_ref_after", + "target_ref_before", + ], label); + const strategy = string(item.strategy, `${label}.strategy`); + const resolution = string(item.resolution, `${label}.resolution`); + if (!["fast_forward", "merge"].includes(strategy)) { + throw new Error(`${label}.strategy is invalid`); + } + if (!["none", "clean", "conflicts_resolved"].includes(resolution)) { + throw new Error(`${label}.resolution is invalid`); + } + return { + ...eventBase, + kind, + approval_event_id: string( + item.approval_event_id, + `${label}.approval_event_id`, + ), + approved_source_ref: string( + item.approved_source_ref, + `${label}.approved_source_ref`, + ), + merged_by: parseMergeRequestWorker(item.merged_by, `${label}.merged_by`), + operation_id: string(item.operation_id, `${label}.operation_id`), + resolution: resolution as "none" | "clean" | "conflicts_resolved", + strategy: strategy as "fast_forward" | "merge", + target_ref_after: string( + item.target_ref_after, + `${label}.target_ref_after`, + ), + target_ref_before: string( + item.target_ref_before, + `${label}.target_ref_before`, + ), + }; + } + throw new Error(`${label}.kind is invalid`); +} + +function parseMergeRequestRef( + value: unknown, + label: string, +): MergeRequestRefResponse { + const item = object(value, label); + exact(item, ["diagnostic", "observed_at", "ref", "status"], label); + return { + diagnostic: item.diagnostic === undefined + ? undefined + : item.diagnostic === null + ? null + : parseMergeRequestDiagnostic(item.diagnostic, `${label}.diagnostic`), + observed_at: string(item.observed_at, `${label}.observed_at`), + ref: optionalNullableString(item.ref, `${label}.ref`), + status: string(item.status, `${label}.status`), + }; +} + +function parseLinkedTicket( + value: unknown, + label: string, +): MergeRequestLinkedTicketResponse { + const item = object(value, label); + exact(item, ["key", "ticket_id"], label); + return { + key: optionalNullableString(item.key, `${label}.key`), + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + }; +} + +export function parseMergeRequestDetailResponse( + value: unknown, +): MergeRequestDetailResponse { + const label = "Merge Request detail"; + const item = object(value, label); + exact(item, [ + "created_at", + "linked_tickets", + "merge_request_id", + "repository_key", + "selector_from", + "selector_to", + "source", + "state", + "target", + "thread", + "ticket_ids", + "updated_at", + "workspace_id", + ], label); + const state = string(item.state, `${label}.state`); + if (!["open", "merged", "closed"].includes(state)) { + throw new Error(`${label}.state is invalid`); + } + return { + created_at: string(item.created_at, `${label}.created_at`), + linked_tickets: array( + item.linked_tickets, + `${label}.linked_tickets`, + parseLinkedTicket, + ), + merge_request_id: string( + item.merge_request_id, + `${label}.merge_request_id`, + ), + repository_key: string(item.repository_key, `${label}.repository_key`), + selector_from: optionalNullableString( + item.selector_from, + `${label}.selector_from`, + ), + selector_to: string(item.selector_to, `${label}.selector_to`), + source: parseMergeRequestRef(item.source, `${label}.source`), + state: state as MergeRequestDetailResponse["state"], + target: parseMergeRequestRef(item.target, `${label}.target`), + thread: array(item.thread, `${label}.thread`, parseMergeRequestThreadEvent), + ticket_ids: strings(item.ticket_ids, `${label}.ticket_ids`), + updated_at: string(item.updated_at, `${label}.updated_at`), + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + }; +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index aef46f99d..9019922ee 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -385,7 +385,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as "Tickets, Objectives, and Merge Requests should each be a single primary sidebar link", ); assert( - ticketsLoad.includes("Object.entries(LANE_STATES)") && + ticketsLoad.includes("loadLane(fetch, workspaceId") && ticketsLoad.includes('limit: "30"') && ticketsLoad.includes('states: states.join(",")') && ticketsLoad.includes("/tickets?${search}") && @@ -405,9 +405,12 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as "Ticket Kanban should combine related states into independent 30-item display windows", ); assert( - generatedTicketApi.includes("Generated from yoi-workspace-server") && + generatedTicketApi.includes( + "@generated by yoi-ticket-browser-openapi-typescript@1", + ) && + generatedTicketApi.includes("Input contract: 0.1.0 (sha256:") && generatedTicketApi.includes("export type TicketListResponse") && - generatedTicketApi.includes("items: Array"), + generatedTicketApi.includes("items: Array"), "Ticket response types should come from the generated Server contract", ); assert( diff --git a/web/workspace/src/lib/workspace/merge-request-status.ts b/web/workspace/src/lib/workspace/merge-request-status.ts index 536476e75..03a584e1b 100644 --- a/web/workspace/src/lib/workspace/merge-request-status.ts +++ b/web/workspace/src/lib/workspace/merge-request-status.ts @@ -3,11 +3,17 @@ import type { MergeRequestThreadEvent, } from "./api/merge-requests.ts"; +type ReviewEvent = Extract; +type SourceEvidenceEvent = Extract< + MergeRequestThreadEvent, + { kind: "review" | "review_requested" } +>; + function isCurrentSourceEvent( event: MergeRequestThreadEvent, source: string, ): boolean { - return event.subject_ref === source; + return "subject_ref" in event && event.subject_ref === source; } function requestHasTerminalOutcome( @@ -26,16 +32,18 @@ export function sourceReviewFreshness( const source = mergeRequest.source.ref; if (!source) return "Source review unavailable: selector_from is unresolved."; - const effectiveReview = [...mergeRequest.thread].reverse().find((event) => { - if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) { - return false; - } - return !mergeRequest.thread.some( - (candidate) => - candidate.kind === "review_revoked" && - candidate.review_event_id === event.event_id, - ); - }); + const effectiveReview = [...mergeRequest.thread].reverse().find( + (event): event is ReviewEvent => { + if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) { + return false; + } + return !mergeRequest.thread.some( + (candidate) => + candidate.kind === "review_revoked" && + candidate.review_event_id === event.event_id, + ); + }, + ); if (effectiveReview) { return effectiveReview.decision === "approve" ? `Current source approved at exact ref ${source}.` @@ -43,9 +51,8 @@ export function sourceReviewFreshness( } const latestEvidence = [...mergeRequest.thread].reverse().find( - (event) => - (event.kind === "review" || event.kind === "review_requested") && - typeof event.subject_ref === "string", + (event): event is SourceEvidenceEvent => + event.kind === "review" || event.kind === "review_requested", ); if (latestEvidence?.subject_ref && latestEvidence.subject_ref !== source) { return `Fresh source review required: selector_from moved from ${latestEvidence.subject_ref} to ${source}.`; diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index d17a338d9..f1c7bf0c9 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -82,15 +82,17 @@ export type WorkerCapabilities = SharedWorkerCapabilitySummary; export type WorkerWorkdirAttachment = SharedWorkerWorkdirAttachmentSummary; -export type Worker = Omit< - SharedWorkerSummary, - "display_name" | "tags" | "worker_state" | "diagnostics" -> & { - display_name: string; - tags: string[]; - worker_state?: WorkerStateSnapshot | null; - diagnostics: Diagnostic[]; -}; +export type Worker = + & Omit< + SharedWorkerSummary, + "display_name" | "tags" | "worker_state" | "diagnostics" + > + & { + display_name: string; + tags: string[]; + worker_state?: WorkerStateSnapshot | null; + diagnostics: Diagnostic[]; + }; export type WorkerOperationState = "accepted" | "unsupported" | "rejected"; @@ -149,61 +151,22 @@ export type RepositoryDetailResponse = SharedRepositoryDetailResponse; export type RepositoryLogResponse = SharedRepositoryLogResponse; export type { - DerivedTicketRelation, + ObjectiveDetail, + ObjectiveLinkedTicketSummary, + ObjectiveListResponse, + ObjectiveSummary, TicketDetail, TicketEventDetail, TicketListResponse, - TicketRelation, - TicketRelationBlocker, - TicketRelationNotice, - TicketRelationView, - TicketSummary, } from "$lib/generated/ticket-api"; - -export type ObjectiveSummary = { - id: string; - resource_key: string; - title: string; - state: string; - updated_at?: string | null; - summary: string; - linked_tickets?: string[]; - record_source?: string; -}; - -export type ObjectiveLinkedTicketSummary = { - id: string; - resource_key: string; - title: string; - state: string; -}; - -export type ObjectiveDetail = { - id: string; - resource_key: string; - title: string; - state: string; - created_at?: string | null; - updated_at?: string | null; - linked_tickets: string[]; - linked_ticket_summaries: ObjectiveLinkedTicketSummary[]; - body: string; - body_truncated: boolean; - record_source: string; -}; - -export type InvalidProjectRecord = { - label: string; - reason: string; -}; - -export type ObjectiveListResponse = { - workspace_id: string; - limit: number; - items: ObjectiveSummary[]; - invalid_records: InvalidProjectRecord[]; - record_authority: string; -}; +export type { + TicketDetailDerivedRelation as DerivedTicketRelation, + TicketDetailRelation as TicketRelation, + TicketDetailRelationBlocker as TicketRelationBlocker, + TicketDetailRelationNotice as TicketRelationNotice, + TicketDetailRelationView as TicketRelationView, + TicketListItemSummary as TicketSummary, +} from "$lib/generated/ticket-api"; export type { CompanionCancelRequest, diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index 520f4e900..25226c099 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -7,7 +7,7 @@ import { } from "./ticket-panel.ts"; import type { TicketDetail, - TicketSummary, + TicketListItemSummary as TicketSummary, } from "../../generated/ticket-api.ts"; declare const Deno: { @@ -120,7 +120,10 @@ Deno.test("ticket detail uses server-derived role assignment actions", async () assertEquals(source.includes("ticket.action_eligibility.can_queue"), true); assertEquals(source.includes("ticket.relations.blockers.length > 0"), true); - assertEquals(source.includes("ticket.action_eligibility.queue_tickets"), true); + assertEquals( + source.includes("ticket.action_eligibility.queue_tickets"), + true, + ); assertEquals(source.includes("This operation queues:"), true); assertEquals(source.includes("outcome.queued_tickets.join"), true); assertEquals( diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 428a9f1d3..0ead20777 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -1,5 +1,8 @@ import type { BrowserWorkspaceOrchestratorResponse } from "$lib/generated/worker-launch-api"; -import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api"; +import type { + TicketDetail, + TicketListItemSummary, +} from "$lib/generated/ticket-api"; export const TICKET_STATES = [ "planning", @@ -38,7 +41,7 @@ export const TICKET_LANE_PAGE_SIZE = 30; export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number]; export type TicketLaneId = TicketLaneDefinition["id"]; export type TicketCardSummary = Pick< - TicketSummary, + TicketListItemSummary, "id" | "resource_key" | "title" | "state" | "priority" | "updated_at" >; diff --git a/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts b/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts index c8639c92d..7cc98ce4e 100644 --- a/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts @@ -1,33 +1,22 @@ import type { PageLoad } from "./$types"; +import { loadJson } from "$lib/workspace/api/http"; +import { mergeRequestCollectionPath } from "$lib/workspace/api/merge-requests"; import { - mergeRequestCollectionPath, - type MergeRequestListPage, -} from "$lib/workspace/api/merge-requests"; + parseMergeRequestListResponse, + TICKET_BROWSER_API_LOAD_POLICY, +} from "$lib/workspace/api/ticket-browser"; export const load: PageLoad = async ({ params, fetch }) => { - try { - const response = await fetch( - `${mergeRequestCollectionPath(params.workspaceId)}?limit=100`, - ); - const body = await response.json().catch(() => ({})); - if (!response.ok) { - return { - workspaceId: params.workspaceId, - mergeRequests: null, - error: body?.error ?? body?.message ?? - `Request failed (${response.status})`, - }; - } - return { - workspaceId: params.workspaceId, - mergeRequests: body as MergeRequestListPage, - error: null, - }; - } catch (error) { - return { - workspaceId: params.workspaceId, - mergeRequests: null, - error: error instanceof Error ? error.message : String(error), - }; - } + const result = await loadJson( + fetch, + `${mergeRequestCollectionPath(params.workspaceId)}?limit=100`, + undefined, + parseMergeRequestListResponse, + TICKET_BROWSER_API_LOAD_POLICY, + ); + return { + workspaceId: params.workspaceId, + mergeRequests: result.data, + error: result.error, + }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte index 90dcf7087..93507274b 100644 --- a/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte @@ -1,4 +1,5 @@