1use crate::cache::FileContent;
2use crate::{Directory, DirectoryMut};
3use reqwest::header::HeaderMap;
4use reqwest::{Client, Proxy, StatusCode};
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10#[cfg(feature = "utoipa")]
11use utoipa::ToSchema;
12
13#[derive(Debug, Clone)]
14pub struct GithubClient {
15 client: Client,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Repository {
21 pub owner: Arc<str>,
23 pub repo: Arc<str>,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
29pub struct Branch {
30 pub branch: Option<String>,
32}
33
34impl Branch {
35 pub fn as_str(&self) -> Option<&str> {
38 self.branch.as_deref()
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct RepositoryPath {
45 #[serde(flatten)]
47 pub repo: Repository,
48 pub path: Arc<str>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct RepositoryIssue {
55 #[serde(flatten)]
57 pub repo: Repository,
58 pub number: u64,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct IssueQuery {
65 pub query: String,
67}
68
69impl AsRef<str> for IssueQuery {
70 fn as_ref(&self) -> &str {
71 self.query.as_str()
72 }
73}
74
75impl<O, R> From<(O, R)> for Repository
76where
77 O: AsRef<str>,
78 R: AsRef<str>,
79{
80 fn from((owner, repo): (O, R)) -> Self {
81 Self {
82 owner: Arc::from(owner.as_ref()),
83 repo: Arc::from(repo.as_ref()),
84 }
85 }
86}
87
88impl GithubClient {
89 pub fn new(token: &str, proxy: impl Into<Option<Proxy>>) -> anyhow::Result<Self> {
90 let authorization = format!("token {token}");
91 let mut headers = HeaderMap::new();
92 headers.insert(reqwest::header::AUTHORIZATION, authorization.parse()?);
93 headers.insert(reqwest::header::USER_AGENT, "Rust Assistant".parse()?);
94
95 let mut builder = reqwest::ClientBuilder::default().default_headers(headers);
96 if let Some(proxy) = proxy.into() {
97 builder = builder.proxy(proxy);
98 }
99
100 Ok(Self {
101 client: builder.build()?,
102 })
103 }
104
105 pub fn build_file_url(&self, repo: &Repository, path: &str) -> String {
106 format!(
107 "https://api.github.com/repos/{}/{}/contents/{path}",
108 repo.owner, repo.repo
109 )
110 }
111
112 pub async fn get_file(
113 &self,
114 repo: &Repository,
115 path: &str,
116 branch: impl Into<Option<&str>>,
117 ) -> anyhow::Result<Option<FileContent>> {
118 let file_path = self.build_file_url(repo, path);
119 let mut builder = self.client.get(file_path);
120 if let Some(branch) = branch.into() {
121 builder = builder.query(&[("ref", branch)]);
122 }
123 let resp = builder.send().await?;
124 let status = resp.status();
125 if status == StatusCode::NOT_FOUND {
126 return Ok(None);
127 }
128 if status != StatusCode::OK {
129 anyhow::bail!(
130 "The server returned a non-200 status code when fetching the file download URL ({status}): {}",
131 resp.text().await?
132 );
133 }
134
135 let body = resp.json::<serde_json::Value>().await?;
136 if body.is_array() || body.get("type") != Some(&json!("file")) {
137 anyhow::bail!("The path is not a regular file.");
138 }
139 let Some(download_url) = body.get("download_url").map(|u| u.as_str()).flatten() else {
140 anyhow::bail!("Failed to get download url from response body: {body}");
141 };
142
143 let resp = self.client.get(download_url).send().await?;
144 if !resp.status().is_success() {
145 anyhow::bail!(
146 "The server returned a non-200 status code when fetching file content ({status}): {}",
147 resp.text().await?
148 );
149 }
150 let bytes = resp.bytes().await?;
151 Ok(Some(crate::cache::FileContent::from(bytes)))
152 }
153
154 pub async fn read_dir(
155 &self,
156 repo: &Repository,
157 path: &str,
158 branch: impl Into<Option<&str>>,
159 ) -> anyhow::Result<Option<Directory>> {
160 let file_path = self.build_file_url(repo, path);
161 let mut builder = self.client.get(file_path);
162 if let Some(branch) = branch.into() {
163 builder = builder.query(&[("ref", branch)]);
164 }
165 let resp = builder.send().await?;
166 let status = resp.status();
167 if status == StatusCode::NOT_FOUND {
168 return Ok(None);
169 }
170 if status != StatusCode::OK {
171 anyhow::bail!(
172 "The server returned a non-200 status code when fetching the file download URL ({status}): {}",
173 resp.text().await?
174 );
175 }
176
177 let items = resp.json::<Vec<Item>>().await?;
178 let mut directories = DirectoryMut::default();
179 for item in items {
180 match item.r#type.as_str() {
181 "file" => {
182 directories.files.insert(PathBuf::from(item.name));
183 }
184 "dir" => {
185 directories.directories.insert(PathBuf::from(item.name));
186 }
187 _ => {
188 continue;
189 }
190 }
191 }
192 Ok(Some(directories.freeze()))
193 }
194
195 pub async fn search_for_issues(
206 &self,
207 Repository { owner, repo }: &Repository,
208 keyword: &str,
209 ) -> anyhow::Result<Vec<Issue>> {
210 let url = format!("https://api.github.com/search/issues?q={keyword}+repo:{owner}/{repo}",);
211 let resp = self.client.get(url).send().await?;
212 let status = resp.status();
213 if status != StatusCode::OK {
214 anyhow::bail!(
215 "The server returned a non-200 status code when fetching the file download URL ({status}): {}",
216 resp.text().await?
217 );
218 }
219
220 let body = resp.json::<SearchIssuesResponse>().await?;
221 Ok(body.items)
222 }
223
224 pub async fn get_issue_timeline(
226 &self,
227 Repository { owner, repo }: &Repository,
228 issue_number: u64,
229 ) -> anyhow::Result<Vec<IssueEvent>> {
230 let url = format!(
231 "https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/timeline",
232 owner = owner,
233 repo = repo,
234 issue_number = issue_number
235 );
236 let resp = self.client.get(url).send().await?;
237 let status = resp.status();
238 if status != StatusCode::OK {
239 anyhow::bail!(
240 "The server returned a non-200 status code when fetching the file download URL ({status}): {}",
241 resp.text().await?
242 );
243 }
244
245 let body = resp.json::<Vec<IssueEvent>>().await?;
246 Ok(body)
247 }
248
249 pub async fn get_repo_branches(
251 &self,
252 Repository { owner, repo }: &Repository,
253 ) -> anyhow::Result<Vec<String>> {
254 #[derive(Deserialize, Debug)]
255 struct Branch {
256 name: String,
257 }
258
259 let url = format!("https://api.github.com/repos/{owner}/{repo}/branches",);
260 let resp = self.client.get(url).send().await?;
261 let status = resp.status();
262 if status != StatusCode::OK {
263 anyhow::bail!(
264 "The server returned a non-200 status code when fetching the file download URL ({status}): {}",
265 resp.text().await?
266 );
267 }
268
269 let body = resp.json::<Vec<Branch>>().await?;
270 Ok(body.into_iter().map(|b| b.name).collect())
271 }
272}
273
274#[derive(Deserialize, Serialize, Debug)]
275struct Item {
276 r#type: String,
277 name: String,
278}
279
280#[derive(Serialize, Deserialize, Debug)]
282#[cfg_attr(feature = "utoipa", derive(ToSchema))]
283pub struct Issue {
284 pub number: u64,
285 pub title: String,
286 pub url: String,
287 pub state: String,
288 pub body: Option<String>,
289}
290
291#[derive(Deserialize, Debug)]
293pub struct SearchIssuesResponse {
294 pub items: Vec<Issue>,
295}
296
297#[derive(Serialize, Deserialize, Debug)]
302#[cfg_attr(feature = "utoipa", derive(ToSchema))]
303pub struct IssueEvent {
304 pub event: String,
306 pub actor: Option<Actor>,
308 pub author: Option<Author>,
310 pub created_at: Option<String>,
312 pub body: Option<String>,
314}
315
316#[derive(Serialize, Deserialize, Debug)]
319#[cfg_attr(feature = "utoipa", derive(ToSchema))]
320pub struct Actor {
321 pub login: String,
322 pub avatar_url: String,
323}
324
325#[derive(Serialize, Deserialize, Debug)]
327#[cfg_attr(feature = "utoipa", derive(ToSchema))]
328pub struct Author {
329 pub email: String,
331 pub name: String,
333}
334