ggen_core/
github.rs

1//! GitHub API client for Pages and workflow operations
2//!
3//! This module provides a client for interacting with the GitHub API, specifically
4//! for managing GitHub Pages configurations and GitHub Actions workflow runs.
5//!
6//! ## Features
7//!
8//! - **GitHub Pages**: Query and manage Pages configuration
9//! - **Workflow Runs**: List, query, and trigger GitHub Actions workflows
10//! - **Authentication**: Support for GitHub tokens via environment variables
11//! - **Error Handling**: Comprehensive error messages for API failures
12//!
13//! ## Authentication
14//!
15//! The client automatically detects GitHub tokens from environment variables:
16//! - `GITHUB_TOKEN` (primary)
17//! - `GH_TOKEN` (fallback)
18//!
19//! ## Examples
20//!
21//! ### Creating a Client
22//!
23//! ```rust,no_run
24//! use ggen_core::github::{GitHubClient, RepoInfo};
25//!
26//! # fn main() -> ggen_utils::error::Result<()> {
27//! let repo = RepoInfo::parse("owner/repo")?;
28//! let client = GitHubClient::new(repo)?;
29//!
30//! if client.is_authenticated() {
31//!     println!("Authenticated with GitHub");
32//! }
33//! # Ok(())
34//! # }
35//! ```
36//!
37//! ### Getting Pages Configuration
38//!
39//! ```rust,no_run
40//! use ggen_core::github::{GitHubClient, RepoInfo};
41//!
42//! # async fn example() -> ggen_utils::error::Result<()> {
43//! let repo = RepoInfo::parse("owner/repo")?;
44//! let client = GitHubClient::new(repo.clone())?;
45//!
46//! let pages_config = client.get_pages_config(&repo).await?;
47//! println!("Pages URL: {:?}", pages_config.url);
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! ### Listing Workflow Runs
53//!
54//! ```rust,no_run
55//! use ggen_core::github::{GitHubClient, RepoInfo};
56//!
57//! # async fn example() -> ggen_utils::error::Result<()> {
58//! let repo = RepoInfo::parse("owner/repo")?;
59//! let client = GitHubClient::new(repo.clone())?;
60//!
61//! let runs = client.get_workflow_runs(&repo, "deploy.yml", 10).await?;
62//! println!("Found {} workflow runs", runs.total_count);
63//! # Ok(())
64//! # }
65//! ```
66
67//! GitHub API client for Pages and workflow operations
68//!
69//! This module provides a client for interacting with the GitHub API, specifically
70//! for managing GitHub Pages configurations and GitHub Actions workflow runs.
71//! It supports authentication via GitHub tokens and provides convenient methods
72//! for common operations.
73//!
74//! ## Features
75//!
76//! - **GitHub Pages**: Get Pages configuration and check site status
77//! - **Workflow Management**: List workflow runs, get run details, trigger workflows
78//! - **Authentication**: Automatic token detection from environment variables
79//! - **Error Handling**: Comprehensive error handling with context
80//! - **Repository Parsing**: Parse "owner/repo" format into structured types
81//!
82//! ## Authentication
83//!
84//! The client automatically detects GitHub tokens from environment variables:
85//! - `GITHUB_TOKEN` (preferred)
86//! - `GH_TOKEN` (fallback)
87//!
88//! Operations that require authentication will fail with a clear error message
89//! if no token is available.
90//!
91//! ## Examples
92//!
93//! ### Getting Pages Configuration
94//!
95//! ```rust,no_run
96//! use ggen_core::github::{GitHubClient, RepoInfo};
97//!
98//! # async fn example() -> ggen_utils::error::Result<()> {
99//! let repo = RepoInfo::parse("seanchatmangpt/ggen")?;
100//! let client = GitHubClient::new(repo.clone())?;
101//!
102//! let pages_config = client.get_pages_config(&repo).await?;
103//! println!("Pages URL: {:?}", pages_config.url);
104//! # Ok(())
105//! # }
106//! ```
107//!
108//! ### Listing Workflow Runs
109//!
110//! ```rust,no_run
111//! use ggen_core::github::{GitHubClient, RepoInfo};
112//!
113//! # async fn example() -> ggen_utils::error::Result<()> {
114//! let repo = RepoInfo::parse("seanchatmangpt/ggen")?;
115//! let client = GitHubClient::new(repo.clone())?;
116//!
117//! let runs = client.get_workflow_runs(&repo, "ci.yml", 10).await?;
118//! for run in runs.workflow_runs {
119//!     println!("Run #{}: {} - {}", run.run_number, run.name, run.status);
120//! }
121//! # Ok(())
122//! # }
123//! ```
124//!
125//! ### Triggering a Workflow
126//!
127//! ```rust,no_run
128//! use ggen_core::github::{GitHubClient, RepoInfo};
129//!
130//! # async fn example() -> ggen_utils::error::Result<()> {
131//! let repo = RepoInfo::parse("seanchatmangpt/ggen")?;
132//! let client = GitHubClient::new(repo.clone())?;
133//!
134//! // Requires GITHUB_TOKEN or GH_TOKEN environment variable
135//! client.trigger_workflow(&repo, "deploy.yml", "main").await?;
136//! # Ok(())
137//! # }
138//! ```
139
140//! GitHub API client for Pages and Actions integration
141//!
142//! This module provides a client for interacting with the GitHub API to manage
143//! GitHub Pages deployments and GitHub Actions workflow runs. It supports
144//! authentication via environment variables and provides typed interfaces for
145//! common GitHub operations.
146//!
147//! ## Features
148//!
149//! - **GitHub Pages**: Query Pages configuration and deployment status
150//! - **Workflow Management**: List and trigger GitHub Actions workflows
151//! - **Authentication**: Support for GITHUB_TOKEN and GH_TOKEN environment variables
152//! - **Repository Info**: Parse and work with "owner/repo" format
153//! - **Site Status**: Check accessibility of GitHub Pages sites
154//!
155//! ## Authentication
156//!
157//! The client automatically detects GitHub tokens from environment variables:
158//! - `GITHUB_TOKEN` (primary)
159//! - `GH_TOKEN` (fallback)
160//!
161//! Some operations (like triggering workflows) require authentication.
162//!
163//! ## Examples
164//!
165//! ### Creating a Client
166//!
167//! ```rust,no_run
168//! use ggen_core::github::{GitHubClient, RepoInfo};
169//!
170//! # fn main() -> ggen_utils::error::Result<()> {
171//! let repo = RepoInfo::parse("seanchatmangpt/ggen")?;
172//! let client = GitHubClient::new(repo)?;
173//!
174//! if client.is_authenticated() {
175//!     println!("Authenticated with GitHub");
176//! }
177//! # Ok(())
178//! # }
179//! ```
180//!
181//! ### Querying GitHub Pages
182//!
183//! ```rust,no_run
184//! use ggen_core::github::{GitHubClient, RepoInfo};
185//!
186//! # async fn example() -> ggen_utils::error::Result<()> {
187//! let repo = RepoInfo::parse("seanchatmangpt/ggen")?;
188//! let client = GitHubClient::new(repo.clone())?;
189//!
190//! let pages_config = client.get_pages_config(&repo).await?;
191//! if let Some(url) = pages_config.url {
192//!     println!("Pages URL: {}", url);
193//! }
194//! # Ok(())
195//! # }
196//! ```
197//!
198//! ### Working with Workflows
199//!
200//! ```rust,no_run
201//! use ggen_core::github::{GitHubClient, RepoInfo};
202//!
203//! # async fn example() -> ggen_utils::error::Result<()> {
204//! let repo = RepoInfo::parse("seanchatmangpt/ggen")?;
205//! let client = GitHubClient::new(repo.clone())?;
206//!
207//! // List workflow runs
208//! let runs = client.get_workflow_runs(&repo, "deploy.yml", 10).await?;
209//! for run in runs.workflow_runs {
210//!     println!("Run #{}: {} ({})", run.run_number, run.name, run.status);
211//! }
212//!
213//! // Trigger a workflow (requires authentication)
214//! client.trigger_workflow(&repo, "deploy.yml", "main").await?;
215//! # Ok(())
216//! # }
217//! ```
218
219use ggen_utils::error::{Error, Result};
220use reqwest;
221use serde::{Deserialize, Serialize};
222use std::env;
223use url::Url;
224
225/// GitHub API client for Pages and workflow operations
226#[derive(Debug, Clone)]
227pub struct GitHubClient {
228    base_url: Url,
229    client: reqwest::Client,
230    token: Option<String>,
231}
232
233/// GitHub Pages configuration
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct PagesConfig {
236    pub url: Option<String>,
237    pub status: Option<String>,
238    #[serde(default)]
239    pub cname: Option<String>,
240    pub source: Option<PagesSource>,
241    pub https_enforced: Option<bool>,
242    pub html_url: Option<String>,
243}
244
245/// GitHub Pages source configuration
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct PagesSource {
248    pub branch: Option<String>,
249    pub path: Option<String>,
250}
251
252/// GitHub Actions workflow run
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct WorkflowRun {
255    pub id: u64,
256    pub name: String,
257    pub head_branch: String,
258    pub status: String,
259    pub conclusion: Option<String>,
260    pub created_at: String,
261    pub updated_at: String,
262    pub html_url: String,
263    pub run_number: u32,
264    pub event: String,
265}
266
267/// Workflow runs response
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct WorkflowRunsResponse {
270    pub total_count: u32,
271    pub workflow_runs: Vec<WorkflowRun>,
272}
273
274/// Pages deployment
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct PagesDeployment {
277    pub id: Option<u64>,
278    pub status_url: Option<String>,
279    pub environment: Option<String>,
280    pub created_at: Option<String>,
281    pub updated_at: Option<String>,
282}
283
284/// Repository information
285#[derive(Debug, Clone)]
286pub struct RepoInfo {
287    pub owner: String,
288    pub name: String,
289}
290
291impl RepoInfo {
292    /// Parse from "owner/repo" format
293    pub fn parse(repo_str: &str) -> Result<Self> {
294        let parts: Vec<&str> = repo_str.split('/').collect();
295        if parts.len() != 2 {
296            return Err(Error::new(&format!(
297                "Invalid repository format. Expected 'owner/repo', got '{}'",
298                repo_str
299            )));
300        }
301        Ok(Self {
302            owner: parts[0].to_string(),
303            name: parts[1].to_string(),
304        })
305    }
306
307    /// Get repository string
308    pub fn as_str(&self) -> String {
309        format!("{}/{}", self.owner, self.name)
310    }
311}
312
313impl GitHubClient {
314    /// Create a new GitHub API client
315    pub fn new(_repo: RepoInfo) -> Result<Self> {
316        let base_url = Url::parse("https://api.github.com").map_err(|e| {
317            Error::with_context("Failed to parse GitHub API base URL", &e.to_string())
318        })?;
319
320        // Check for GitHub token in environment
321        let token = env::var("GITHUB_TOKEN")
322            .ok()
323            .or_else(|| env::var("GH_TOKEN").ok());
324
325        let mut client_builder = reqwest::Client::builder()
326            .timeout(std::time::Duration::from_secs(30))
327            .user_agent("ggen-cli");
328
329        // Add authorization header if token is available
330        if let Some(ref token) = token {
331            let mut headers = reqwest::header::HeaderMap::new();
332            headers.insert(
333                reqwest::header::AUTHORIZATION,
334                reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))
335                    .map_err(|e| Error::with_context("Invalid GitHub token", &e.to_string()))?,
336            );
337            client_builder = client_builder.default_headers(headers);
338        }
339
340        let client = client_builder
341            .build()
342            .map_err(|e| Error::with_context("Failed to create HTTP client", &e.to_string()))?;
343
344        Ok(Self {
345            base_url,
346            client,
347            token,
348        })
349    }
350
351    /// Check if client is authenticated
352    pub fn is_authenticated(&self) -> bool {
353        self.token.is_some()
354    }
355
356    /// Get Pages configuration for a repository
357    pub async fn get_pages_config(&self, repo: &RepoInfo) -> Result<PagesConfig> {
358        let url = self
359            .base_url
360            .join(&format!("repos/{}/pages", repo.as_str()))
361            .map_err(|e| {
362                Error::with_context("Failed to construct Pages API URL", &e.to_string())
363            })?;
364
365        let response = self.client.get(url.clone()).send().await.map_err(|e| {
366            Error::with_context(
367                &format!("Failed to fetch Pages config from {}", url),
368                &e.to_string(),
369            )
370        })?;
371
372        if response.status() == 404 {
373            return Err(Error::new(&format!(
374                "GitHub Pages not configured for repository {}",
375                repo.as_str()
376            )));
377        }
378
379        if !response.status().is_success() {
380            return Err(Error::new(&format!(
381                "GitHub API returned status: {} for URL: {}",
382                response.status(),
383                url
384            )));
385        }
386
387        let config: PagesConfig = response.json().await.map_err(|e| {
388            Error::with_context("Failed to parse Pages configuration", &e.to_string())
389        })?;
390
391        Ok(config)
392    }
393
394    /// Get workflow runs for a specific workflow file
395    pub async fn get_workflow_runs(
396        &self, repo: &RepoInfo, workflow_file: &str, per_page: u32,
397    ) -> Result<WorkflowRunsResponse> {
398        let url = self
399            .base_url
400            .join(&format!(
401                "repos/{}/actions/workflows/{}/runs?per_page={}",
402                repo.as_str(),
403                workflow_file,
404                per_page
405            ))
406            .map_err(|e| {
407                Error::with_context("Failed to construct workflow runs API URL", &e.to_string())
408            })?;
409
410        let response = self.client.get(url.clone()).send().await.map_err(|e| {
411            Error::with_context(
412                &format!("Failed to fetch workflow runs from {}", url),
413                &e.to_string(),
414            )
415        })?;
416
417        if !response.status().is_success() {
418            return Err(Error::new(&format!(
419                "GitHub API returned status: {} for URL: {}",
420                response.status(),
421                url
422            )));
423        }
424
425        let runs: WorkflowRunsResponse = response
426            .json()
427            .await
428            .map_err(|e| Error::with_context("Failed to parse workflow runs", &e.to_string()))?;
429
430        Ok(runs)
431    }
432
433    /// Get a specific workflow run by ID
434    pub async fn get_workflow_run(&self, repo: &RepoInfo, run_id: u64) -> Result<WorkflowRun> {
435        let url = self
436            .base_url
437            .join(&format!("repos/{}/actions/runs/{}", repo.as_str(), run_id))
438            .map_err(|e| {
439                Error::with_context("Failed to construct workflow run API URL", &e.to_string())
440            })?;
441
442        let response = self.client.get(url.clone()).send().await.map_err(|e| {
443            Error::with_context(
444                &format!("Failed to fetch workflow run from {}", url),
445                &e.to_string(),
446            )
447        })?;
448
449        if !response.status().is_success() {
450            return Err(Error::new(&format!(
451                "GitHub API returned status: {} for URL: {}",
452                response.status(),
453                url
454            )));
455        }
456
457        let run: WorkflowRun = response
458            .json()
459            .await
460            .map_err(|e| Error::with_context("Failed to parse workflow run", &e.to_string()))?;
461
462        Ok(run)
463    }
464
465    /// Trigger a workflow dispatch event
466    pub async fn trigger_workflow(
467        &self,
468        repo: &RepoInfo,
469        workflow_file: &str,
470        ref_name: &str, // branch, tag, or SHA
471    ) -> Result<()> {
472        if !self.is_authenticated() {
473            return Err(Error::new("GitHub token required to trigger workflows. Set GITHUB_TOKEN or GH_TOKEN environment variable."));
474        }
475
476        let url = self
477            .base_url
478            .join(&format!(
479                "repos/{}/actions/workflows/{}/dispatches",
480                repo.as_str(),
481                workflow_file
482            ))
483            .map_err(|e| {
484                Error::with_context(
485                    "Failed to construct workflow dispatch API URL",
486                    &e.to_string(),
487                )
488            })?;
489
490        #[derive(Serialize)]
491        struct DispatchRequest<'a> {
492            #[serde(rename = "ref")]
493            ref_name: &'a str,
494        }
495
496        let body = DispatchRequest { ref_name };
497
498        let response = self
499            .client
500            .post(url.clone())
501            .json(&body)
502            .send()
503            .await
504            .map_err(|e| {
505                Error::with_context(
506                    &format!("Failed to trigger workflow at {}", url),
507                    &e.to_string(),
508                )
509            })?;
510
511        if !response.status().is_success() {
512            return Err(Error::new(&format!(
513                "Failed to trigger workflow. Status: {}",
514                response.status()
515            )));
516        }
517
518        Ok(())
519    }
520
521    /// Check if a GitHub Pages site is accessible
522    pub async fn check_site_status(&self, pages_url: &str) -> Result<u16> {
523        let response = self.client.get(pages_url).send().await.map_err(|e| {
524            Error::with_context(
525                &format!("Failed to check site status at {}", pages_url),
526                &e.to_string(),
527            )
528        })?;
529
530        Ok(response.status().as_u16())
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn test_repo_info_parse() {
540        let repo = RepoInfo::parse("seanchatmangpt/ggen").unwrap();
541        assert_eq!(repo.owner, "seanchatmangpt");
542        assert_eq!(repo.name, "ggen");
543        assert_eq!(repo.as_str(), "seanchatmangpt/ggen");
544    }
545
546    #[test]
547    fn test_repo_info_parse_invalid() {
548        assert!(RepoInfo::parse("invalid").is_err());
549        assert!(RepoInfo::parse("too/many/parts").is_err());
550    }
551
552    #[ignore] // Requires network access
553    #[tokio::test]
554    async fn test_github_client_creation() {
555        let repo = RepoInfo::parse("seanchatmangpt/ggen").unwrap();
556        let client = GitHubClient::new(repo);
557        assert!(client.is_ok());
558    }
559}