Skip to main content

github_bot_sdk/client/
mod.rs

1//! GitHub API client for authenticated operations.
2//!
3//! This module provides the primary interface for interacting with GitHub's REST API as a GitHub App.
4//! It handles authentication, rate limiting, retries, pagination, and provides type-safe operations
5//! for repositories, issues, pull requests, projects, and more.
6//!
7//! # Overview
8//!
9//! The client operates at two levels:
10//!
11//! - **App-level** ([`GitHubClient`]) - Operations as the GitHub App itself (using JWT)
12//! - **Installation-level** ([`InstallationClient`]) - Operations within a specific installation (using installation tokens)
13//!
14//! Most operations happen at the installation level, where you interact with repositories,
15//! issues, pull requests, etc. on behalf of the installation.
16//!
17//! # Features
18//!
19//! - **Automatic Authentication** - Tokens are injected and refreshed automatically
20//! - **Rate Limit Handling** - Detects rate limits and backs off appropriately
21//! - **Retry Logic** - Exponential backoff for transient failures (network errors, 5xx responses)
22//! - **Pagination Support** - Helper methods for paginated API responses
23//! - **Type Safety** - Strongly-typed request and response models
24//! - **Configurable** - Timeouts, retry behavior, rate limit margins
25//!
26//! # Quick Start
27//!
28//! ```no_run
29//! use github_bot_sdk::{
30//!     auth::{AuthenticationProvider, InstallationId},
31//!     client::{GitHubClient, ClientConfig},
32//!     error::ApiError,
33//! };
34//! use std::time::Duration;
35//!
36//! # async fn example(auth: impl AuthenticationProvider + 'static) -> Result<(), ApiError> {
37//! // Create client with custom configuration
38//! let client = GitHubClient::builder(auth)
39//!     .config(ClientConfig::default()
40//!         .with_user_agent("my-bot/1.0")
41//!         .with_timeout(Duration::from_secs(30))
42//!         .with_max_retries(5))
43//!     .build()?;
44//!
45//! // Get app information (app-level operation)
46//! let app = client.get_app().await?;
47//! println!("Running as: {}", app.name);
48//!
49//! // Get installation information
50//! let installation_id = InstallationId::new(12345);
51//! let installation = client.get_installation(installation_id).await?;
52//! println!("Installation: {}", installation.id.as_u64());
53//! # Ok(())
54//! # }
55//! ```
56//!
57//! # Configuration
58//!
59//! Use [`ClientConfig`] to customize client behavior:
60//!
61//! ```
62//! use github_bot_sdk::client::ClientConfig;
63//! use std::time::Duration;
64//!
65//! let config = ClientConfig::default()
66//!     .with_user_agent("my-github-bot/1.0")           // Required by GitHub
67//!     .with_timeout(Duration::from_secs(60))          // Request timeout
68//!     .with_max_retries(5)                            // Max retry attempts
69//!     .with_rate_limit_margin(0.1)                    // Keep 10% rate limit buffer
70//!     .with_github_api_url("https://api.github.com"); // Override for GHE
71//!
72//! // Or use the builder pattern
73//! let config = ClientConfig::builder()
74//!     .user_agent("my-bot/2.0")
75//!     .timeout(Duration::from_secs(45))
76//!     .max_retries(3)
77//!     .build();
78//! ```
79//!
80//! # Operations by Category
81//!
82//! ## Repository Operations
83//!
84//! ```no_run
85//! # use github_bot_sdk::client::GitHubClient;
86//! # use github_bot_sdk::auth::InstallationId;
87//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
88//! let installation_id = InstallationId::new(12345);
89//! let _installation = client.get_installation(installation_id).await?;
90//!
91//! // Repository operations are available through client methods
92//! // See InstallationClient and related modules for detailed API
93//! # Ok(())
94//! # }
95//! ```
96//!
97//! ## Issue Operations
98//!
99//! ```no_run
100//! # use github_bot_sdk::client::GitHubClient;
101//! # use github_bot_sdk::auth::InstallationId;
102//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
103//! let installation_id = InstallationId::new(12345);
104//! let _installation = client.get_installation(installation_id).await?;
105//!
106//! // Issue operations through client methods
107//! // Create, comment, label, and manage issues
108//! # Ok(())
109//! # }
110//! ```
111//!
112//! ## Pull Request Operations
113//!
114//! ```no_run
115//! # use github_bot_sdk::client::GitHubClient;
116//! # use github_bot_sdk::auth::InstallationId;
117//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
118//! let installation_id = InstallationId::new(12345);
119//! let _installation = client.get_installation(installation_id).await?;
120//!
121//! // Pull request operations through client methods
122//! // Create, review, merge, and manage PRs
123//! # Ok(())
124//! # }
125//! ```
126//!
127//! ## Project Operations (GitHub Projects V2)
128//!
129//! ```no_run
130//! # use github_bot_sdk::client::GitHubClient;
131//! # use github_bot_sdk::auth::InstallationId;
132//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
133//! let installation_id = InstallationId::new(12345);
134//! let _installation = client.get_installation(installation_id).await?;
135//!
136//! // Project operations through client methods
137//! # Ok(())
138//! # }
139//! ```
140//!
141//! # Rate Limiting
142//!
143//! The client automatically handles GitHub's rate limits:
144//!
145//! - **Detection** - Monitors `X-RateLimit-*` headers in responses
146//! - **Proactive Backoff** - Slows down when approaching limit (configurable margin)
147//! - **Reactive Handling** - Respects `Retry-After` headers on 429 responses
148//! - **Secondary Rate Limits** - Handles abuse detection (403 with retry-after)
149//!
150//! ```no_run
151//! # use github_bot_sdk::client::{GitHubClient, ClientConfig};
152//! # use github_bot_sdk::auth::AuthenticationProvider;
153//! # async fn example(auth: impl AuthenticationProvider + 'static) -> Result<(), Box<dyn std::error::Error>> {
154//! // Configure rate limit behavior
155//! let config = ClientConfig::default()
156//!     .with_rate_limit_margin(0.15);  // Start backing off at 85% of limit
157//!
158//! let client = GitHubClient::builder(auth)
159//!     .config(config)
160//!     .build()?;
161//!
162//! // Client will automatically handle rate limits
163//! // No manual rate limit checking needed
164//! # Ok(())
165//! # }
166//! ```
167//!
168//! # Retry Behavior
169//!
170//! The client automatically retries transient failures with exponential backoff:
171//!
172//! - **Retryable Errors**:
173//!   - Network errors (timeouts, connection failures)
174//!   - 5xx server errors (GitHub is having issues)
175//!   - 429 rate limit exceeded (respects Retry-After)
176//!   - 403 with Retry-After (secondary rate limit)
177//!
178//! - **Non-Retryable Errors**:
179//!   - 4xx client errors (except 429 and some 403s)
180//!   - Authentication failures
181//!   - Validation errors
182//!
183//! ```no_run
184//! use github_bot_sdk::client::ClientConfig;
185//! use std::time::Duration;
186//!
187//! // Configure retry behavior
188//! let config = ClientConfig::default()
189//!     .with_max_retries(5)                            // Max attempts
190//!     .with_timeout(Duration::from_secs(30));         // Request timeout
191//!
192//! // Default exponential backoff is applied automatically
193//! ```
194//!
195//! # Pagination
196//!
197//! GitHub's API uses Link headers for pagination. See specific operation documentation for examples.
198//!
199//! ```no_run
200//! # use github_bot_sdk::client::GitHubClient;
201//! # use github_bot_sdk::auth::InstallationId;
202//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
203//! let installation_id = InstallationId::new(12345);
204//!
205//! // Get installation
206//! let installation = client.get_installation(installation_id).await?;
207//! println!("Installation: {}", installation.id.as_u64());
208//! # Ok(())
209//! # }
210//! ```
211//!
212//! # Error Handling
213//!
214//! All operations return [`Result<T, ApiError>`]:
215//!
216//! ```no_run
217//! # use github_bot_sdk::{client::GitHubClient, error::ApiError, auth::InstallationId};
218//! # async fn example(client: &GitHubClient) -> Result<(), ApiError> {
219//! let installation_id = InstallationId::new(12345);
220//!
221//! // Error handling example
222//! match client.get_installation(installation_id).await {
223//!     Ok(installation) => println!("Found installation: {}", installation.id.as_u64()),
224//!     Err(ApiError::NotFound { .. }) => {
225//!         println!("Installation doesn't exist or no access");
226//!     }
227//!     Err(ApiError::RateLimitExceeded { reset_at, .. }) => {
228//!         println!("Rate limited, resets at: {:?}", reset_at);
229//!     }
230//!     Err(ApiError::AuthorizationFailed) => {
231//!         println!("Insufficient permissions");
232//!     }
233//!     Err(e) => return Err(e),
234//! }
235//! # Ok(())
236//! # }
237//! ```
238//!
239//! # GitHub Enterprise Support
240//!
241//! To use with GitHub Enterprise Server, configure the API base URL:
242//!
243//! ```
244//! use github_bot_sdk::client::ClientConfig;
245//!
246//! let config = ClientConfig::default()
247//!     .with_github_api_url("https://github.example.com/api/v3");
248//! ```
249//!
250//! # See Also
251//!
252//! - [`crate::auth`] - Authentication types and providers
253//! - [`crate::webhook`] - Webhook validation for incoming events
254//! - [GitHub REST API Documentation](https://docs.github.com/en/rest)
255
256mod app;
257mod commit;
258mod installation;
259mod issue;
260mod pagination;
261mod project;
262mod pull_request;
263mod rate_limit;
264mod release;
265mod repository;
266mod retry;
267mod workflow;
268
269use std::sync::Arc;
270use std::time::Duration;
271
272use reqwest;
273
274use crate::auth::{AuthenticationProvider, Installation, InstallationId};
275use crate::error::ApiError;
276
277// ============================================================================
278// Shared HTTP error mapping
279// ============================================================================
280
281/// Map an unsuccessful HTTP response to the canonical `ApiError` variant.
282///
283/// Called by sub-client modules (`issue`, `pull_request`) and
284/// `InstallationClient::fetch_all_pages`. The response body is consumed only
285/// for status codes that carry a useful message (422 and any unrecognised
286/// error), avoiding unnecessary I/O on 401 / 403 / 404 responses.
287pub(in crate::client) async fn map_http_error(
288    status: reqwest::StatusCode,
289    response: reqwest::Response,
290) -> ApiError {
291    match status.as_u16() {
292        401 => ApiError::AuthenticationFailed,
293        403 => ApiError::AuthorizationFailed,
294        404 => ApiError::NotFound,
295        422 => {
296            let message = response
297                .text()
298                .await
299                .unwrap_or_else(|_| "Validation failed".to_string());
300            ApiError::InvalidRequest { message }
301        }
302        _ => {
303            let message = response
304                .text()
305                .await
306                .unwrap_or_else(|_| "Unknown error".to_string());
307            ApiError::HttpError {
308                status: status.as_u16(),
309                message,
310            }
311        }
312    }
313}
314
315pub use app::App;
316pub use commit::{
317    CommitDetails, CommitReference, Comparison, FileChange, FullCommit, GitSignature, Verification,
318};
319pub use installation::InstallationClient;
320pub use issue::{
321    Comment, CreateCommentRequest, CreateIssueRequest, CreateLabelRequest, CreateMilestoneRequest,
322    Issue, IssueActivityEvent, IssueRename, IssueUser, IssuesClient, Label, LabelsClient,
323    LockReason, Milestone, MilestoneSortField, MilestoneState, MilestoneSummary, MilestonesClient,
324    Reaction, ReactionContent, SortDirection, TimelineEvent, UpdateCommentRequest,
325    UpdateIssueRequest, UpdateLabelRequest, UpdateMilestoneRequest,
326};
327pub use pagination::{extract_page_number, parse_link_header, PagedResponse, Pagination};
328pub use project::{
329    AddProjectV2ItemRequest, ProjectOwner, ProjectV2, ProjectV2Item, ProjectsClient,
330};
331pub use pull_request::{
332    CreatePullRequestCommentRequest, CreatePullRequestRequest, CreateReviewRequest,
333    DismissReviewRequest, MergePullRequestRequest, MergeResult, PullRequest, PullRequestBranch,
334    PullRequestComment, PullRequestRepo, PullRequestsClient, Review,
335    UpdatePullRequestCommentRequest, UpdatePullRequestRequest, UpdateReviewRequest,
336};
337pub use rate_limit::{parse_rate_limit_from_headers, RateLimit, RateLimitContext, RateLimiter};
338pub use release::{
339    CreateReleaseRequest, Release, ReleaseAsset, ReleasesClient, UpdateReleaseRequest,
340};
341pub use repository::{
342    Branch, Commit, GitRef, OwnerType, RepositoriesClient, Repository, RepositoryOwner, Tag,
343};
344pub use retry::{
345    calculate_rate_limit_delay, detect_secondary_rate_limit, parse_retry_after, RateLimitInfo,
346    RetryPolicy,
347};
348pub use workflow::{
349    TriggerWorkflowRequest, Workflow, WorkflowRun, WorkflowRunConclusion, WorkflowRunStatus,
350    WorkflowState, WorkflowsClient,
351};
352
353/// Configuration for GitHub API client behavior.
354///
355/// Controls timeouts, retry behavior, rate limiting, and API endpoints.
356///
357/// # Examples
358///
359/// ```
360/// use github_bot_sdk::client::ClientConfig;
361/// use std::time::Duration;
362///
363/// let config = ClientConfig::default()
364///     .with_timeout(Duration::from_secs(60))
365///     .with_max_retries(5);
366/// ```
367#[derive(Debug, Clone)]
368pub struct ClientConfig {
369    /// User agent string for API requests (required by GitHub)
370    pub user_agent: String,
371    /// Request timeout duration
372    pub timeout: Duration,
373    /// Maximum number of retry attempts for transient failures
374    pub max_retries: u32,
375    /// Base delay for exponential backoff retries
376    pub initial_retry_delay: Duration,
377    /// Maximum delay between retries
378    pub max_retry_delay: Duration,
379    /// Rate limit safety margin (0.0 to 1.0) - buffer before hitting limits
380    pub rate_limit_margin: f64,
381    /// GitHub API base URL
382    pub github_api_url: String,
383}
384
385impl Default for ClientConfig {
386    fn default() -> Self {
387        Self {
388            user_agent: "github-bot-sdk/0.1.0".to_string(),
389            timeout: Duration::from_secs(30),
390            max_retries: 3,
391            initial_retry_delay: Duration::from_millis(100),
392            max_retry_delay: Duration::from_secs(60),
393            rate_limit_margin: 0.1, // Keep 10% buffer
394            github_api_url: "https://api.github.com".to_string(),
395        }
396    }
397}
398
399impl ClientConfig {
400    /// Create a new builder for client configuration.
401    pub fn builder() -> ClientConfigBuilder {
402        ClientConfigBuilder::new()
403    }
404
405    /// Set the user agent string.
406    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
407        self.user_agent = user_agent.into();
408        self
409    }
410
411    /// Set the request timeout.
412    pub fn with_timeout(mut self, timeout: Duration) -> Self {
413        self.timeout = timeout;
414        self
415    }
416
417    /// Set the maximum number of retries.
418    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
419        self.max_retries = max_retries;
420        self
421    }
422
423    /// Set the rate limit safety margin.
424    pub fn with_rate_limit_margin(mut self, margin: f64) -> Self {
425        self.rate_limit_margin = margin.clamp(0.0, 1.0);
426        self
427    }
428
429    /// Set the GitHub API base URL.
430    pub fn with_github_api_url(mut self, url: impl Into<String>) -> Self {
431        self.github_api_url = url.into();
432        self
433    }
434}
435
436/// Builder for constructing `ClientConfig` instances.
437#[derive(Debug)]
438pub struct ClientConfigBuilder {
439    config: ClientConfig,
440}
441
442impl ClientConfigBuilder {
443    /// Create a new configuration builder with defaults.
444    pub fn new() -> Self {
445        Self {
446            config: ClientConfig::default(),
447        }
448    }
449
450    /// Set the user agent string.
451    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
452        self.config.user_agent = user_agent.into();
453        self
454    }
455
456    /// Set the request timeout.
457    pub fn timeout(mut self, timeout: Duration) -> Self {
458        self.config.timeout = timeout;
459        self
460    }
461
462    /// Set the maximum number of retries.
463    pub fn max_retries(mut self, max_retries: u32) -> Self {
464        self.config.max_retries = max_retries;
465        self
466    }
467
468    /// Set the rate limit safety margin.
469    pub fn rate_limit_margin(mut self, margin: f64) -> Self {
470        self.config.rate_limit_margin = margin.clamp(0.0, 1.0);
471        self
472    }
473
474    /// Set the GitHub API base URL.
475    pub fn github_api_url(mut self, url: impl Into<String>) -> Self {
476        self.config.github_api_url = url.into();
477        self
478    }
479
480    /// Build the final configuration.
481    pub fn build(self) -> ClientConfig {
482        self.config
483    }
484}
485
486impl Default for ClientConfigBuilder {
487    fn default() -> Self {
488        Self::new()
489    }
490}
491
492/// GitHub API client for authenticated operations.
493///
494/// The main client for interacting with GitHub's REST API. Handles authentication,
495/// rate limiting, retries, and provides both app-level and installation-level operations.
496///
497/// # Examples
498///
499/// ```no_run
500/// # use github_bot_sdk::client::{GitHubClient, ClientConfig};
501/// # use github_bot_sdk::auth::AuthenticationProvider;
502/// # async fn example(auth: impl AuthenticationProvider + 'static) -> Result<(), Box<dyn std::error::Error>> {
503/// let client = GitHubClient::builder(auth)
504///     .config(ClientConfig::default())
505///     .build()?;
506///
507/// // Get app information
508/// let app = client.get_app().await?;
509/// println!("App: {}", app.name);
510/// # Ok(())
511/// # }
512/// ```
513#[derive(Clone)]
514pub struct GitHubClient {
515    auth: Arc<dyn AuthenticationProvider>,
516    http_client: reqwest::Client,
517    config: ClientConfig,
518}
519
520impl GitHubClient {
521    /// Create a new builder for constructing a GitHub client.
522    ///
523    /// # Arguments
524    ///
525    /// * `auth` - Authentication provider for obtaining tokens
526    ///
527    /// # Examples
528    ///
529    /// ```no_run
530    /// # use github_bot_sdk::client::GitHubClient;
531    /// # use github_bot_sdk::auth::AuthenticationProvider;
532    /// # async fn example(auth: impl AuthenticationProvider + 'static) {
533    /// let client = GitHubClient::builder(auth).build().unwrap();
534    /// # }
535    /// ```
536    pub fn builder(auth: impl AuthenticationProvider + 'static) -> GitHubClientBuilder {
537        GitHubClientBuilder::new(auth)
538    }
539
540    /// Get the client configuration.
541    pub fn config(&self) -> &ClientConfig {
542        &self.config
543    }
544
545    /// Get the authentication provider.
546    pub fn auth_provider(&self) -> &dyn AuthenticationProvider {
547        self.auth.as_ref()
548    }
549
550    /// Get the HTTP client (internal use by InstallationClient).
551    pub(crate) fn http_client(&self) -> &reqwest::Client {
552        &self.http_client
553    }
554
555    // ========================================================================
556    // App-Level Operations (authenticated with JWT)
557    // ========================================================================
558
559    /// Get details about the authenticated GitHub App.
560    ///
561    /// Fetches metadata about the app including ID, name, owner, and permissions.
562    ///
563    /// # Authentication
564    ///
565    /// Requires app-level JWT authentication.
566    ///
567    /// # Examples
568    ///
569    /// ```no_run
570    /// # use github_bot_sdk::client::GitHubClient;
571    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
572    /// let app = client.get_app().await?;
573    /// println!("App: {} (ID: {})", app.name, app.id);
574    /// # Ok(())
575    /// # }
576    /// ```
577    ///
578    /// # Errors
579    ///
580    /// Returns `ApiError` if:
581    /// - JWT generation fails
582    /// - HTTP request fails
583    /// - Response cannot be parsed
584    pub async fn get_app(&self) -> Result<App, ApiError> {
585        // Get JWT token from auth provider
586        let jwt = self
587            .auth
588            .app_token()
589            .await
590            .map_err(|e| ApiError::TokenGenerationFailed {
591                message: format!("Failed to generate JWT: {}", e),
592            })?;
593
594        // Build request URL
595        let url = format!("{}/app", self.config.github_api_url);
596
597        // Make authenticated request
598        let response = self
599            .http_client
600            .get(&url)
601            .header("Authorization", format!("Bearer {}", jwt.token()))
602            .header("Accept", "application/vnd.github+json")
603            .send()
604            .await
605            .map_err(|e| ApiError::Configuration {
606                message: format!("HTTP request failed: {}", e),
607            })?;
608
609        // Check for errors
610        if !response.status().is_success() {
611            let status = response.status();
612            let error_text = response
613                .text()
614                .await
615                .unwrap_or_else(|_| "Unable to read error body".to_string());
616            return Err(ApiError::Configuration {
617                message: format!("API request failed with status {}: {}", status, error_text),
618            });
619        }
620
621        // Parse response
622        let app = response
623            .json::<App>()
624            .await
625            .map_err(|e| ApiError::Configuration {
626                message: format!("Failed to parse App response: {}", e),
627            })?;
628
629        Ok(app)
630    }
631
632    /// List all installations for the authenticated GitHub App.
633    ///
634    /// Fetches all installations where this app is installed, including organizations
635    /// and user accounts.
636    ///
637    /// # Authentication
638    ///
639    /// Requires app-level JWT authentication.
640    ///
641    /// # Examples
642    ///
643    /// ```no_run
644    /// # use github_bot_sdk::client::GitHubClient;
645    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
646    /// let installations = client.list_installations().await?;
647    /// for installation in installations {
648    ///     println!("Installation ID: {} for {}", installation.id.as_u64(), installation.account.login);
649    /// }
650    /// # Ok(())
651    /// # }
652    /// ```
653    ///
654    /// # Errors
655    ///
656    /// Returns `ApiError` if:
657    /// - JWT generation fails
658    /// - HTTP request fails
659    /// - Response cannot be parsed
660    pub async fn list_installations(&self) -> Result<Vec<Installation>, ApiError> {
661        // Get JWT token from auth provider
662        let jwt = self
663            .auth
664            .app_token()
665            .await
666            .map_err(|e| ApiError::TokenGenerationFailed {
667                message: format!("Failed to generate JWT: {}", e),
668            })?;
669
670        // Build request URL
671        let url = format!("{}/app/installations", self.config.github_api_url);
672
673        // Make authenticated request
674        let response = self
675            .http_client
676            .get(&url)
677            .header("Authorization", format!("Bearer {}", jwt.token()))
678            .header("Accept", "application/vnd.github+json")
679            .send()
680            .await
681            .map_err(|e| ApiError::Configuration {
682                message: format!("HTTP request failed: {}", e),
683            })?;
684
685        // Check for errors
686        if !response.status().is_success() {
687            let status = response.status();
688            let error_text = response
689                .text()
690                .await
691                .unwrap_or_else(|_| "Unable to read error body".to_string());
692            return Err(ApiError::Configuration {
693                message: format!("API request failed with status {}: {}", status, error_text),
694            });
695        }
696
697        // Parse response
698        let installations =
699            response
700                .json::<Vec<Installation>>()
701                .await
702                .map_err(|e| ApiError::Configuration {
703                    message: format!("Failed to parse installations response: {}", e),
704                })?;
705
706        Ok(installations)
707    }
708
709    /// Get a specific installation by ID.
710    ///
711    /// Fetches detailed information about a specific installation of this GitHub App.
712    ///
713    /// # Authentication
714    ///
715    /// Requires app-level JWT authentication.
716    ///
717    /// # Arguments
718    ///
719    /// * `installation_id` - The unique identifier for the installation
720    ///
721    /// # Examples
722    ///
723    /// ```no_run
724    /// # use github_bot_sdk::client::GitHubClient;
725    /// # use github_bot_sdk::auth::InstallationId;
726    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
727    /// let installation_id = InstallationId::new(12345);
728    /// let installation = client.get_installation(installation_id).await?;
729    /// println!("Installation for: {}", installation.account.login);
730    /// # Ok(())
731    /// # }
732    /// ```
733    ///
734    /// # Errors
735    ///
736    /// Returns `ApiError` if:
737    /// - JWT generation fails
738    /// - HTTP request fails
739    /// - Installation not found (404)
740    /// - Response cannot be parsed
741    pub async fn get_installation(
742        &self,
743        installation_id: InstallationId,
744    ) -> Result<Installation, ApiError> {
745        // Get JWT token from auth provider
746        let jwt = self
747            .auth
748            .app_token()
749            .await
750            .map_err(|e| ApiError::TokenGenerationFailed {
751                message: format!("Failed to generate JWT: {}", e),
752            })?;
753
754        // Build request URL
755        let url = format!(
756            "{}/app/installations/{}",
757            self.config.github_api_url,
758            installation_id.as_u64()
759        );
760
761        // Make authenticated request
762        let response = self
763            .http_client
764            .get(&url)
765            .header("Authorization", format!("Bearer {}", jwt.token()))
766            .header("Accept", "application/vnd.github+json")
767            .send()
768            .await
769            .map_err(|e| ApiError::Configuration {
770                message: format!("HTTP request failed: {}", e),
771            })?;
772
773        // Check for errors
774        if !response.status().is_success() {
775            let status = response.status();
776            let error_text = response
777                .text()
778                .await
779                .unwrap_or_else(|_| "Unable to read error body".to_string());
780            return Err(ApiError::Configuration {
781                message: format!("API request failed with status {}: {}", status, error_text),
782            });
783        }
784
785        // Parse response
786        let installation =
787            response
788                .json::<Installation>()
789                .await
790                .map_err(|e| ApiError::Configuration {
791                    message: format!("Failed to parse installation response: {}", e),
792                })?;
793
794        Ok(installation)
795    }
796
797    /// Make a raw authenticated GET request as the GitHub App.
798    ///
799    /// This is a generic method for making custom API requests that aren't covered
800    /// by the specific methods. Returns the raw response for flexible handling by the caller.
801    ///
802    /// # Authentication
803    ///
804    /// Requires app-level JWT authentication.
805    ///
806    /// # Arguments
807    ///
808    /// * `path` - The API path (e.g., "/app/installations" or "app/installations")
809    ///
810    /// # Examples
811    ///
812    /// ```no_run
813    /// # use github_bot_sdk::client::GitHubClient;
814    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
815    /// // Make a custom GET request
816    /// let response = client.get_as_app("/app/hook/config").await?;
817    ///
818    /// if response.status().is_success() {
819    ///     let data: serde_json::Value = response.json().await?;
820    ///     println!("Response: {:?}", data);
821    /// }
822    /// # Ok(())
823    /// # }
824    /// ```
825    ///
826    /// # Errors
827    ///
828    /// Returns `ApiError` if:
829    /// - JWT generation fails
830    /// - HTTP request fails (network error, timeout, etc.)
831    ///
832    /// Note: Does NOT return an error for non-2xx status codes. The caller is responsible
833    /// for checking the response status.
834    pub async fn get_as_app(&self, path: &str) -> Result<reqwest::Response, ApiError> {
835        // Get JWT token from auth provider
836        let jwt = self
837            .auth
838            .app_token()
839            .await
840            .map_err(|e| ApiError::TokenGenerationFailed {
841                message: format!("Failed to generate JWT: {}", e),
842            })?;
843
844        // Normalize path - remove leading slash if present for consistent URL building
845        let normalized_path = path.strip_prefix('/').unwrap_or(path);
846
847        // Build request URL
848        let url = format!("{}/{}", self.config.github_api_url, normalized_path);
849
850        // Make authenticated request
851        let response = self
852            .http_client
853            .get(&url)
854            .header("Authorization", format!("Bearer {}", jwt.token()))
855            .header("Accept", "application/vnd.github+json")
856            .send()
857            .await
858            .map_err(|e| ApiError::Configuration {
859                message: format!("HTTP request failed: {}", e),
860            })?;
861
862        Ok(response)
863    }
864
865    /// Make a raw authenticated POST request as the GitHub App.
866    ///
867    /// This is a generic method for making custom API requests that aren't covered
868    /// by the specific methods. Returns the raw response for flexible handling by the caller.
869    ///
870    /// # Authentication
871    ///
872    /// Requires app-level JWT authentication.
873    ///
874    /// # Arguments
875    ///
876    /// * `path` - The API path (e.g., "/app/installations/{id}/suspended")
877    /// * `body` - The request body to serialize as JSON
878    ///
879    /// # Examples
880    ///
881    /// ```no_run
882    /// # use github_bot_sdk::client::GitHubClient;
883    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
884    /// // Make a custom POST request
885    /// let body = serde_json::json!({"reason": "Violation of terms"});
886    /// let response = client.post_as_app("/app/installations/123/suspended", &body).await?;
887    ///
888    /// if response.status().is_success() {
889    ///     println!("Installation suspended");
890    /// }
891    /// # Ok(())
892    /// # }
893    /// ```
894    ///
895    /// # Errors
896    ///
897    /// Returns `ApiError` if:
898    /// - JWT generation fails
899    /// - Body serialization fails
900    /// - HTTP request fails (network error, timeout, etc.)
901    ///
902    /// Note: Does NOT return an error for non-2xx status codes. The caller is responsible
903    /// for checking the response status.
904    pub async fn post_as_app(
905        &self,
906        path: &str,
907        body: &impl serde::Serialize,
908    ) -> Result<reqwest::Response, ApiError> {
909        // Get JWT token from auth provider
910        let jwt = self
911            .auth
912            .app_token()
913            .await
914            .map_err(|e| ApiError::TokenGenerationFailed {
915                message: format!("Failed to generate JWT: {}", e),
916            })?;
917
918        // Normalize path - remove leading slash if present for consistent URL building
919        let normalized_path = path.strip_prefix('/').unwrap_or(path);
920
921        // Build request URL
922        let url = format!("{}/{}", self.config.github_api_url, normalized_path);
923
924        // Make authenticated request with JSON body
925        let response = self
926            .http_client
927            .post(&url)
928            .header("Authorization", format!("Bearer {}", jwt.token()))
929            .header("Accept", "application/vnd.github+json")
930            .json(body)
931            .send()
932            .await
933            .map_err(|e| ApiError::Configuration {
934                message: format!("HTTP request failed: {}", e),
935            })?;
936
937        Ok(response)
938    }
939
940    // Installation-level operations will be implemented in task 5.0
941}
942
943impl std::fmt::Debug for GitHubClient {
944    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
945        f.debug_struct("GitHubClient")
946            .field("config", &self.config)
947            .field("auth", &"<AuthenticationProvider>")
948            .finish()
949    }
950}
951
952/// Builder for constructing `GitHubClient` instances.
953pub struct GitHubClientBuilder {
954    auth: Arc<dyn AuthenticationProvider>,
955    config: Option<ClientConfig>,
956}
957
958impl GitHubClientBuilder {
959    /// Create a new client builder.
960    fn new(auth: impl AuthenticationProvider + 'static) -> Self {
961        Self {
962            auth: Arc::new(auth),
963            config: None,
964        }
965    }
966
967    /// Set the client configuration.
968    ///
969    /// If not set, uses `ClientConfig::default()`.
970    pub fn config(mut self, config: ClientConfig) -> Self {
971        self.config = Some(config);
972        self
973    }
974
975    /// Build the GitHub client.
976    ///
977    /// # Errors
978    ///
979    /// Returns `ApiError::Configuration` if the HTTP client cannot be created.
980    pub fn build(self) -> Result<GitHubClient, ApiError> {
981        let config = self.config.unwrap_or_default();
982
983        // Build reqwest client with timeout and user agent
984        let http_client = reqwest::Client::builder()
985            .timeout(config.timeout)
986            .user_agent(&config.user_agent)
987            .build()
988            .map_err(|e| ApiError::Configuration {
989                message: format!("Failed to create HTTP client: {}", e),
990            })?;
991
992        Ok(GitHubClient {
993            auth: self.auth,
994            http_client,
995            config,
996        })
997    }
998}
999
1000#[cfg(test)]
1001#[path = "mod_tests.rs"]
1002mod tests;