crates/flotilla-core/src/providers/issue_query/github.rs
Line | Count | Source |
1 | | //! GitHub implementation of the IssueQueryService. |
2 | | |
3 | | use std::{path::Path, sync::Arc}; |
4 | | |
5 | | use async_trait::async_trait; |
6 | | use flotilla_protocol::provider_data::Issue; |
7 | | |
8 | | use super::{IssueQuery, IssueQueryService, IssueResultPage}; |
9 | | use crate::providers::{ |
10 | | gh_api_get, gh_api_get_with_headers, |
11 | | github_api::{clamp_per_page, GhApi}, |
12 | | issue_tracker::github::parse_issue, |
13 | | run, CommandRunner, |
14 | | }; |
15 | | |
16 | | /// Provider name used for association keys on parsed issues. |
17 | | const PROVIDER_NAME: &str = "github"; |
18 | | |
19 | | pub struct GitHubIssueQueryService { |
20 | | repo_slug: String, |
21 | | api: Arc<dyn GhApi>, |
22 | | runner: Arc<dyn CommandRunner>, |
23 | | } |
24 | | |
25 | | impl GitHubIssueQueryService { |
26 | 7 | pub fn new(repo_slug: String, api: Arc<dyn GhApi>, runner: Arc<dyn CommandRunner>) -> Self { |
27 | 7 | Self { repo_slug, api, runner } |
28 | 7 | } |
29 | | } |
30 | | |
31 | | #[async_trait] |
32 | | impl IssueQueryService for GitHubIssueQueryService { |
33 | 5 | async fn query(&self, repo: &Path, params: &IssueQuery, page: u32, count: usize) -> Result<IssueResultPage, String> { |
34 | | let per_page = clamp_per_page(count); |
35 | | let (items, has_more, total) = match ¶ms.search { |
36 | | None => { |
37 | | let endpoint = format!("repos/{}/issues?state=open&per_page={}&page={}", self.repo_slug, per_page, page); |
38 | | let response = gh_api_get_with_headers!(self.api, &endpoint, repo)?; |
39 | 0 | let raw_items: Vec<serde_json::Value> = serde_json::from_str(&response.body).map_err(|e| e.to_string())?; |
40 | | let issues: Vec<(String, Issue)> = raw_items |
41 | | .into_iter() |
42 | 9 | .filter(|v| !v.as_object().map(|o| o.contains_key("pull_request")).unwrap_or(false)) |
43 | 8 | .filter_map(|v| parse_issue(PROVIDER_NAME, &v)) |
44 | | .collect(); |
45 | | (issues, response.has_next_page, None) |
46 | | } |
47 | | Some(search_term) => { |
48 | | let raw_query = format!("repo:{} is:issue is:open {}", self.repo_slug, search_term); |
49 | | let encoded_query = urlencoding::encode(&raw_query); |
50 | | let endpoint = format!("search/issues?q={}&per_page={}&page={}", encoded_query, per_page, page); |
51 | | let response = gh_api_get_with_headers!(self.api, &endpoint, repo)?; |
52 | 0 | let parsed: serde_json::Value = serde_json::from_str(&response.body).map_err(|e| e.to_string())?; |
53 | 1 | let total_count = parsed["total_count"].as_u64().map(|n| n as u32); |
54 | | let items_array = parsed["items"].as_array().ok_or("no items array in search response")?; |
55 | 2 | let issues: Vec<(String, Issue)> = items_array.iter().filter_map(|v| parse_issue(PROVIDER_NAME, v)).collect(); |
56 | | (issues, response.has_next_page, total_count) |
57 | | } |
58 | | }; |
59 | | Ok(IssueResultPage { items, total, has_more }) |
60 | 5 | } |
61 | | |
62 | 1 | async fn fetch_by_ids(&self, repo: &Path, ids: &[String]) -> Result<Vec<(String, Issue)>, String> { |
63 | | use futures::stream::{ |
64 | | StreamExt, {self}, |
65 | | }; |
66 | | |
67 | | let repo_root = repo.to_path_buf(); |
68 | | let futs: Vec<_> = ids |
69 | | .iter() |
70 | 2 | .map(|id| { |
71 | 2 | let endpoint = format!("repos/{}/issues/{}", self.repo_slug, id); |
72 | 2 | let api = Arc::clone(&self.api); |
73 | 2 | let id = id.clone(); |
74 | 2 | let repo_root = repo_root.clone(); |
75 | 2 | async move { |
76 | 2 | let body = gh_api_get!(api, &endpoint, &repo_root)?0 ; |
77 | 2 | let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e0 .to_string0 ())?0 ; |
78 | 2 | parse_issue(PROVIDER_NAME, &v).ok_or_else(|| format!0 ("failed to parse issue {}", id)) |
79 | 2 | } |
80 | 2 | }) |
81 | | .collect(); |
82 | | |
83 | | let results: Vec<_> = stream::iter(futs).buffer_unordered(10).collect().await; |
84 | | let mut issues = Vec::new(); |
85 | | for result in results { |
86 | | match result { |
87 | | Ok(issue) => issues.push(issue), |
88 | | Err(e) => tracing::warn!(provider = "github", err = %e, "failed to fetch issue by id"), |
89 | | } |
90 | | } |
91 | | Ok(issues) |
92 | 1 | } |
93 | | |
94 | 1 | async fn open_in_browser(&self, repo: &Path, id: &str) -> Result<(), String> { |
95 | | run!(self.runner, "gh", &["issue", "view", id, "--web"], repo)?; |
96 | | Ok(()) |
97 | 1 | } |
98 | | } |
99 | | |
100 | | #[cfg(test)] |
101 | | mod tests { |
102 | | use std::{collections::VecDeque, sync::Mutex as StdMutex}; |
103 | | |
104 | | use super::*; |
105 | | use crate::providers::{ |
106 | | github_api::{GhApi, GhApiResponse}, |
107 | | testing::MockRunner, |
108 | | ChannelLabel, |
109 | | }; |
110 | | |
111 | | struct MockGhApi { |
112 | | responses: StdMutex<VecDeque<Result<GhApiResponse, String>>>, |
113 | | } |
114 | | |
115 | | impl MockGhApi { |
116 | 6 | fn new(responses: Vec<Result<GhApiResponse, String>>) -> Self { |
117 | 6 | Self { responses: StdMutex::new(responses.into()) } |
118 | 6 | } |
119 | | } |
120 | | |
121 | | #[async_trait] |
122 | | impl GhApi for MockGhApi { |
123 | 2 | async fn get(&self, endpoint: &str, repo_root: &Path, label: &ChannelLabel) -> Result<String, String> { |
124 | | self.get_with_headers(endpoint, repo_root, label).await.map(|r| r.body) |
125 | 2 | } |
126 | 7 | async fn get_with_headers(&self, _endpoint: &str, _repo_root: &Path, _label: &ChannelLabel) -> Result<GhApiResponse, String> { |
127 | | self.responses.lock().unwrap().pop_front().expect("MockGhApi: no more responses") |
128 | 7 | } |
129 | | } |
130 | | |
131 | 3 | fn make_issues_json(count: usize) -> String { |
132 | 6 | let issues3 : Vec<String>3 = (1..=count3 ).map3 (|n| format!(r#"{{"number": {}, "title": "Issue {}", "labels": []}}"#, n, n)).collect3 (); |
133 | 3 | format!("[{}]", issues.join(",")) |
134 | 3 | } |
135 | | |
136 | 1 | fn make_search_json(count: usize, total: usize) -> String { |
137 | 1 | let issues: Vec<String> = |
138 | 2 | (1..=count1 ).map1 (|n| format!(r#"{{"number": {}, "title": "Search result {}", "labels": []}}"#, n, n)).collect1 (); |
139 | 1 | format!(r#"{{"total_count": {}, "items": [{}]}}"#, total, issues.join(",")) |
140 | 1 | } |
141 | | |
142 | 4 | fn mock_service(responses: Vec<Result<GhApiResponse, String>>) -> GitHubIssueQueryService { |
143 | 4 | let api = Arc::new(MockGhApi::new(responses)); |
144 | 4 | let runner = Arc::new(MockRunner::new(vec![])); |
145 | 4 | GitHubIssueQueryService::new("owner/repo".into(), api, runner) |
146 | 4 | } |
147 | | |
148 | 5 | fn ok_response(body: &str, has_next_page: bool) -> Result<GhApiResponse, String> { |
149 | 5 | Ok(GhApiResponse { status: 200, etag: None, body: body.to_string(), has_next_page, total_count: None }) |
150 | 5 | } |
151 | | |
152 | | #[tokio::test] |
153 | 1 | async fn query_returns_issues_from_list_endpoint() { |
154 | 1 | let body = make_issues_json(3); |
155 | 1 | let service = mock_service(vec![ok_response(&body, false)]); |
156 | 1 | let params = IssueQuery::default(); |
157 | 1 | let page = service.query(Path::new("/repo"), ¶ms, 1, 10).await.unwrap(); |
158 | 1 | assert_eq!(page.items.len(), 3); |
159 | 1 | assert!(!page.has_more); |
160 | 1 | assert_eq!(page.items[0].0, "1"); |
161 | 1 | assert_eq!(page.items[0].1.title, "Issue 1"); |
162 | 1 | } |
163 | | |
164 | | #[tokio::test] |
165 | 1 | async fn query_with_search_uses_search_endpoint() { |
166 | 1 | let body = make_search_json(2, 5); |
167 | 1 | let service = mock_service(vec![ok_response(&body, true)]); |
168 | 1 | let params = IssueQuery { search: Some("bug".into()) }; |
169 | 1 | let page = service.query(Path::new("/repo"), ¶ms, 1, 10).await.unwrap(); |
170 | 1 | assert_eq!(page.items.len(), 2); |
171 | 1 | assert!(page.has_more); |
172 | 1 | assert_eq!(page.total, Some(5)); |
173 | 1 | } |
174 | | |
175 | | #[tokio::test] |
176 | 1 | async fn query_filters_pull_requests() { |
177 | 1 | let body = r#"[ |
178 | 1 | {"number": 1, "title": "Real issue", "labels": []}, |
179 | 1 | {"number": 2, "title": "A PR", "labels": [], "pull_request": {"url": "..."}}, |
180 | 1 | {"number": 3, "title": "Another issue", "labels": []} |
181 | 1 | ]"#; |
182 | 1 | let service = mock_service(vec![ok_response(body, false)]); |
183 | 1 | let params = IssueQuery::default(); |
184 | 1 | let page = service.query(Path::new("/repo"), ¶ms, 1, 10).await.unwrap(); |
185 | 1 | assert_eq!(page.items.len(), 2); |
186 | 1 | assert_eq!(page.items[0].0, "1"); |
187 | 1 | assert_eq!(page.items[1].0, "3"); |
188 | 1 | } |
189 | | |
190 | | #[tokio::test] |
191 | 1 | async fn query_pagination_uses_page_param() { |
192 | 1 | let body1 = make_issues_json(2); |
193 | 1 | let body2 = make_issues_json(1); |
194 | 1 | let service = mock_service(vec![ok_response(&body1, true), ok_response(&body2, false)]); |
195 | 1 | let params = IssueQuery::default(); |
196 | | |
197 | 1 | let page1 = service.query(Path::new("/repo"), ¶ms, 1, 2).await.unwrap(); |
198 | 1 | assert_eq!(page1.items.len(), 2); |
199 | 1 | assert!(page1.has_more); |
200 | | |
201 | 1 | let page2 = service.query(Path::new("/repo"), ¶ms, 2, 2).await.unwrap(); |
202 | 1 | assert_eq!(page2.items.len(), 1); |
203 | 1 | assert!(!page2.has_more); |
204 | 1 | } |
205 | | |
206 | | #[tokio::test] |
207 | 1 | async fn fetch_by_ids_returns_matching_issues() { |
208 | 1 | let body1 = r#"{"number": 42, "title": "The answer", "labels": [{"name": "bug"}]}"#; |
209 | 1 | let body2 = r#"{"number": 99, "title": "Another one", "labels": []}"#; |
210 | 1 | let api = Arc::new(MockGhApi::new(vec![ |
211 | 1 | Ok(GhApiResponse { status: 200, etag: None, body: body1.into(), has_next_page: false, total_count: None }), |
212 | 1 | Ok(GhApiResponse { status: 200, etag: None, body: body2.into(), has_next_page: false, total_count: None }), |
213 | | ])); |
214 | 1 | let runner = Arc::new(MockRunner::new(vec![])); |
215 | 1 | let svc = GitHubIssueQueryService::new("owner/repo".into(), api, runner); |
216 | | |
217 | 1 | let issues = svc.fetch_by_ids(Path::new("/repo"), &["42".into(), "99".into()]).await.unwrap(); |
218 | 1 | assert_eq!(issues.len(), 2); |
219 | | // buffer_unordered may reorder, so check by collecting ids |
220 | 2 | let ids1 : Vec<&str>1 = issues.iter()1 .map1 (|(id, _)| id.as_str()).collect1 (); |
221 | 1 | assert!(ids.contains(&"42")); |
222 | 1 | assert!(ids.contains(&"99")); |
223 | 1 | } |
224 | | |
225 | | #[tokio::test] |
226 | 1 | async fn open_in_browser_calls_gh_cli() { |
227 | 1 | let runner = Arc::new(MockRunner::new(vec![Ok(String::new())])); |
228 | 1 | let api = Arc::new(MockGhApi::new(vec![])); |
229 | 1 | let svc = GitHubIssueQueryService::new("owner/repo".into(), api, runner.clone()); |
230 | | |
231 | 1 | svc.open_in_browser(Path::new("/repo"), "42").await.unwrap(); |
232 | | |
233 | 1 | let calls = runner.calls(); |
234 | 1 | assert_eq!(calls.len(), 1); |
235 | 1 | assert_eq!(calls[0].0, "gh"); |
236 | 1 | assert_eq!(calls[0].1, vec!["issue", "view", "42", "--web"]); |
237 | 1 | } |
238 | | } |