Skip to main content

gonzalo_ticket_github/
source.rs

1//! A read-only [`TicketSource`] backed by the GitHub REST API.
2//!
3//! Phase 1 imports issues via `fetch_changed` / `get`; write-back is not yet
4//! implemented, so [`capabilities`](TicketSource::capabilities) reports all-false
5//! and the trait's `Unsupported` defaults apply. HTTP-level behavior is exercised
6//! by the conformance suite's recorded fixtures (#20); the pure mapping is unit-
7//! tested in [`crate::mapping`].
8
9use crate::mapping::{GhIssue, issue_to_ticket};
10use async_trait::async_trait;
11use gonzalo_domain::{StateCategory, Ticket};
12use gonzalo_ticket::{Capabilities, Cursor, Page, Result, SourceError, TicketSource};
13
14const API_ROOT: &str = "https://api.github.com";
15const ACCEPT: &str = "application/vnd.github+json";
16
17/// Imports issues from a single GitHub repository.
18pub struct GitHubSource {
19    client: reqwest::Client,
20    api_root: reqwest::Url,
21    owner: String,
22    repo: String,
23    owner_repo: String,
24    token: Option<String>,
25}
26
27impl GitHubSource {
28    /// Import from `owner/name` anonymously (subject to GitHub's low unauth
29    /// rate limit).
30    pub fn new(owner_repo: impl Into<String>) -> Result<Self> {
31        Self::build(owner_repo.into(), None, API_ROOT)
32    }
33
34    /// Import from `owner/name`, authenticating with a personal-access / app
35    /// token on every request.
36    pub fn with_token(owner_repo: impl Into<String>, token: impl Into<String>) -> Result<Self> {
37        Self::build(owner_repo.into(), Some(token.into()), API_ROOT)
38    }
39
40    /// Import against a custom API base — GitHub Enterprise (e.g.
41    /// `https://ghe.example.com/api/v3`) or a test server.
42    pub fn with_base(
43        api_root: &str,
44        owner_repo: impl Into<String>,
45        token: Option<String>,
46    ) -> Result<Self> {
47        Self::build(owner_repo.into(), token, api_root)
48    }
49
50    fn build(owner_repo: String, token: Option<String>, api_root: &str) -> Result<Self> {
51        let (owner, repo) = owner_repo.split_once('/').ok_or_else(|| {
52            SourceError::Backend(format!("expected owner/repo, got {owner_repo}"))
53        })?;
54        let client = reqwest::Client::builder()
55            .user_agent("gonzalo-ticket-github")
56            .build()
57            .map_err(be)?;
58        let api_root = reqwest::Url::parse(api_root).map_err(be)?;
59        Ok(Self {
60            client,
61            api_root,
62            owner: owner.to_string(),
63            repo: repo.to_string(),
64            owner_repo: owner_repo.clone(),
65            token,
66        })
67    }
68
69    /// Build an issues URL under the configured repo, e.g. `.../issues`,
70    /// `.../issues/15`, or `.../issues/15/comments`. Each `trailing` element is a
71    /// distinct, individually-encoded path segment.
72    fn issues_url(&self, trailing: &[&str]) -> Result<reqwest::Url> {
73        let mut url = self.api_root.clone();
74        {
75            let mut seg = url
76                .path_segments_mut()
77                .map_err(|_| SourceError::Backend("api root cannot be a base".into()))?;
78            seg.extend(["repos", &self.owner, &self.repo, "issues"]);
79            seg.extend(trailing);
80        }
81        Ok(url)
82    }
83
84    fn send(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
85        let rb = rb.header(reqwest::header::ACCEPT, ACCEPT);
86        match &self.token {
87            Some(t) => rb.bearer_auth(t),
88            None => rb,
89        }
90    }
91}
92
93#[async_trait]
94impl TicketSource for GitHubSource {
95    fn capabilities(&self) -> Capabilities {
96        Capabilities {
97            push: true,
98            comments: true,
99            ..Capabilities::default()
100        }
101    }
102
103    async fn fetch_changed(&self, cursor: &Cursor) -> Result<Page> {
104        // Follow GitHub's `Link`-header pagination. The first call (empty
105        // cursor) builds the issues URL; every later call GETs the `rel="next"`
106        // URL that the previous page carried forward in the cursor — page N+1,
107        // with `per_page`/`sort`/`direction` echoed by GitHub. When GitHub stops
108        // emitting a `rel="next"`, the cursor terminates and the ingest loop stops.
109        let url: reqwest::Url = match &cursor.0 {
110            Some(next) => reqwest::Url::parse(next).map_err(be)?,
111            None => {
112                let mut url = self.issues_url(&[])?;
113                {
114                    let mut q = url.query_pairs_mut();
115                    q.append_pair("state", "all");
116                    q.append_pair("per_page", "100");
117                    q.append_pair("sort", "updated");
118                    q.append_pair("direction", "asc");
119                }
120                url
121            }
122        };
123        let resp = self
124            .send(self.client.get(url))
125            .send()
126            .await
127            .map_err(be)?
128            .error_for_status()
129            .map_err(be)?;
130        // Read the next-page indicator before consuming the body.
131        let next = resp
132            .headers()
133            .get(reqwest::header::LINK)
134            .and_then(|v| v.to_str().ok())
135            .and_then(parse_next_link);
136        let issues: Vec<GhIssue> = resp.json().await.map_err(be)?;
137        let tickets = issues
138            .iter()
139            .filter(|i| !i.is_pull_request())
140            .map(|i| issue_to_ticket(i, &self.owner_repo))
141            .collect();
142        // TODO(#19): incremental `since`-based sync (advancing off each page's
143        // max `updated_at`) is still future work; this always starts from page 1.
144        Ok(Page {
145            tickets,
146            next: Cursor(next),
147        })
148    }
149
150    async fn get(&self, uid: &str) -> Result<Ticket> {
151        let url = self.issues_url(&[&issue_number(uid)?.to_string()])?;
152        let resp = self
153            .send(self.client.get(url))
154            .send()
155            .await
156            .map_err(be)?
157            .error_for_status()
158            .map_err(be)?;
159        let issue: GhIssue = resp.json().await.map_err(be)?;
160        Ok(issue_to_ticket(&issue, &self.owner_repo))
161    }
162
163    async fn set_state(&self, uid: &str, target: StateCategory) -> Result<()> {
164        // GitHub state is binary + reason: closed/completed, closed/not_planned,
165        // or open. Categories collapse onto those.
166        let (state, reason) = match target {
167            StateCategory::Done => ("closed", Some("completed")),
168            StateCategory::Canceled => ("closed", Some("not_planned")),
169            _ => ("open", None),
170        };
171        let mut body = serde_json::json!({ "state": state });
172        if let Some(r) = reason {
173            body["state_reason"] = serde_json::json!(r);
174        }
175        let url = self.issues_url(&[&issue_number(uid)?.to_string()])?;
176        self.send(self.client.patch(url).json(&body))
177            .send()
178            .await
179            .map_err(be)?
180            .error_for_status()
181            .map_err(be)?;
182        Ok(())
183    }
184
185    async fn comment(&self, uid: &str, body: &str) -> Result<()> {
186        let url = self.issues_url(&[&issue_number(uid)?.to_string(), "comments"])?;
187        self.send(
188            self.client
189                .post(url)
190                .json(&serde_json::json!({ "body": body })),
191        )
192        .send()
193        .await
194        .map_err(be)?
195        .error_for_status()
196        .map_err(be)?;
197        Ok(())
198    }
199}
200
201/// Parse the issue number from a `uid` (`owner/repo#N` or a bare `N`).
202fn issue_number(uid: &str) -> Result<u64> {
203    uid.rsplit_once('#')
204        .map(|(_, n)| n)
205        .unwrap_or(uid)
206        .parse::<u64>()
207        .map_err(|_| SourceError::Backend(format!("cannot parse issue number from uid {uid}")))
208}
209
210fn be<E: std::fmt::Display>(e: E) -> SourceError {
211    SourceError::Backend(e.to_string())
212}
213
214/// Parse a GitHub `Link` response header, returning the URL of the `rel="next"`
215/// relation if one is present.
216///
217/// GitHub paginates with a comma-separated list of `<url>; rel="name"` entries,
218/// e.g. `<...?page=2>; rel="next", <...?page=9>; rel="last"`. The last page
219/// omits `next` entirely, so `None` is the terminating signal: the ingest loop
220/// stops when the cursor holds no next URL.
221fn parse_next_link(header: &str) -> Option<String> {
222    for part in header.split(',') {
223        let mut segs = part.split(';').map(str::trim);
224        let Some(url) = segs
225            .next()
226            .and_then(|s| s.strip_prefix('<'))
227            .and_then(|s| s.strip_suffix('>'))
228        else {
229            continue;
230        };
231        for param in segs {
232            if let Some(rel) = param.strip_prefix("rel=")
233                && rel
234                    .trim_matches('"')
235                    .split_whitespace()
236                    .any(|r| r == "next")
237            {
238                return Some(url.to_string());
239            }
240        }
241    }
242    None
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn rejects_owner_repo_without_slash() {
251        assert!(GitHubSource::new("not-a-repo").is_err());
252    }
253
254    #[test]
255    fn builds_issue_urls_under_the_repo() {
256        let src = GitHubSource::new("caliban-ai/gonzalo").unwrap();
257        assert_eq!(
258            src.issues_url(&[]).unwrap().as_str(),
259            "https://api.github.com/repos/caliban-ai/gonzalo/issues"
260        );
261        assert_eq!(
262            src.issues_url(&["15", "comments"]).unwrap().as_str(),
263            "https://api.github.com/repos/caliban-ai/gonzalo/issues/15/comments"
264        );
265    }
266
267    #[test]
268    fn parse_next_link_finds_next_among_multiple_rels() {
269        let header = concat!(
270            "<https://api.github.com/repositories/1/issues?page=2>; rel=\"next\", ",
271            "<https://api.github.com/repositories/1/issues?page=9>; rel=\"last\""
272        );
273        assert_eq!(
274            parse_next_link(header),
275            Some("https://api.github.com/repositories/1/issues?page=2".to_string())
276        );
277    }
278
279    #[test]
280    fn parse_next_link_terminates_without_a_next_rel() {
281        // The last page emits only prev/first — no `next`, so pagination ends.
282        let header = concat!(
283            "<https://api.github.com/repositories/1/issues?page=8>; rel=\"prev\", ",
284            "<https://api.github.com/repositories/1/issues?page=1>; rel=\"first\""
285        );
286        assert_eq!(parse_next_link(header), None);
287    }
288}