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
277pub use app::App;
278pub use commit::{
279    CommitDetails, CommitReference, Comparison, FileChange, FullCommit, GitSignature, Verification,
280};
281pub use installation::InstallationClient;
282pub use issue::{
283    Comment, CreateCommentRequest, CreateIssueRequest, CreateLabelRequest, Issue, IssueUser, Label,
284    Milestone, SetIssueMilestoneRequest, UpdateCommentRequest, UpdateIssueRequest,
285    UpdateLabelRequest,
286};
287pub use pagination::{extract_page_number, parse_link_header, PagedResponse, Pagination};
288pub use project::{AddProjectV2ItemRequest, ProjectOwner, ProjectV2, ProjectV2Item};
289pub use pull_request::{
290    CreatePullRequestCommentRequest, CreatePullRequestRequest, CreateReviewRequest,
291    DismissReviewRequest, MergePullRequestRequest, MergeResult, PullRequest, PullRequestBranch,
292    PullRequestComment, PullRequestRepo, Review, SetPullRequestMilestoneRequest,
293    UpdatePullRequestRequest, UpdateReviewRequest,
294};
295pub use rate_limit::{parse_rate_limit_from_headers, RateLimit, RateLimitContext, RateLimiter};
296pub use release::{CreateReleaseRequest, Release, ReleaseAsset, UpdateReleaseRequest};
297pub use repository::{Branch, Commit, GitRef, OwnerType, Repository, RepositoryOwner, Tag};
298pub use retry::{
299    calculate_rate_limit_delay, detect_secondary_rate_limit, parse_retry_after, RateLimitInfo,
300    RetryPolicy,
301};
302pub use workflow::{TriggerWorkflowRequest, Workflow, WorkflowRun};
303
304/// Configuration for GitHub API client behavior.
305///
306/// Controls timeouts, retry behavior, rate limiting, and API endpoints.
307///
308/// # Examples
309///
310/// ```
311/// use github_bot_sdk::client::ClientConfig;
312/// use std::time::Duration;
313///
314/// let config = ClientConfig::default()
315///     .with_timeout(Duration::from_secs(60))
316///     .with_max_retries(5);
317/// ```
318#[derive(Debug, Clone)]
319pub struct ClientConfig {
320    /// User agent string for API requests (required by GitHub)
321    pub user_agent: String,
322    /// Request timeout duration
323    pub timeout: Duration,
324    /// Maximum number of retry attempts for transient failures
325    pub max_retries: u32,
326    /// Base delay for exponential backoff retries
327    pub initial_retry_delay: Duration,
328    /// Maximum delay between retries
329    pub max_retry_delay: Duration,
330    /// Rate limit safety margin (0.0 to 1.0) - buffer before hitting limits
331    pub rate_limit_margin: f64,
332    /// GitHub API base URL
333    pub github_api_url: String,
334}
335
336impl Default for ClientConfig {
337    fn default() -> Self {
338        Self {
339            user_agent: "github-bot-sdk/0.1.0".to_string(),
340            timeout: Duration::from_secs(30),
341            max_retries: 3,
342            initial_retry_delay: Duration::from_millis(100),
343            max_retry_delay: Duration::from_secs(60),
344            rate_limit_margin: 0.1, // Keep 10% buffer
345            github_api_url: "https://api.github.com".to_string(),
346        }
347    }
348}
349
350impl ClientConfig {
351    /// Create a new builder for client configuration.
352    pub fn builder() -> ClientConfigBuilder {
353        ClientConfigBuilder::new()
354    }
355
356    /// Set the user agent string.
357    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
358        self.user_agent = user_agent.into();
359        self
360    }
361
362    /// Set the request timeout.
363    pub fn with_timeout(mut self, timeout: Duration) -> Self {
364        self.timeout = timeout;
365        self
366    }
367
368    /// Set the maximum number of retries.
369    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
370        self.max_retries = max_retries;
371        self
372    }
373
374    /// Set the rate limit safety margin.
375    pub fn with_rate_limit_margin(mut self, margin: f64) -> Self {
376        self.rate_limit_margin = margin.clamp(0.0, 1.0);
377        self
378    }
379
380    /// Set the GitHub API base URL.
381    pub fn with_github_api_url(mut self, url: impl Into<String>) -> Self {
382        self.github_api_url = url.into();
383        self
384    }
385}
386
387/// Builder for constructing `ClientConfig` instances.
388#[derive(Debug)]
389pub struct ClientConfigBuilder {
390    config: ClientConfig,
391}
392
393impl ClientConfigBuilder {
394    /// Create a new configuration builder with defaults.
395    pub fn new() -> Self {
396        Self {
397            config: ClientConfig::default(),
398        }
399    }
400
401    /// Set the user agent string.
402    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
403        self.config.user_agent = user_agent.into();
404        self
405    }
406
407    /// Set the request timeout.
408    pub fn timeout(mut self, timeout: Duration) -> Self {
409        self.config.timeout = timeout;
410        self
411    }
412
413    /// Set the maximum number of retries.
414    pub fn max_retries(mut self, max_retries: u32) -> Self {
415        self.config.max_retries = max_retries;
416        self
417    }
418
419    /// Set the rate limit safety margin.
420    pub fn rate_limit_margin(mut self, margin: f64) -> Self {
421        self.config.rate_limit_margin = margin.clamp(0.0, 1.0);
422        self
423    }
424
425    /// Set the GitHub API base URL.
426    pub fn github_api_url(mut self, url: impl Into<String>) -> Self {
427        self.config.github_api_url = url.into();
428        self
429    }
430
431    /// Build the final configuration.
432    pub fn build(self) -> ClientConfig {
433        self.config
434    }
435}
436
437impl Default for ClientConfigBuilder {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443/// GitHub API client for authenticated operations.
444///
445/// The main client for interacting with GitHub's REST API. Handles authentication,
446/// rate limiting, retries, and provides both app-level and installation-level operations.
447///
448/// # Examples
449///
450/// ```no_run
451/// # use github_bot_sdk::client::{GitHubClient, ClientConfig};
452/// # use github_bot_sdk::auth::AuthenticationProvider;
453/// # async fn example(auth: impl AuthenticationProvider + 'static) -> Result<(), Box<dyn std::error::Error>> {
454/// let client = GitHubClient::builder(auth)
455///     .config(ClientConfig::default())
456///     .build()?;
457///
458/// // Get app information
459/// let app = client.get_app().await?;
460/// println!("App: {}", app.name);
461/// # Ok(())
462/// # }
463/// ```
464#[derive(Clone)]
465pub struct GitHubClient {
466    auth: Arc<dyn AuthenticationProvider>,
467    http_client: reqwest::Client,
468    config: ClientConfig,
469}
470
471impl GitHubClient {
472    /// Create a new builder for constructing a GitHub client.
473    ///
474    /// # Arguments
475    ///
476    /// * `auth` - Authentication provider for obtaining tokens
477    ///
478    /// # Examples
479    ///
480    /// ```no_run
481    /// # use github_bot_sdk::client::GitHubClient;
482    /// # use github_bot_sdk::auth::AuthenticationProvider;
483    /// # async fn example(auth: impl AuthenticationProvider + 'static) {
484    /// let client = GitHubClient::builder(auth).build().unwrap();
485    /// # }
486    /// ```
487    pub fn builder(auth: impl AuthenticationProvider + 'static) -> GitHubClientBuilder {
488        GitHubClientBuilder::new(auth)
489    }
490
491    /// Get the client configuration.
492    pub fn config(&self) -> &ClientConfig {
493        &self.config
494    }
495
496    /// Get the authentication provider.
497    pub fn auth_provider(&self) -> &dyn AuthenticationProvider {
498        self.auth.as_ref()
499    }
500
501    /// Get the HTTP client (internal use by InstallationClient).
502    pub(crate) fn http_client(&self) -> &reqwest::Client {
503        &self.http_client
504    }
505
506    // ========================================================================
507    // App-Level Operations (authenticated with JWT)
508    // ========================================================================
509
510    /// Get details about the authenticated GitHub App.
511    ///
512    /// Fetches metadata about the app including ID, name, owner, and permissions.
513    ///
514    /// # Authentication
515    ///
516    /// Requires app-level JWT authentication.
517    ///
518    /// # Examples
519    ///
520    /// ```no_run
521    /// # use github_bot_sdk::client::GitHubClient;
522    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
523    /// let app = client.get_app().await?;
524    /// println!("App: {} (ID: {})", app.name, app.id);
525    /// # Ok(())
526    /// # }
527    /// ```
528    ///
529    /// # Errors
530    ///
531    /// Returns `ApiError` if:
532    /// - JWT generation fails
533    /// - HTTP request fails
534    /// - Response cannot be parsed
535    pub async fn get_app(&self) -> Result<App, ApiError> {
536        // Get JWT token from auth provider
537        let jwt = self
538            .auth
539            .app_token()
540            .await
541            .map_err(|e| ApiError::TokenGenerationFailed {
542                message: format!("Failed to generate JWT: {}", e),
543            })?;
544
545        // Build request URL
546        let url = format!("{}/app", self.config.github_api_url);
547
548        // Make authenticated request
549        let response = self
550            .http_client
551            .get(&url)
552            .header("Authorization", format!("Bearer {}", jwt.token()))
553            .header("Accept", "application/vnd.github+json")
554            .send()
555            .await
556            .map_err(|e| ApiError::Configuration {
557                message: format!("HTTP request failed: {}", e),
558            })?;
559
560        // Check for errors
561        if !response.status().is_success() {
562            let status = response.status();
563            let error_text = response
564                .text()
565                .await
566                .unwrap_or_else(|_| "Unable to read error body".to_string());
567            return Err(ApiError::Configuration {
568                message: format!("API request failed with status {}: {}", status, error_text),
569            });
570        }
571
572        // Parse response
573        let app = response
574            .json::<App>()
575            .await
576            .map_err(|e| ApiError::Configuration {
577                message: format!("Failed to parse App response: {}", e),
578            })?;
579
580        Ok(app)
581    }
582
583    /// List all installations for the authenticated GitHub App.
584    ///
585    /// Fetches all installations where this app is installed, including organizations
586    /// and user accounts.
587    ///
588    /// # Authentication
589    ///
590    /// Requires app-level JWT authentication.
591    ///
592    /// # Examples
593    ///
594    /// ```no_run
595    /// # use github_bot_sdk::client::GitHubClient;
596    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
597    /// let installations = client.list_installations().await?;
598    /// for installation in installations {
599    ///     println!("Installation ID: {} for {}", installation.id.as_u64(), installation.account.login);
600    /// }
601    /// # Ok(())
602    /// # }
603    /// ```
604    ///
605    /// # Errors
606    ///
607    /// Returns `ApiError` if:
608    /// - JWT generation fails
609    /// - HTTP request fails
610    /// - Response cannot be parsed
611    pub async fn list_installations(&self) -> Result<Vec<Installation>, ApiError> {
612        // Get JWT token from auth provider
613        let jwt = self
614            .auth
615            .app_token()
616            .await
617            .map_err(|e| ApiError::TokenGenerationFailed {
618                message: format!("Failed to generate JWT: {}", e),
619            })?;
620
621        // Build request URL
622        let url = format!("{}/app/installations", self.config.github_api_url);
623
624        // Make authenticated request
625        let response = self
626            .http_client
627            .get(&url)
628            .header("Authorization", format!("Bearer {}", jwt.token()))
629            .header("Accept", "application/vnd.github+json")
630            .send()
631            .await
632            .map_err(|e| ApiError::Configuration {
633                message: format!("HTTP request failed: {}", e),
634            })?;
635
636        // Check for errors
637        if !response.status().is_success() {
638            let status = response.status();
639            let error_text = response
640                .text()
641                .await
642                .unwrap_or_else(|_| "Unable to read error body".to_string());
643            return Err(ApiError::Configuration {
644                message: format!("API request failed with status {}: {}", status, error_text),
645            });
646        }
647
648        // Parse response
649        let installations =
650            response
651                .json::<Vec<Installation>>()
652                .await
653                .map_err(|e| ApiError::Configuration {
654                    message: format!("Failed to parse installations response: {}", e),
655                })?;
656
657        Ok(installations)
658    }
659
660    /// Get a specific installation by ID.
661    ///
662    /// Fetches detailed information about a specific installation of this GitHub App.
663    ///
664    /// # Authentication
665    ///
666    /// Requires app-level JWT authentication.
667    ///
668    /// # Arguments
669    ///
670    /// * `installation_id` - The unique identifier for the installation
671    ///
672    /// # Examples
673    ///
674    /// ```no_run
675    /// # use github_bot_sdk::client::GitHubClient;
676    /// # use github_bot_sdk::auth::InstallationId;
677    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
678    /// let installation_id = InstallationId::new(12345);
679    /// let installation = client.get_installation(installation_id).await?;
680    /// println!("Installation for: {}", installation.account.login);
681    /// # Ok(())
682    /// # }
683    /// ```
684    ///
685    /// # Errors
686    ///
687    /// Returns `ApiError` if:
688    /// - JWT generation fails
689    /// - HTTP request fails
690    /// - Installation not found (404)
691    /// - Response cannot be parsed
692    pub async fn get_installation(
693        &self,
694        installation_id: InstallationId,
695    ) -> Result<Installation, ApiError> {
696        // Get JWT token from auth provider
697        let jwt = self
698            .auth
699            .app_token()
700            .await
701            .map_err(|e| ApiError::TokenGenerationFailed {
702                message: format!("Failed to generate JWT: {}", e),
703            })?;
704
705        // Build request URL
706        let url = format!(
707            "{}/app/installations/{}",
708            self.config.github_api_url,
709            installation_id.as_u64()
710        );
711
712        // Make authenticated request
713        let response = self
714            .http_client
715            .get(&url)
716            .header("Authorization", format!("Bearer {}", jwt.token()))
717            .header("Accept", "application/vnd.github+json")
718            .send()
719            .await
720            .map_err(|e| ApiError::Configuration {
721                message: format!("HTTP request failed: {}", e),
722            })?;
723
724        // Check for errors
725        if !response.status().is_success() {
726            let status = response.status();
727            let error_text = response
728                .text()
729                .await
730                .unwrap_or_else(|_| "Unable to read error body".to_string());
731            return Err(ApiError::Configuration {
732                message: format!("API request failed with status {}: {}", status, error_text),
733            });
734        }
735
736        // Parse response
737        let installation =
738            response
739                .json::<Installation>()
740                .await
741                .map_err(|e| ApiError::Configuration {
742                    message: format!("Failed to parse installation response: {}", e),
743                })?;
744
745        Ok(installation)
746    }
747
748    /// Make a raw authenticated GET request as the GitHub App.
749    ///
750    /// This is a generic method for making custom API requests that aren't covered
751    /// by the specific methods. Returns the raw response for flexible handling by the caller.
752    ///
753    /// # Authentication
754    ///
755    /// Requires app-level JWT authentication.
756    ///
757    /// # Arguments
758    ///
759    /// * `path` - The API path (e.g., "/app/installations" or "app/installations")
760    ///
761    /// # Examples
762    ///
763    /// ```no_run
764    /// # use github_bot_sdk::client::GitHubClient;
765    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
766    /// // Make a custom GET request
767    /// let response = client.get_as_app("/app/hook/config").await?;
768    ///
769    /// if response.status().is_success() {
770    ///     let data: serde_json::Value = response.json().await?;
771    ///     println!("Response: {:?}", data);
772    /// }
773    /// # Ok(())
774    /// # }
775    /// ```
776    ///
777    /// # Errors
778    ///
779    /// Returns `ApiError` if:
780    /// - JWT generation fails
781    /// - HTTP request fails (network error, timeout, etc.)
782    ///
783    /// Note: Does NOT return an error for non-2xx status codes. The caller is responsible
784    /// for checking the response status.
785    pub async fn get_as_app(&self, path: &str) -> Result<reqwest::Response, ApiError> {
786        // Get JWT token from auth provider
787        let jwt = self
788            .auth
789            .app_token()
790            .await
791            .map_err(|e| ApiError::TokenGenerationFailed {
792                message: format!("Failed to generate JWT: {}", e),
793            })?;
794
795        // Normalize path - remove leading slash if present for consistent URL building
796        let normalized_path = path.strip_prefix('/').unwrap_or(path);
797
798        // Build request URL
799        let url = format!("{}/{}", self.config.github_api_url, normalized_path);
800
801        // Make authenticated request
802        let response = self
803            .http_client
804            .get(&url)
805            .header("Authorization", format!("Bearer {}", jwt.token()))
806            .header("Accept", "application/vnd.github+json")
807            .send()
808            .await
809            .map_err(|e| ApiError::Configuration {
810                message: format!("HTTP request failed: {}", e),
811            })?;
812
813        Ok(response)
814    }
815
816    /// Make a raw authenticated POST request as the GitHub App.
817    ///
818    /// This is a generic method for making custom API requests that aren't covered
819    /// by the specific methods. Returns the raw response for flexible handling by the caller.
820    ///
821    /// # Authentication
822    ///
823    /// Requires app-level JWT authentication.
824    ///
825    /// # Arguments
826    ///
827    /// * `path` - The API path (e.g., "/app/installations/{id}/suspended")
828    /// * `body` - The request body to serialize as JSON
829    ///
830    /// # Examples
831    ///
832    /// ```no_run
833    /// # use github_bot_sdk::client::GitHubClient;
834    /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
835    /// // Make a custom POST request
836    /// let body = serde_json::json!({"reason": "Violation of terms"});
837    /// let response = client.post_as_app("/app/installations/123/suspended", &body).await?;
838    ///
839    /// if response.status().is_success() {
840    ///     println!("Installation suspended");
841    /// }
842    /// # Ok(())
843    /// # }
844    /// ```
845    ///
846    /// # Errors
847    ///
848    /// Returns `ApiError` if:
849    /// - JWT generation fails
850    /// - Body serialization fails
851    /// - HTTP request fails (network error, timeout, etc.)
852    ///
853    /// Note: Does NOT return an error for non-2xx status codes. The caller is responsible
854    /// for checking the response status.
855    pub async fn post_as_app(
856        &self,
857        path: &str,
858        body: &impl serde::Serialize,
859    ) -> Result<reqwest::Response, ApiError> {
860        // Get JWT token from auth provider
861        let jwt = self
862            .auth
863            .app_token()
864            .await
865            .map_err(|e| ApiError::TokenGenerationFailed {
866                message: format!("Failed to generate JWT: {}", e),
867            })?;
868
869        // Normalize path - remove leading slash if present for consistent URL building
870        let normalized_path = path.strip_prefix('/').unwrap_or(path);
871
872        // Build request URL
873        let url = format!("{}/{}", self.config.github_api_url, normalized_path);
874
875        // Make authenticated request with JSON body
876        let response = self
877            .http_client
878            .post(&url)
879            .header("Authorization", format!("Bearer {}", jwt.token()))
880            .header("Accept", "application/vnd.github+json")
881            .json(body)
882            .send()
883            .await
884            .map_err(|e| ApiError::Configuration {
885                message: format!("HTTP request failed: {}", e),
886            })?;
887
888        Ok(response)
889    }
890
891    // Installation-level operations will be implemented in task 5.0
892}
893
894impl std::fmt::Debug for GitHubClient {
895    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
896        f.debug_struct("GitHubClient")
897            .field("config", &self.config)
898            .field("auth", &"<AuthenticationProvider>")
899            .finish()
900    }
901}
902
903/// Builder for constructing `GitHubClient` instances.
904pub struct GitHubClientBuilder {
905    auth: Arc<dyn AuthenticationProvider>,
906    config: Option<ClientConfig>,
907}
908
909impl GitHubClientBuilder {
910    /// Create a new client builder.
911    fn new(auth: impl AuthenticationProvider + 'static) -> Self {
912        Self {
913            auth: Arc::new(auth),
914            config: None,
915        }
916    }
917
918    /// Set the client configuration.
919    ///
920    /// If not set, uses `ClientConfig::default()`.
921    pub fn config(mut self, config: ClientConfig) -> Self {
922        self.config = Some(config);
923        self
924    }
925
926    /// Build the GitHub client.
927    ///
928    /// # Errors
929    ///
930    /// Returns `ApiError::Configuration` if the HTTP client cannot be created.
931    pub fn build(self) -> Result<GitHubClient, ApiError> {
932        let config = self.config.unwrap_or_default();
933
934        // Build reqwest client with timeout and user agent
935        let http_client = reqwest::Client::builder()
936            .timeout(config.timeout)
937            .user_agent(&config.user_agent)
938            .build()
939            .map_err(|e| ApiError::Configuration {
940                message: format!("Failed to create HTTP client: {}", e),
941            })?;
942
943        Ok(GitHubClient {
944            auth: self.auth,
945            http_client,
946            config,
947        })
948    }
949}
950
951#[cfg(test)]
952#[path = "mod_tests.rs"]
953mod tests;