github_rust/github/
rest.rs

1use crate::github::client::GitHubClient;
2use crate::github::graphql::Repository as GraphQLRepository;
3use crate::github::types::*;
4use crate::{config::*, error::*};
5use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
6use serde::Deserialize;
7
8/// URL-encode a path segment for safe use in GitHub API URLs.
9fn encode_path_segment(segment: &str) -> String {
10    utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string()
11}
12
13#[derive(Deserialize)]
14struct RestRepository {
15    id: u64,
16    name: String,
17    full_name: String,
18    description: Option<String>,
19    html_url: String,
20    homepage: Option<String>,
21    created_at: String,
22    updated_at: String,
23    pushed_at: Option<String>,
24    private: bool,
25    fork: bool,
26    archived: bool,
27    stargazers_count: u32,
28    forks_count: u32,
29    watchers_count: u32,
30    open_issues_count: u32,
31    language: Option<String>,
32    license: Option<RestLicense>,
33    default_branch: String,
34    topics: Vec<String>,
35}
36
37#[derive(Deserialize)]
38struct RestLicense {
39    name: String,
40    spdx_id: Option<String>,
41}
42
43#[derive(Deserialize)]
44struct LanguageStats {
45    #[serde(flatten)]
46    languages: std::collections::HashMap<String, u64>,
47}
48
49#[derive(Deserialize)]
50pub struct UserProfile {
51    pub login: String,
52    pub name: Option<String>,
53}
54
55pub async fn get_repository_info(
56    client: &GitHubClient,
57    owner: &str,
58    name: &str,
59) -> Result<GraphQLRepository> {
60    let encoded_owner = encode_path_segment(owner);
61    let encoded_name = encode_path_segment(name);
62    let repo_url = format!(
63        "{}/repos/{}/{}",
64        GITHUB_API_URL, encoded_owner, encoded_name
65    );
66
67    let response = client.client().get(&repo_url).send().await?;
68
69    let status = response.status();
70    if !status.is_success() {
71        let error_text = response.text().await.unwrap_or_default();
72        return match status.as_u16() {
73            401 => Err(GitHubError::AuthenticationError(
74                "Invalid or missing GitHub token".to_string(),
75            )),
76            403 => {
77                // Parse HTTP 403 more intelligently
78                let error_lower = error_text.to_lowercase();
79                if error_lower.contains("rate limit")
80                    || error_lower.contains("api rate limit exceeded")
81                {
82                    Err(GitHubError::RateLimitError(
83                        "REST API rate limit exceeded".to_string(),
84                    ))
85                } else if error_lower.contains("repository access blocked")
86                    || error_lower.contains("access blocked")
87                    || error_lower.contains("blocked")
88                {
89                    Err(GitHubError::AccessBlockedError(format!(
90                        "{}/{}",
91                        owner, name
92                    )))
93                } else {
94                    // Generic access denied (permissions, private repo, etc.)
95                    Err(GitHubError::AuthenticationError(format!(
96                        "Access denied to {}/{}: {}",
97                        owner, name, error_text
98                    )))
99                }
100            }
101            404 => Err(GitHubError::NotFoundError(format!("{}/{}", owner, name))),
102            451 => Err(GitHubError::DmcaBlockedError(format!("{}/{}", owner, name))),
103            _ => Err(GitHubError::ApiError {
104                status: status.as_u16(),
105                message: error_text,
106            }),
107        };
108    }
109
110    let rest_repo: RestRepository = response.json().await?;
111
112    let languages_url = format!(
113        "{}/repos/{}/{}/languages",
114        GITHUB_API_URL, encoded_owner, encoded_name
115    );
116    let lang_response = client.client().get(&languages_url).send().await?;
117    let language_stats: LanguageStats = if lang_response.status().is_success() {
118        lang_response.json().await?
119    } else {
120        LanguageStats {
121            languages: std::collections::HashMap::new(),
122        }
123    };
124
125    Ok(convert_rest_to_graphql(rest_repo, language_stats))
126}
127
128pub async fn get_user_profile(client: &GitHubClient) -> Result<UserProfile> {
129    let user_url = format!("{}/user", GITHUB_API_URL);
130
131    let response = client.client().get(&user_url).send().await?;
132
133    let status = response.status();
134    if !status.is_success() {
135        let error_text = response.text().await.unwrap_or_default();
136        return match status.as_u16() {
137            401 => Err(GitHubError::AuthenticationError(
138                "GitHub token is required to get user profile".to_string(),
139            )),
140            403 => {
141                // Parse HTTP 403 more intelligently
142                let error_lower = error_text.to_lowercase();
143                if error_lower.contains("rate limit")
144                    || error_lower.contains("api rate limit exceeded")
145                {
146                    Err(GitHubError::RateLimitError(
147                        "REST API rate limit exceeded".to_string(),
148                    ))
149                } else {
150                    // Generic access denied for user profile
151                    Err(GitHubError::AuthenticationError(format!(
152                        "Access denied for user profile: {}",
153                        error_text
154                    )))
155                }
156            }
157            _ => Err(GitHubError::ApiError {
158                status: status.as_u16(),
159                message: error_text,
160            }),
161        };
162    }
163
164    let user_profile: UserProfile = response.json().await?;
165    Ok(user_profile)
166}
167
168#[derive(Deserialize)]
169struct StarredRepository {
170    full_name: String,
171}
172
173/// Maximum number of pages to fetch for starred repositories.
174/// This limits total results to 10,000 repositories (100 pages * 100 per page).
175const MAX_STARRED_PAGES: u32 = 100;
176
177pub async fn get_user_starred_repositories(client: &GitHubClient) -> Result<Vec<String>> {
178    let mut all_starred = Vec::new();
179    let mut page = 1;
180    let per_page = 100;
181
182    loop {
183        // Safety limit to prevent infinite loops or excessive API calls
184        if page > MAX_STARRED_PAGES {
185            tracing::warn!(
186                "Reached maximum page limit ({}) for starred repositories. Returning {} repositories.",
187                MAX_STARRED_PAGES,
188                all_starred.len()
189            );
190            break;
191        }
192        let starred_url = format!(
193            "{}/user/starred?per_page={}&page={}",
194            GITHUB_API_URL, per_page, page
195        );
196
197        let response = client.client().get(&starred_url).send().await?;
198
199        let status = response.status();
200        if !status.is_success() {
201            let error_text = response.text().await.unwrap_or_default();
202            return match status.as_u16() {
203                401 => Err(GitHubError::AuthenticationError(
204                    "GitHub token is required to get starred repositories".to_string(),
205                )),
206                403 => {
207                    // Parse HTTP 403 more intelligently
208                    let error_lower = error_text.to_lowercase();
209                    if error_lower.contains("rate limit")
210                        || error_lower.contains("api rate limit exceeded")
211                    {
212                        Err(GitHubError::RateLimitError(
213                            "REST API rate limit exceeded".to_string(),
214                        ))
215                    } else {
216                        // Generic access denied for starred repositories
217                        Err(GitHubError::AuthenticationError(format!(
218                            "Access denied for starred repositories: {}",
219                            error_text
220                        )))
221                    }
222                }
223                _ => Err(GitHubError::ApiError {
224                    status: status.as_u16(),
225                    message: error_text,
226                }),
227            };
228        }
229
230        let starred_repos: Vec<StarredRepository> = response.json().await?;
231
232        if starred_repos.is_empty() {
233            break;
234        }
235
236        let repo_count = starred_repos.len();
237        all_starred.extend(starred_repos.into_iter().map(|repo| repo.full_name));
238
239        if repo_count < per_page {
240            break;
241        }
242
243        page += 1;
244    }
245
246    Ok(all_starred)
247}
248
249pub async fn get_repository_stargazers(
250    client: &GitHubClient,
251    owner: &str,
252    name: &str,
253    per_page: Option<u32>,
254    page: Option<u32>,
255) -> Result<Vec<StargazerWithDate>> {
256    let per_page = per_page.unwrap_or(30).min(100); // GitHub max is 100
257    let page = page.unwrap_or(1);
258
259    let encoded_owner = encode_path_segment(owner);
260    let encoded_name = encode_path_segment(name);
261    let stargazers_url = format!(
262        "{}/repos/{}/{}/stargazers?per_page={}&page={}",
263        GITHUB_API_URL, encoded_owner, encoded_name, per_page, page
264    );
265
266    let response = client
267        .client()
268        .get(&stargazers_url)
269        .header("Accept", "application/vnd.github.v3.star+json")
270        .send()
271        .await?;
272
273    let status = response.status();
274    if !status.is_success() {
275        let error_text = response.text().await.unwrap_or_default();
276        return match status.as_u16() {
277            401 => Err(GitHubError::AuthenticationError(
278                "Invalid or missing GitHub token".to_string(),
279            )),
280            403 => {
281                let error_lower = error_text.to_lowercase();
282                if error_lower.contains("rate limit")
283                    || error_lower.contains("api rate limit exceeded")
284                {
285                    Err(GitHubError::RateLimitError(
286                        "REST API rate limit exceeded".to_string(),
287                    ))
288                } else if error_lower.contains("repository access blocked")
289                    || error_lower.contains("access blocked")
290                    || error_lower.contains("blocked")
291                {
292                    Err(GitHubError::AccessBlockedError(format!(
293                        "{}/{}",
294                        owner, name
295                    )))
296                } else {
297                    Err(GitHubError::AuthenticationError(format!(
298                        "Access denied to {}/{}: {}",
299                        owner, name, error_text
300                    )))
301                }
302            }
303            404 => Err(GitHubError::NotFoundError(format!("{}/{}", owner, name))),
304            451 => Err(GitHubError::DmcaBlockedError(format!("{}/{}", owner, name))),
305            _ => Err(GitHubError::ApiError {
306                status: status.as_u16(),
307                message: error_text,
308            }),
309        };
310    }
311
312    let stargazers: Vec<StargazerWithDate> = response.json().await?;
313    Ok(stargazers)
314}
315
316fn convert_rest_to_graphql(rest: RestRepository, lang_stats: LanguageStats) -> GraphQLRepository {
317    let languages = LanguageConnection {
318        edges: lang_stats
319            .languages
320            .into_iter()
321            .map(|(name, size)| LanguageEdge {
322                size,
323                node: Language { name, color: None },
324            })
325            .collect(),
326    };
327
328    let repository_topics = TopicConnection {
329        edges: rest
330            .topics
331            .into_iter()
332            .map(|topic_name| TopicEdge {
333                node: TopicNode {
334                    topic: Topic { name: topic_name },
335                },
336            })
337            .collect(),
338    };
339
340    GraphQLRepository {
341        id: rest.id.to_string(),
342        name: rest.name,
343        name_with_owner: rest.full_name,
344        description: rest.description,
345        url: rest.html_url,
346        homepage_url: rest.homepage,
347        created_at: rest.created_at,
348        updated_at: rest.updated_at,
349        pushed_at: rest.pushed_at,
350        is_private: rest.private,
351        is_fork: rest.fork,
352        is_archived: rest.archived,
353        stargazer_count: rest.stargazers_count,
354        fork_count: rest.forks_count,
355        watchers: TotalCount {
356            total_count: rest.watchers_count,
357        },
358        issues: TotalCount {
359            total_count: rest.open_issues_count,
360        },
361        pull_requests: TotalCount { total_count: 0 },
362        releases: TotalCount { total_count: 0 },
363        primary_language: rest.language.map(|name| Language { name, color: None }),
364        languages,
365        license_info: rest.license.map(|l| License {
366            name: l.name,
367            spdx_id: l.spdx_id,
368        }),
369        default_branch_ref: Some(Branch {
370            name: rest.default_branch,
371        }),
372        repository_topics,
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use serde_json;
380
381    #[test]
382    fn test_stargazer_deserialization() {
383        let json_data = r#"[
384            {
385                "starred_at": "2015-09-11T10:42:05Z",
386                "user": {
387                    "login": "testuser",
388                    "id": 12345,
389                    "node_id": "MDQ6VXNlcjEyMzQ1",
390                    "avatar_url": "https://avatars.githubusercontent.com/u/12345?v=4",
391                    "gravatar_id": "",
392                    "url": "https://api.github.com/users/testuser",
393                    "html_url": "https://github.com/testuser",
394                    "followers_url": "https://api.github.com/users/testuser/followers",
395                    "following_url": "https://api.github.com/users/testuser/following{/other_user}",
396                    "gists_url": "https://api.github.com/users/testuser/gists{/gist_id}",
397                    "starred_url": "https://api.github.com/users/testuser/starred{/owner}{/repo}",
398                    "subscriptions_url": "https://api.github.com/users/testuser/subscriptions",
399                    "organizations_url": "https://api.github.com/users/testuser/orgs",
400                    "repos_url": "https://api.github.com/users/testuser/repos",
401                    "events_url": "https://api.github.com/users/testuser/events{/privacy}",
402                    "received_events_url": "https://api.github.com/users/testuser/received_events",
403                    "type": "User",
404                    "site_admin": false
405                }
406            }
407        ]"#;
408
409        let stargazers: Vec<StargazerWithDate> =
410            serde_json::from_str(json_data).expect("Failed to deserialize stargazers");
411        assert_eq!(stargazers.len(), 1);
412        assert_eq!(stargazers[0].starred_at, "2015-09-11T10:42:05Z");
413        assert_eq!(stargazers[0].user.login, "testuser");
414        assert_eq!(stargazers[0].user.id, 12345);
415        assert!(!stargazers[0].user.site_admin);
416    }
417
418    #[test]
419    fn test_pagination_parameters() {
420        // Test that pagination parameters are validated correctly
421        fn apply_limit(per_page: u32) -> u32 {
422            per_page.min(100)
423        }
424
425        assert_eq!(apply_limit(50), 50);
426        assert_eq!(apply_limit(150), 100);
427        assert_eq!(apply_limit(30), 30);
428    }
429
430    #[test]
431    fn test_stargazers_url_construction() {
432        let owner = "microsoft";
433        let name = "vscode";
434        let per_page = 30;
435        let page = 1;
436
437        let expected_url = format!(
438            "{}/repos/{}/{}/stargazers?per_page={}&page={}",
439            GITHUB_API_URL, owner, name, per_page, page
440        );
441
442        assert!(expected_url.contains("microsoft/vscode/stargazers"));
443        assert!(expected_url.contains("per_page=30"));
444        assert!(expected_url.contains("page=1"));
445    }
446}