organizational_intelligence_plugin/
github.rs

1// GitHub API integration module
2// Toyota Way: Start simple, validate with real usage
3
4use anyhow::{anyhow, Result};
5use chrono::{DateTime, Utc};
6use octocrab::Octocrab;
7use serde::{Deserialize, Serialize};
8use tracing::{debug, info};
9
10/// Simplified repository information
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct RepoInfo {
13    pub name: String,
14    pub full_name: String,
15    pub description: Option<String>,
16    pub language: Option<String>,
17    pub stars: u32,
18    pub default_branch: String,
19    pub updated_at: DateTime<Utc>,
20}
21
22/// GitHub organization miner
23/// Phase 1: Basic organization and repository fetching
24pub struct GitHubMiner {
25    client: Octocrab,
26}
27
28impl GitHubMiner {
29    /// Create a new GitHub miner
30    ///
31    /// # Arguments
32    /// * `token` - Optional GitHub personal access token for authenticated requests
33    ///
34    /// # Examples
35    /// ```no_run
36    /// use organizational_intelligence_plugin::github::GitHubMiner;
37    ///
38    /// // Public repos only (unauthenticated)
39    /// let miner = GitHubMiner::new(None);
40    ///
41    /// // With authentication (higher rate limits)
42    /// let miner_auth = GitHubMiner::new(Some("ghp_token".to_string()));
43    /// ```
44    pub fn new(token: Option<String>) -> Self {
45        let client = if let Some(token) = token {
46            debug!("Initializing GitHub client with authentication");
47            Octocrab::builder()
48                .personal_token(token)
49                .build()
50                .expect("Failed to build Octocrab client")
51        } else {
52            debug!("Initializing GitHub client without authentication");
53            Octocrab::builder()
54                .build()
55                .expect("Failed to build Octocrab client")
56        };
57
58        Self { client }
59    }
60
61    /// Fetch all repositories for an organization
62    ///
63    /// # Arguments
64    /// * `org_name` - GitHub organization name
65    ///
66    /// # Errors
67    /// Returns error if:
68    /// - Organization name is empty
69    /// - API request fails
70    /// - Organization doesn't exist
71    ///
72    /// # Examples
73    /// ```no_run
74    /// use organizational_intelligence_plugin::github::GitHubMiner;
75    ///
76    /// # async fn example() -> Result<(), anyhow::Error> {
77    /// let miner = GitHubMiner::new(None);
78    /// let repos = miner.fetch_organization_repos("rust-lang").await?;
79    /// println!("Found {} repositories", repos.len());
80    /// # Ok(())
81    /// # }
82    /// ```
83    pub async fn fetch_organization_repos(&self, org_name: &str) -> Result<Vec<RepoInfo>> {
84        // Validation: Empty org name
85        if org_name.trim().is_empty() {
86            return Err(anyhow!("Organization name cannot be empty"));
87        }
88
89        info!("Fetching repositories for organization: {}", org_name);
90
91        // Fetch organization repositories
92        let repos = self
93            .client
94            .orgs(org_name)
95            .list_repos()
96            .send()
97            .await
98            .map_err(|e| anyhow!("Failed to fetch repositories for {}: {}", org_name, e))?;
99
100        debug!("Found {} repositories for {}", repos.items.len(), org_name);
101
102        // Convert to our simplified RepoInfo structure
103        let repo_infos: Vec<RepoInfo> = repos
104            .items
105            .into_iter()
106            .map(|repo| RepoInfo {
107                name: repo.name,
108                full_name: repo.full_name.unwrap_or_default(),
109                description: repo.description,
110                language: repo.language.and_then(|v| v.as_str().map(String::from)),
111                stars: repo.stargazers_count.unwrap_or(0),
112                default_branch: repo.default_branch.unwrap_or_else(|| "main".to_string()),
113                updated_at: repo.updated_at.unwrap_or_else(Utc::now),
114            })
115            .collect();
116
117        info!(
118            "Successfully fetched {} repositories for {}",
119            repo_infos.len(),
120            org_name
121        );
122
123        Ok(repo_infos)
124    }
125
126    /// Filter repositories by last update date
127    ///
128    /// # Arguments
129    /// * `repos` - List of repositories to filter
130    /// * `since` - Only include repos updated since this date
131    ///
132    /// # Returns
133    /// Filtered list of repositories
134    pub fn filter_by_date(repos: Vec<RepoInfo>, since: DateTime<Utc>) -> Vec<RepoInfo> {
135        repos
136            .into_iter()
137            .filter(|repo| repo.updated_at >= since)
138            .collect()
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[tokio::test]
147    async fn test_github_miner_creation() {
148        // Test that GitHubMiner can be created
149        let _miner = GitHubMiner::new(None);
150        let _miner_with_token = GitHubMiner::new(Some("test_token".to_string()));
151    }
152
153    #[tokio::test]
154    async fn test_empty_org_name_validation() {
155        let miner = GitHubMiner::new(None);
156        let result = miner.fetch_organization_repos("").await;
157
158        assert!(result.is_err());
159        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
160    }
161
162    #[tokio::test]
163    async fn test_whitespace_org_name_validation() {
164        let miner = GitHubMiner::new(None);
165        let result = miner.fetch_organization_repos("   ").await;
166
167        assert!(result.is_err());
168    }
169}