Coverage Report

Created: 2026-04-05 07:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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 &params.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"), &params, 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"), &params, 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"), &params, 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"), &params, 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"), &params, 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
}