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}