1pub mod conformance;
14pub mod ingest;
15pub mod mapping;
16pub mod mock;
17pub mod source;
18
19pub use ingest::{IngestError, IngestSummary, ingest};
20pub use mapping::{FieldMapping, ReverseError, StateMapping, StateSignal};
21pub use mock::InMemorySource;
22pub use source::{Capabilities, Cursor, Page, Result, SourceError, TicketSource};
23
24use gonzalo_core::RecordKey;
25use gonzalo_domain::{Provider, Ticket};
26
27pub fn record_key(ticket: &Ticket, scope: Option<&str>) -> RecordKey {
42 RecordKey::new(
43 "tickets",
44 provider_slug(&ticket.provider),
45 scoped_uid(&ticket.uid, scope),
46 )
47}
48
49pub fn scoped_uid(uid: &str, scope: Option<&str>) -> String {
58 match scope {
59 Some(board) => format!("{board}/{uid}"),
60 None => uid.to_string(),
61 }
62}
63
64fn provider_slug(provider: &Provider) -> String {
65 match provider {
66 Provider::GitHub => "github".into(),
67 Provider::Jira => "jira".into(),
68 Provider::Linear => "linear".into(),
69 Provider::GitLab => "gitlab".into(),
70 Provider::Asana => "asana".into(),
71 Provider::AzureDevOps => "azure-devops".into(),
72 Provider::Bugzilla => "bugzilla".into(),
73 Provider::Other(name) => name.to_lowercase(),
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80 use gonzalo_domain::{BodyFormat, State, StateCategory, TicketBody};
81 use std::collections::BTreeMap;
82
83 fn ticket(uid: &str) -> Ticket {
84 Ticket {
85 provider: Provider::GitHub,
86 uid: uid.into(),
87 display: "#1".into(),
88 item_type: "issue".into(),
89 title: "t".into(),
90 state: State {
91 category: StateCategory::Open,
92 resolution: None,
93 raw_name: "open".into(),
94 raw_id: None,
95 },
96 priority: None,
97 actors: vec![],
98 labels: vec![],
99 containers: vec![],
100 links: vec![],
101 body: TicketBody {
102 markdown: String::new(),
103 format: BodyFormat::Markdown,
104 raw: None,
105 },
106 fields: BTreeMap::new(),
107 }
108 }
109
110 #[test]
111 fn record_key_follows_tickets_provider_uid_convention() {
112 let k = record_key(&ticket("caliban-ai/gonzalo#15"), None);
113 assert_eq!(k.namespace, "tickets");
114 assert_eq!(k.collection, "github");
115 assert_eq!(k.id, "caliban-ai/gonzalo#15");
116 }
117
118 #[test]
119 fn scoped_record_key_prefixes_id_with_the_board() {
120 let k = record_key(&ticket("caliban-ai/gonzalo#15"), Some("board-a"));
121 assert_eq!(k.namespace, "tickets");
123 assert_eq!(k.collection, "github");
124 assert_eq!(k.id, "board-a/caliban-ai/gonzalo#15");
125 }
126
127 #[test]
128 fn same_issue_on_two_boards_yields_distinct_keys() {
129 let uid = "caliban-ai/gonzalo#15";
131 let a = record_key(&ticket(uid), Some("board-a"));
132 let b = record_key(&ticket(uid), Some("board-b"));
133 assert_ne!(a, b);
134 assert_ne!(a.id, b.id);
135 }
136
137 #[test]
138 fn plain_source_key_is_unchanged_by_scope_option() {
139 let uid = "caliban-ai/gonzalo#15";
141 assert_eq!(record_key(&ticket(uid), None).id, uid);
142 assert_eq!(scoped_uid(uid, None), uid);
143 assert_eq!(scoped_uid(uid, Some("b")), "b/caliban-ai/gonzalo#15");
144 }
145
146 #[tokio::test]
147 async fn in_memory_source_fetches_and_gets() {
148 let src = InMemorySource::new(vec![ticket("a"), ticket("b")]);
149 let page = src.fetch_changed(&Cursor::default()).await.unwrap();
150 assert_eq!(page.tickets.len(), 2);
151 assert_eq!(src.get("b").await.unwrap().uid, "b");
152 assert!(src.get("missing").await.is_err());
153 }
154
155 #[tokio::test]
156 async fn read_only_source_rejects_writes() {
157 let src = InMemorySource::new(vec![ticket("a")]);
158 assert!(!src.capabilities().push);
159 let err = src.set_state("a", StateCategory::Done).await.unwrap_err();
160 assert!(matches!(err, SourceError::Unsupported("set_state")));
161 }
162}