github_rust/github/
graphql.rs

1use crate::github::client::GitHubClient;
2use crate::github::types::*;
3use crate::{config::*, error::*};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Full repository information from GitHub API.
8///
9/// Contains comprehensive details about a repository including metadata,
10/// statistics, and related information.
11#[derive(Deserialize, Serialize)]
12pub struct Repository {
13    /// GitHub's internal ID for the repository
14    pub id: String,
15    /// Repository name (without owner)
16    pub name: String,
17    /// Full repository name in "owner/repo" format
18    #[serde(rename = "nameWithOwner")]
19    pub name_with_owner: String,
20    /// Repository description
21    pub description: Option<String>,
22    /// GitHub URL for the repository
23    pub url: String,
24    /// Custom homepage URL if set
25    #[serde(rename = "homepageUrl")]
26    pub homepage_url: Option<String>,
27    /// ISO 8601 timestamp when repository was created
28    #[serde(rename = "createdAt")]
29    pub created_at: String,
30    /// ISO 8601 timestamp of last update
31    #[serde(rename = "updatedAt")]
32    pub updated_at: String,
33    /// ISO 8601 timestamp of last push
34    #[serde(rename = "pushedAt")]
35    pub pushed_at: Option<String>,
36    /// Whether the repository is private
37    #[serde(rename = "isPrivate")]
38    pub is_private: bool,
39    /// Whether the repository is a fork
40    #[serde(rename = "isFork")]
41    pub is_fork: bool,
42    /// Whether the repository is archived
43    #[serde(rename = "isArchived")]
44    pub is_archived: bool,
45    /// Number of stars
46    #[serde(rename = "stargazerCount")]
47    pub stargazer_count: u32,
48    /// Number of forks
49    #[serde(rename = "forkCount")]
50    pub fork_count: u32,
51    /// Number of watchers
52    pub watchers: TotalCount,
53    /// Number of open issues
54    pub issues: TotalCount,
55    /// Number of pull requests
56    #[serde(rename = "pullRequests")]
57    pub pull_requests: TotalCount,
58    /// Number of releases
59    pub releases: TotalCount,
60    /// Primary programming language
61    #[serde(rename = "primaryLanguage")]
62    pub primary_language: Option<Language>,
63    /// All languages used in the repository
64    pub languages: LanguageConnection,
65    /// License information
66    #[serde(rename = "licenseInfo")]
67    pub license_info: Option<License>,
68    /// Default branch reference
69    #[serde(rename = "defaultBranchRef")]
70    pub default_branch_ref: Option<Branch>,
71    /// Repository topics/tags
72    #[serde(rename = "repositoryTopics")]
73    pub repository_topics: TopicConnection,
74}
75
76impl Repository {
77    /// Returns the primary language name, or None if not set.
78    ///
79    /// # Example
80    ///
81    /// ```no_run
82    /// # use github_rust::GitHubService;
83    /// # async fn example() -> github_rust::Result<()> {
84    /// let service = GitHubService::new()?;
85    /// let repo = service.get_repository_info("rust-lang", "rust").await?;
86    ///
87    /// if let Some(lang) = repo.language() {
88    ///     println!("Primary language: {}", lang);
89    /// }
90    /// # Ok(())
91    /// # }
92    /// ```
93    #[must_use]
94    pub fn language(&self) -> Option<&str> {
95        self.primary_language.as_ref().map(|l| l.name.as_str())
96    }
97
98    /// Returns the license name, or None if not set.
99    #[must_use]
100    pub fn license(&self) -> Option<&str> {
101        self.license_info.as_ref().map(|l| l.name.as_str())
102    }
103
104    /// Returns the SPDX license identifier, or None if not available.
105    #[must_use]
106    pub fn license_spdx(&self) -> Option<&str> {
107        self.license_info
108            .as_ref()
109            .and_then(|l| l.spdx_id.as_deref())
110    }
111
112    /// Returns the default branch name, or None if not set.
113    #[must_use]
114    pub fn default_branch(&self) -> Option<&str> {
115        self.default_branch_ref.as_ref().map(|b| b.name.as_str())
116    }
117
118    /// Returns a list of topic names.
119    ///
120    /// # Example
121    ///
122    /// ```no_run
123    /// # use github_rust::GitHubService;
124    /// # async fn example() -> github_rust::Result<()> {
125    /// let service = GitHubService::new()?;
126    /// let repo = service.get_repository_info("rust-lang", "rust").await?;
127    ///
128    /// for topic in repo.topics() {
129    ///     println!("Topic: {}", topic);
130    /// }
131    /// # Ok(())
132    /// # }
133    /// ```
134    #[must_use]
135    pub fn topics(&self) -> Vec<&str> {
136        self.repository_topics
137            .edges
138            .iter()
139            .map(|e| e.node.topic.name.as_str())
140            .collect()
141    }
142
143    /// Returns the owner part of name_with_owner.
144    #[must_use]
145    pub fn owner(&self) -> &str {
146        self.name_with_owner
147            .split('/')
148            .next()
149            .unwrap_or(&self.name_with_owner)
150    }
151
152    /// Returns the number of open issues.
153    #[must_use]
154    pub fn open_issues(&self) -> u32 {
155        self.issues.total_count
156    }
157
158    /// Returns the number of watchers.
159    #[must_use]
160    pub fn watcher_count(&self) -> u32 {
161        self.watchers.total_count
162    }
163}
164
165#[derive(Deserialize)]
166struct RepositoryResponse {
167    repository: Option<Repository>,
168}
169
170pub async fn get_repository_info(
171    client: &GitHubClient,
172    owner: &str,
173    name: &str,
174) -> Result<Repository> {
175    let mut variables = HashMap::new();
176    variables.insert("owner".to_string(), owner.to_string());
177    variables.insert("name".to_string(), name.to_string());
178
179    let query: GraphQLQuery<HashMap<String, String>> = GraphQLQuery {
180        query: GRAPHQL_REPOSITORY_QUERY.to_string(),
181        variables,
182    };
183
184    let response = client
185        .client()
186        .post(GITHUB_GRAPHQL_URL)
187        .json(&query)
188        .send()
189        .await?;
190
191    let status = response.status();
192    if !status.is_success() {
193        let error_text = response.text().await.unwrap_or_default();
194        return match status.as_u16() {
195            401 => Err(GitHubError::AuthenticationError(
196                "Invalid or missing GitHub token".to_string(),
197            )),
198            403 => {
199                // Parse HTTP 403 more intelligently
200                let error_lower = error_text.to_lowercase();
201                if error_lower.contains("rate limit")
202                    || error_lower.contains("api rate limit exceeded")
203                {
204                    Err(GitHubError::RateLimitError(
205                        "GraphQL API rate limit exceeded".to_string(),
206                    ))
207                } else if error_lower.contains("repository access blocked")
208                    || error_lower.contains("access blocked")
209                    || error_lower.contains("blocked")
210                {
211                    Err(GitHubError::AccessBlockedError(format!(
212                        "{}/{}",
213                        owner, name
214                    )))
215                } else {
216                    // Generic access denied (permissions, private repo, etc.)
217                    Err(GitHubError::AuthenticationError(format!(
218                        "Access denied to {}/{}: {}",
219                        owner, name, error_text
220                    )))
221                }
222            }
223            404 => Err(GitHubError::NotFoundError(format!("{}/{}", owner, name))),
224            451 => Err(GitHubError::DmcaBlockedError(format!("{}/{}", owner, name))),
225            _ => Err(GitHubError::ApiError {
226                status: status.as_u16(),
227                message: error_text,
228            }),
229        };
230    }
231
232    let graphql_response: GraphQLResponse<RepositoryResponse> = response.json().await?;
233
234    if let Some(errors) = graphql_response.errors {
235        let error_message = errors
236            .into_iter()
237            .map(|e| e.message)
238            .collect::<Vec<_>>()
239            .join(", ");
240        return Err(GitHubError::ApiError {
241            status: 200,
242            message: error_message,
243        });
244    }
245
246    match graphql_response.data {
247        Some(data) => match data.repository {
248            Some(repo) => Ok(repo),
249            None => Err(GitHubError::NotFoundError(format!("{}/{}", owner, name))),
250        },
251        None => Err(GitHubError::ParseError(
252            "No data in GraphQL response".to_string(),
253        )),
254    }
255}