use project_record::validate_record_id; use serde::{Deserialize, Serialize}; use crate::{Error, Result}; const SUMMARY_BODY_LIMIT: usize = 240; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProjectRecordList { pub items: Vec, pub invalid_records: Vec, pub record_authority: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TicketSummaryPage { pub items: Vec, pub page: QueryPage, pub invalid_records: Vec, pub record_authority: String, } #[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 human_key: String, pub title: String, pub state: String, pub priority: String, pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, pub workspace_action_priority: String, pub record_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct TicketListPageRequest { #[serde(default)] pub states: Vec, #[serde(default)] pub limit: Option, #[serde(default)] pub cursor: Option, } #[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, pub items: Vec, pub page: QueryPage, pub invalid_records: Vec, pub record_authority: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketDetail { pub id: String, pub human_key: String, pub title: String, pub state: String, pub readiness: Option, pub priority: String, pub created_at: Option, pub updated_at: Option, pub item_revision: String, pub queued_by: Option, pub queued_at: Option, pub assignee: Option, pub repository_id: Option, pub ref_selector: Option, pub risk_flags: Vec, pub body: String, pub body_truncated: bool, pub event_count: usize, pub events: Vec, pub event_page: QueryPage, pub artifact_count: usize, pub artifacts: Vec, pub relations: TicketRelationView, pub linked_objectives: Vec, pub implementation_reports: Vec, pub current_assignment: Option, pub merge_request: Option, pub evidence: TicketEvidenceSummary, pub resolution: Option, pub record_source: String, } #[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, pub kind: String, pub author: Option, pub at: Option, pub status: Option, pub from: Option, pub to: Option, pub reason: Option, pub state_field: Option, pub heading: Option, pub body: Option, pub attributes: std::collections::BTreeMap, pub references: Vec, } #[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, pub target: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_human_key: Option, pub note: Option, pub author: String, pub at: String, } #[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")] pub source_human_key: Option, pub inverse_kind: String, pub forward_kind: String, pub note: Option, pub author: String, pub at: String, } #[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")] pub blocking_human_key: Option, pub reason_kind: String, pub relation_kind: String, pub note: Option, pub blocking_state: String, } #[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, pub message: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketRelationView { pub outgoing: Vec, pub incoming: Vec, pub blockers: Vec, pub notices: Vec, } impl From for TicketRelationView { fn from(value: ticket::TicketRelationView) -> Self { Self { outgoing: value .outgoing .into_iter() .map(|relation| TicketRelation { ticket_id: relation.ticket_id, kind: relation.kind.as_str().to_string(), target: relation.target, target_human_key: None, note: relation.note, author: relation.author, at: relation.at, }) .collect(), incoming: value .incoming .into_iter() .map(|relation| DerivedTicketRelation { source_ticket: relation.source_ticket, source_human_key: None, inverse_kind: relation.inverse_kind, forward_kind: relation.forward_kind.as_str().to_string(), note: relation.note, author: relation.author, at: relation.at, }) .collect(), blockers: value .blockers .into_iter() .map(|blocker| TicketRelationBlocker { blocking_ticket: blocker.blocking_ticket, blocking_human_key: None, reason_kind: blocker.reason_kind, relation_kind: blocker.relation_kind.as_str().to_string(), note: blocker.note, blocking_state: blocker.blocking_state.as_str().to_string(), }) .collect(), notices: value .notices .into_iter() .map(|notice| TicketRelationNotice { related_ticket: notice.related_ticket, kind: notice.kind.as_str().to_string(), message: notice.message, }) .collect(), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct QueryPage { pub limit: usize, pub returned: usize, pub has_more: bool, pub next_cursor: Option, pub sort: String, pub source_limit: Option, pub source_truncated: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct ObjectiveLinkSummary { pub id: String, pub human_key: String, pub title: String, pub state: String, } #[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, pub kind: String, pub at: Option, pub author: Option, pub excerpt: String, } #[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, pub worker_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub worker_human_key: Option, } #[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_id: String, pub state: String, pub review_status: String, pub selector_from: Option, pub selector_to: String, pub updated_at: String, pub current_subject_ref: Option, pub review_subject_ref: Option, pub review_requested_at: Option, pub review_submitted_at: Option, pub review_excerpt: 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, pub has_review_request: bool, pub has_commit: bool, pub review_status: Option, pub approved_current_subject: bool, pub review_after_rescope: bool, pub unresolved_request_changes: bool, pub complete_for_integration: bool, pub missing: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketQueryRequest { pub query: Option, #[serde(default)] pub states: Vec, #[serde(default)] pub event_kinds: Vec, #[serde(default)] pub evidence: Vec, pub review_status: Option, #[serde(default)] pub attention: Vec, pub related_ticket_id: Option, pub relation_kind: Option, pub linked_objective_id: Option, pub updated_after: Option, pub updated_before: Option, pub sort: Option, pub limit: Option, pub cursor: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketQueryItem { pub id: String, pub human_key: String, pub title: String, pub state: String, pub readiness: Option, pub priority: String, pub created_at: Option, pub updated_at: Option, pub item_revision: String, pub workspace_action_priority: String, pub matched_fields: Vec, pub snippet: Option, pub matching_event: Option, pub linked_objective_ids: Vec, pub relation_count: usize, pub blocker_count: usize, pub unresolved_blocker_count: usize, pub unresolved_review_count: usize, pub evidence: TicketEvidenceSummary, pub merge_request: 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, pub record_authority: String, } #[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, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct ObjectiveQueryRequest { pub query: Option, #[serde(default)] pub states: Vec, pub linked_ticket_id: Option, pub updated_after: Option, pub updated_before: Option, pub sort: Option, pub limit: Option, pub cursor: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveQueryItem { pub id: String, pub title: String, pub state: String, pub created_at: Option, pub updated_at: Option, pub matched_fields: Vec, pub snippet: Option, pub linked_ticket_count: usize, pub linked_tickets: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveQueryResponse { pub items: Vec, pub page: QueryPage, pub record_authority: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct ObjectiveShowRequest { pub event_limit: Option, pub event_cursor: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct ObjectiveEventDetail { pub event_ref: String, pub kind: String, pub body: Option, pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveLinkedTicketSummary { pub id: String, pub human_key: String, pub title: String, pub state: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, pub human_key: String, pub title: String, pub state: String, pub created_at: Option, pub updated_at: Option, pub summary: String, pub linked_tickets: Vec, pub record_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveDetail { pub id: String, pub human_key: String, pub title: String, pub state: String, pub revision: String, pub created_at: Option, pub updated_at: Option, pub linked_tickets: Vec, pub linked_ticket_summaries: Vec, pub resources: Vec, pub body: String, pub body_truncated: bool, pub events: Vec, pub event_page: QueryPage, pub record_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveResourceSummary { pub path: String, pub media_type: Option, pub bytes: usize, pub updated_at: String, } #[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), TicketMergeRequestSummary::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 validate_project_id(id: &str) -> Result<()> { validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string())) } pub(crate) fn summarize_body(body: &str) -> String { let summary = body .lines() .map(str::trim) .find(|line| !line.is_empty() && !line.starts_with('#')) .unwrap_or_default(); let (summary, truncated) = truncate_body(summary, SUMMARY_BODY_LIMIT); if truncated { format!("{summary}…") } else { summary } } pub(crate) fn truncate_body(body: &str, limit: usize) -> (String, bool) { if body.len() <= limit { return (body.to_string(), false); } let mut end = limit; while !body.is_char_boundary(end) { end -= 1; } (body[..end].to_string(), true) }