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