rust_assistant/
github.rs

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/// A struct representing a GitHub repository.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Repository {
21    /// The owner of the repository.
22    pub owner: Arc<str>,
23    /// The name of the repository.
24    pub repo: Arc<str>,
25}
26
27/// A struct representing a GitHub branch.
28#[derive(Debug, Serialize, Deserialize)]
29pub struct Branch {
30    /// The name of the branch.
31    pub branch: Option<String>,
32}
33
34impl Branch {
35    /// Returns the branch name as a string slice.
36    ///
37    pub fn as_str(&self) -> Option<&str> {
38        self.branch.as_deref()
39    }
40}
41
42/// A struct representing a GitHub repository and a path within it.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct RepositoryPath {
45    /// The repository.
46    #[serde(flatten)]
47    pub repo: Repository,
48    /// The path.
49    pub path: Arc<str>,
50}
51
52/// A struct representing a GitHub repository and an issue number.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct RepositoryIssue {
55    /// The repository.
56    #[serde(flatten)]
57    pub repo: Repository,
58    /// The path.
59    pub number: u64,
60}
61
62/// The Query string for searching issues.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct IssueQuery {
65    /// The query string.
66    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    /// Search for issues.
196    ///
197    /// # Arguments
198    ///
199    /// * `query` - The query string to search for.
200    ///
201    /// # Returns
202    ///
203    /// A vector of issues matching the query.
204    ///
205    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    /// Get the timeline of an issue.
225    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    /// Get the branches of a repository.
250    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/// A struct representing a GitHub issue.
281#[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/// A struct representing a response from a GitHub issue search.
292#[derive(Deserialize, Debug)]
293pub struct SearchIssuesResponse {
294    pub items: Vec<Issue>,
295}
296
297/// A struct representing a GitHub issue event.
298/// https://docs.github.com/en/rest/reference/issues#list-issue-events
299/// https://docs.github.com/en/rest/reference/issues#events
300///
301#[derive(Serialize, Deserialize, Debug)]
302#[cfg_attr(feature = "utoipa", derive(ToSchema))]
303pub struct IssueEvent {
304    /// The event type.
305    pub event: String,
306    /// The actor of the event.
307    pub actor: Option<Actor>,
308    /// The author of the event.
309    pub author: Option<Author>,
310    /// The time the event was created.
311    pub created_at: Option<String>,
312    /// The body of the event.
313    pub body: Option<String>,
314}
315
316/// A struct representing a GitHub actor.
317///
318#[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/// A struct representing a GitHub author.
326#[derive(Serialize, Deserialize, Debug)]
327#[cfg_attr(feature = "utoipa", derive(ToSchema))]
328pub struct Author {
329    /// The author's email.
330    pub email: String,
331    /// The author's name.
332    pub name: String,
333}
334//
335// #[cfg(test)]
336// mod tests {
337//     use super::*;
338//
339//     #[tokio::test]
340//     async fn test_get_file() -> anyhow::Result<()> {
341//         let token = dotenv::var("GITHUB_ACCESS_TOKEN")?;
342//         let proxy = if tokio::net::TcpStream::connect("127.0.0.1:7890")
343//             .await
344//             .is_ok()
345//         {
346//             Some(Proxy::all("http://127.0.0.1:7890")?)
347//         } else {
348//             None
349//         };
350//         let repo = Repository::from(("gengteng", "rust-assistant"));
351//         // https://github.com/rust-lang/crates.io-index
352//         let client = GithubClient::new(token.as_str(), proxy)?;
353//         let content = client.get_file(&repo, "Cargo.toml", "fff").await?;
354//         println!("content: {content:?}");
355//
356//         let dir = client.read_dir(&repo, "crates", None).await?;
357//         println!("dir crates: {dir:#?}");
358//
359//         let issues = client.search_for_issues(&repo, "test").await?;
360//         println!("issues: {issues:#?}");
361//
362//         for issue in issues {
363//             let timeline = client.get_issue_timeline(&repo, issue.number).await?;
364//             println!("timeline: {timeline:#?}");
365//         }
366//
367//         let branches = client.get_repo_branches(&repo).await?;
368//         println!("branches: {branches:#?}");
369//         Ok(())
370//     }
371// }