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)] #[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 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)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketListResponse { pub workspace_id: String, pub limit: usize, pub items: Vec, 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 title: String, pub state: String, pub priority: String, pub created_at: Option, pub updated_at: Option, 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 artifact_count: usize, pub artifacts: Vec, pub relations: TicketRelationView, 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 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, } #[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, 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, 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, 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, note: relation.note, author: relation.author, at: relation.at, }) .collect(), incoming: value .incoming .into_iter() .map(|relation| DerivedTicketRelation { source_ticket: relation.source_ticket, 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, 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)] pub struct ObjectiveSummary { pub id: String, pub title: String, pub state: String, 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 title: String, pub state: String, pub created_at: Option, pub updated_at: Option, pub linked_tickets: Vec, pub resources: Vec, pub body: String, pub body_truncated: bool, 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), TicketEventDetail::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) }