gonzalo_ticket_github/
source.rs1use 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
19pub 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 pub fn new(owner_repo: impl Into<String>) -> Result<Self> {
33 Self::build(owner_repo.into(), None, API_ROOT)
34 }
35
36 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 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 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 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 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 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 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
203fn 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
216fn 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
247pub(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 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 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}