Skip to main content

gonzalo_ticket/
lib.rs

1//! Ticket / work-item capability layer for gonzalo.
2//!
3//! A typed [`Ticket`](gonzalo_domain::Ticket) is persisted as a `Record`
4//! (defined in `gonzalo-domain`); this crate adds the *capability* surface over
5//! it (ADR 0010): the [`TicketSource`] provider boundary, a per-connection
6//! field/state [`mapping`] policy, capability negotiation, and a `RecordKey`
7//! convention so tickets compose with the vector and graph layers by shared key.
8//!
9//! Phase 1 is read-only import; write-back is capability-gated. Concrete
10//! provider connectors (GitHub, Jira, Linear, …) are separate, feature-gated
11//! crates built on this surface.
12
13pub 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
27/// The stable `RecordKey` for a ticket: `tickets / <provider> / <id>`, where
28/// `<id>` is [`scoped_uid`] — the ticket's `uid`, optionally prefixed with a
29/// board/connection discriminator.
30///
31/// `scope` distinguishes the same external item imported under two different
32/// connections (ADR 0010). A Projects v2 uid is only `owner/repo#N`, with no
33/// board component, so an issue sitting on two configured boards would otherwise
34/// produce IDENTICAL keys with differing Status categories — alternating syncs
35/// then thrash the one record and lose per-board status (#159). Passing the
36/// connection name as `scope` yields distinct keys per board. Board-agnostic
37/// callers (plain issue sources, single-board) pass `None` and are unchanged.
38///
39/// Keying off a deterministic `RecordKey` is what lets ticket queries join the
40/// vector and graph layers (ADR 0008/0010) — they all address the same record.
41pub 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
49/// Build the `id` component of a ticket [`RecordKey`]: the bare `uid`, or
50/// `"<scope>/<uid>"` when a board/connection discriminator is supplied.
51///
52/// The `id` is stored as a single opaque, reversibly-encoded path segment
53/// (`gonzalo_core::segment`), so an embedded `/` is escaped rather than nesting
54/// directories; two distinct scope+uid pairs therefore never collide. Kept as a
55/// standalone helper so downstream lookups (e.g. `ticket get`) can reconstruct
56/// the exact scoped id without a `Ticket` in hand.
57pub 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        // Namespace + provider slug are unchanged; the discriminator rides in id.
122        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        // The #159 collision: identical uid, two connections → two records.
130        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        // A board-agnostic caller passing None gets exactly the old key shape.
140        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}