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
8fn 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 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 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 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 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
173const 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 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 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 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); 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 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}