gonzalo_ticket_github/
source.rs1use 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
17pub 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 pub fn new(owner_repo: impl Into<String>) -> Result<Self> {
31 Self::build(owner_repo.into(), None, API_ROOT)
32 }
33
34 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 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 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 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 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 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 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
201fn 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
214fn 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 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}