1use super::{API, GitHubClient, urlencoding};
4use crate::types::{OrgRepoItem, SearchReposResponse, SearchUsersResponse};
5use rootle_provider::{ErrorKind, ProviderError, ProviderResult, SearchItem};
6
7impl GitHubClient {
8 pub fn search(&self, query: &str) -> ProviderResult<Vec<SearchItem>> {
11 let q = urlencoding(query);
12 let mut out = Vec::new();
13
14 let orgs: SearchUsersResponse =
15 self.get(&format!("{API}/search/users?q={q}+type:org&per_page=5"))?;
16 out.extend(orgs.items.into_iter().map(|u| SearchItem::Org(u.login)));
17
18 let repos: SearchReposResponse =
19 self.get(&format!("{API}/search/repositories?q={q}&per_page=20"))?;
20 out.extend(
21 repos
22 .items
23 .into_iter()
24 .map(|r| SearchItem::Repo(r.full_name)),
25 );
26
27 Ok(out)
28 }
29
30 pub fn org_repos(&self, owner: &str) -> ProviderResult<Vec<rootle_provider::RepoInfo>> {
32 self.owner_repos_at(API, owner)
33 }
34
35 fn owner_repos_at(
36 &self,
37 api: &str,
38 owner: &str,
39 ) -> ProviderResult<Vec<rootle_provider::RepoInfo>> {
40 #[derive(serde::Deserialize)]
41 struct Account {
42 #[serde(rename = "type")]
43 kind: String,
44 }
45 let owner = urlencoding(owner);
46 let account: Account = self.get(&format!("{api}/users/{owner}"))?;
47 let collection = match account.kind.as_str() {
48 "Organization" => "orgs",
49 "User" | "Bot" => "users",
50 _ => {
51 return Err(ProviderError::new(
52 ErrorKind::Provider,
53 "unsupported GitHub account type",
54 ));
55 }
56 };
57 let repos: Vec<OrgRepoItem> = self.get(&format!(
58 "{api}/{collection}/{owner}/repos?per_page=100&sort=updated"
59 ))?;
60 Ok(repos
61 .into_iter()
62 .map(|r| rootle_provider::RepoInfo {
63 name: r.name,
64 description: r.description,
65 private: r.private,
66 archived: r.archived,
67 pushed_at: r.pushed_at,
68 })
69 .collect())
70 }
71
72 pub fn search_code(&self, q: &str) -> ProviderResult<(Vec<crate::types::CodeItem>, bool)> {
77 let (items, total) = self.search_code_page(q, 1)?;
78 Ok((items, total > 1000))
81 }
82
83 pub fn search_code_page(
87 &self,
88 q: &str,
89 page: u32,
90 ) -> ProviderResult<(Vec<crate::types::CodeItem>, u64)> {
91 if self.is_anonymous() {
92 return Err(ProviderError::new(
93 ErrorKind::Auth,
94 "code search needs a token — set ROOTLE_TOKEN or log in with `gh`",
95 ));
96 }
97 let url = format!(
98 "{API}/search/code?q={}&per_page=100&page={page}",
99 urlencoding(q)
100 );
101 let resp: crate::types::SearchCodeResponse =
102 self.get_accept(&url, "application/vnd.github.text-match+json")?;
103 Ok((resp.items, resp.total_count))
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use std::io::{BufRead, BufReader, Write};
111 use std::net::TcpListener;
112
113 fn server(
114 responses: Vec<(&'static str, u16, &'static str)>,
115 ) -> (String, std::thread::JoinHandle<()>) {
116 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
117 let address = format!("http://{}", listener.local_addr().unwrap());
118 let worker = std::thread::spawn(move || {
119 for (path, status, body) in responses {
120 let (mut stream, _) = listener.accept().unwrap();
121 stream
122 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
123 .unwrap();
124 let mut reader = BufReader::new(stream.try_clone().unwrap());
125 let mut request = String::new();
126 reader.read_line(&mut request).unwrap();
127 assert_eq!(request.split_whitespace().nth(1), Some(path));
128 loop {
129 let mut line = String::new();
130 reader.read_line(&mut line).unwrap();
131 if line == "\r\n" || line.is_empty() {
132 break;
133 }
134 }
135 write!(stream, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
136 }
137 });
138 (address, worker)
139 }
140
141 #[test]
142 fn explicit_owner_listing_uses_account_type_and_preserves_metadata() {
143 for (kind, path) in [
144 (
145 "{\"type\":\"User\"}",
146 "/users/owner/repos?per_page=100&sort=updated",
147 ),
148 (
149 "{\"type\":\"Organization\"}",
150 "/orgs/owner/repos?per_page=100&sort=updated",
151 ),
152 ] {
153 let (api, worker) = server(vec![
154 ("/users/owner", 200, kind),
155 (
156 path,
157 200,
158 "[{\"name\":\"project\",\"private\":true,\"archived\":true,\"description\":\"kept\"}]",
159 ),
160 ]);
161 let repos = GitHubClient::anonymous()
162 .owner_repos_at(&api, "owner")
163 .unwrap();
164 worker.join().unwrap();
165 assert_eq!(
166 repos,
167 vec![rootle_provider::RepoInfo {
168 name: "project".into(),
169 private: true,
170 archived: true,
171 description: Some("kept".into()),
172 pushed_at: None,
173 }]
174 );
175 }
176 }
177
178 #[test]
179 fn missing_owner_is_not_retried_as_another_account_type() {
180 let (api, worker) = server(vec![("/users/owner", 404, "{}")]);
181 let error = GitHubClient::anonymous()
182 .owner_repos_at(&api, "owner")
183 .unwrap_err();
184 worker.join().unwrap();
185 assert_eq!(error.kind, ErrorKind::NotFound);
186 }
187
188 #[test]
189 fn listing_auth_error_remains_an_auth_error() {
190 let (api, worker) = server(vec![
191 ("/users/owner", 200, "{\"type\":\"User\"}"),
192 ("/users/owner/repos?per_page=100&sort=updated", 401, "{}"),
193 ]);
194 let error = GitHubClient::anonymous()
195 .owner_repos_at(&api, "owner")
196 .unwrap_err();
197 worker.join().unwrap();
198 assert_eq!(error.kind, ErrorKind::Auth);
199 }
200}