Skip to main content

github_bot_sdk/client/
installation.rs

1//! Installation Client Types and Operations
2//!
3//! **Specification**: `docs/spec/interfaces/installation-client.md`
4//!
5//! This module provides installation-scoped access to GitHub API operations.
6//! The `InstallationClient` is bound to a specific installation ID and uses
7//! installation tokens (not JWTs) for authentication.
8
9use crate::{
10    auth::InstallationId,
11    client::{
12        calculate_rate_limit_delay, detect_secondary_rate_limit, extract_page_number,
13        parse_link_header, GitHubClient,
14    },
15    error::ApiError,
16};
17use std::future::Future;
18use std::sync::Arc;
19
20#[cfg(test)]
21#[path = "installation_tests.rs"]
22mod tests;
23
24/// Calculate exponential backoff delay with optional jitter.
25///
26/// # Arguments
27///
28/// * `attempt` - Current retry attempt (0-indexed)
29/// * `initial_delay` - Initial delay for first retry
30/// * `max_delay` - Maximum delay cap
31/// * `multiplier` - Backoff multiplier (typically 2.0)
32/// * `use_jitter` - Whether to add random jitter (±25%)
33fn calculate_exponential_backoff(
34    attempt: u32,
35    initial_delay: std::time::Duration,
36    max_delay: std::time::Duration,
37    multiplier: f64,
38    use_jitter: bool,
39) -> std::time::Duration {
40    if attempt == 0 {
41        return initial_delay;
42    }
43
44    // Calculate exponential backoff
45    let exp_multiplier = multiplier.powi(attempt as i32);
46    let delay_ms = (initial_delay.as_millis() as f64 * exp_multiplier) as u64;
47    let mut delay = std::time::Duration::from_millis(delay_ms);
48
49    // Cap at max delay
50    if delay > max_delay {
51        delay = max_delay;
52    }
53
54    // Add jitter if requested (±25% randomization)
55    if use_jitter {
56        use rand::RngExt;
57        let mut rng = rand::rng();
58        let jitter_factor = rng.random_range(0.75..=1.25);
59        delay = std::time::Duration::from_millis((delay.as_millis() as f64 * jitter_factor) as u64);
60    }
61
62    delay
63}
64
65/// Installation-scoped GitHub API client.
66///
67/// Holds a reference to the parent `GitHubClient` for shared HTTP client,
68/// auth provider, and rate limiter. All operations use installation tokens.
69#[derive(Debug, Clone)]
70pub struct InstallationClient {
71    /// Parent GitHub client (shared HTTP client, auth provider, rate limiter)
72    client: Arc<GitHubClient>,
73    /// Installation ID this client is bound to
74    installation_id: InstallationId,
75}
76
77impl InstallationClient {
78    /// Create a new installation client.
79    ///
80    /// # Arguments
81    ///
82    /// * `client` - Parent GitHubClient
83    /// * `installation_id` - Installation ID to bind to
84    pub fn new(client: Arc<GitHubClient>, installation_id: InstallationId) -> Self {
85        Self {
86            client,
87            installation_id,
88        }
89    }
90
91    /// Get the installation ID this client is bound to.
92    pub fn installation_id(&self) -> InstallationId {
93        self.installation_id
94    }
95
96    /// Execute an HTTP request with retry logic for transient errors.
97    ///
98    /// This method wraps request execution with:
99    /// - Exponential backoff retry on transient errors (5xx, 429, network failures)
100    /// - Retry-After header parsing for 429 responses
101    /// - Secondary rate limit detection for 403 responses
102    /// - Maximum retry limit from client configuration
103    ///
104    /// # Arguments
105    ///
106    /// * `operation_name` - Name of the operation for logging/debugging
107    /// * `request_fn` - Async function that executes the HTTP request
108    ///
109    /// # Returns
110    ///
111    /// Returns the successful response or the last error after exhausting retries.
112    ///
113    /// # Errors
114    ///
115    /// Returns `ApiError` if:
116    /// - All retry attempts fail
117    /// - A non-retryable error occurs (4xx except 429)
118    async fn execute_with_retry<F, Fut>(
119        &self,
120        operation_name: &str,
121        request_fn: F,
122    ) -> Result<reqwest::Response, ApiError>
123    where
124        F: Fn() -> Fut,
125        Fut: Future<Output = Result<reqwest::Response, ApiError>>,
126    {
127        let max_retries = self.client.config().max_retries;
128        let initial_delay = self.client.config().initial_retry_delay;
129        let max_delay = self.client.config().max_retry_delay;
130        let backoff_multiplier = 2.0;
131
132        let mut last_error: Option<ApiError> = None;
133
134        for attempt in 0..=max_retries {
135            // Execute the request
136            match request_fn().await {
137                Ok(response) => {
138                    let status = response.status().as_u16();
139
140                    // Check for rate limit (429)
141                    if status == 429 {
142                        if attempt < max_retries {
143                            let retry_after = response
144                                .headers()
145                                .get("Retry-After")
146                                .and_then(|v| v.to_str().ok());
147                            let rate_limit_reset = response
148                                .headers()
149                                .get("X-RateLimit-Reset")
150                                .and_then(|v| v.to_str().ok());
151
152                            let delay = calculate_rate_limit_delay(retry_after, rate_limit_reset);
153                            tokio::time::sleep(delay).await;
154                            continue;
155                        } else {
156                            return Err(ApiError::HttpError {
157                                status,
158                                message: "Rate limit exceeded after maximum retries".to_string(),
159                            });
160                        }
161                    }
162
163                    // Check for secondary rate limit (403 with abuse detection)
164                    if status == 403 {
165                        let body = response.text().await.unwrap_or_default();
166
167                        if detect_secondary_rate_limit(status, &body) {
168                            if attempt < max_retries {
169                                // Secondary rate limits require longer backoff
170                                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
171                                continue;
172                            } else {
173                                return Err(ApiError::SecondaryRateLimit);
174                            }
175                        } else {
176                            // Not a rate limit, it's a permission error - don't retry
177                            return Err(ApiError::AuthorizationFailed);
178                        }
179                    }
180
181                    // Check for server errors (5xx)
182                    if status >= 500 {
183                        if attempt < max_retries {
184                            let delay = calculate_exponential_backoff(
185                                attempt,
186                                initial_delay,
187                                max_delay,
188                                backoff_multiplier,
189                                true, // with jitter
190                            );
191                            tokio::time::sleep(delay).await;
192                            continue;
193                        } else {
194                            let body = response.text().await.unwrap_or_default();
195                            return Err(ApiError::HttpError {
196                                status,
197                                message: body,
198                            });
199                        }
200                    }
201
202                    // Check for other client errors (4xx except 429 and 403 which are handled above)
203                    // These are non-retryable - map to appropriate error types
204                    if (400..500).contains(&status) {
205                        let status_code = reqwest::StatusCode::from_u16(status)
206                            .unwrap_or(reqwest::StatusCode::BAD_REQUEST);
207                        return Err(super::map_http_error(status_code, response).await);
208                    }
209
210                    // Success (2xx or 3xx)
211                    return Ok(response);
212                }
213                Err(e) => {
214                    // Check if the error is transient
215                    if e.is_transient() && attempt < max_retries {
216                        last_error = Some(e);
217                        let delay = calculate_exponential_backoff(
218                            attempt,
219                            initial_delay,
220                            max_delay,
221                            backoff_multiplier,
222                            true, // with jitter
223                        );
224                        tokio::time::sleep(delay).await;
225                        continue;
226                    } else {
227                        // Non-retryable error or max retries exhausted
228                        return Err(e);
229                    }
230                }
231            }
232        }
233
234        // This should never be reached, but return the last error if it happens
235        Err(last_error.unwrap_or_else(|| ApiError::HttpError {
236            status: 500,
237            message: format!(
238                "Max retries ({}) exhausted for {}",
239                max_retries, operation_name
240            ),
241        }))
242    }
243
244    /// Prepare a request with installation token authentication and normalized path.
245    ///
246    /// This helper extracts common logic for token retrieval, path normalization,
247    /// and URL construction used by all HTTP methods.
248    ///
249    /// # Arguments
250    ///
251    /// * `path` - API path (leading slash optional)
252    /// * `method` - HTTP method name for error messages
253    ///
254    /// # Returns
255    ///
256    /// Returns `(token, url)` tuple with the installation token and complete URL.
257    ///
258    /// # Errors
259    ///
260    /// Returns `ApiError::TokenGenerationFailed` if token retrieval fails.
261    async fn prepare_request(
262        &self,
263        path: &str,
264        method: &str,
265    ) -> Result<(String, String), ApiError> {
266        // Get installation token from auth provider
267        let token = self
268            .client
269            .auth_provider()
270            .installation_token(self.installation_id)
271            .await
272            .map_err(|e| ApiError::TokenGenerationFailed {
273                message: format!(
274                    "{} request to {}: failed to get installation token: {}",
275                    method, path, e
276                ),
277            })?;
278
279        // Normalize path - remove leading slash if present
280        let normalized_path = path.strip_prefix('/').unwrap_or(path);
281
282        // Build request URL
283        let url = format!(
284            "{}/{}",
285            self.client.config().github_api_url,
286            normalized_path
287        );
288
289        Ok((token.token().to_string(), url))
290    }
291
292    /// Make an authenticated GET request to the GitHub API.
293    ///
294    /// Uses installation token for authentication and includes automatic retry logic
295    /// for transient errors (5xx, 429, network failures).
296    ///
297    /// # Arguments
298    ///
299    /// * `path` - API path (e.g., "/repos/owner/repo" or "repos/owner/repo")
300    ///
301    /// # Returns
302    ///
303    /// Returns the raw `reqwest::Response` for flexible handling.
304    ///
305    /// # Errors
306    ///
307    /// Returns `ApiError` for HTTP errors, authentication failures, or network issues.
308    pub async fn get(&self, path: &str) -> Result<reqwest::Response, ApiError> {
309        let path = path.to_string();
310        self.execute_with_retry("GET", || async {
311            let (token, url) = self.prepare_request(&path, "GET").await?;
312
313            // Make authenticated request
314            self.client
315                .http_client()
316                .get(&url)
317                .header("Authorization", format!("Bearer {}", token))
318                .header("Accept", "application/vnd.github+json")
319                .send()
320                .await
321                .map_err(ApiError::HttpClientError)
322        })
323        .await
324    }
325
326    /// Make an authenticated POST request to the GitHub API.
327    ///
328    /// Includes automatic retry logic for transient errors.
329    ///
330    /// # Arguments
331    ///
332    /// * `path` - API path
333    /// * `body` - Request body (will be serialized to JSON)
334    ///
335    /// # Errors
336    ///
337    /// Returns `ApiError` for HTTP errors, serialization failures, or network issues.
338    pub async fn post<T: serde::Serialize>(
339        &self,
340        path: &str,
341        body: &T,
342    ) -> Result<reqwest::Response, ApiError> {
343        let path = path.to_string();
344        let body_json = serde_json::to_value(body).map_err(ApiError::JsonError)?;
345
346        self.execute_with_retry("POST", || async {
347            let (token, url) = self.prepare_request(&path, "POST").await?;
348
349            // Make authenticated request with JSON body
350            self.client
351                .http_client()
352                .post(&url)
353                .header("Authorization", format!("Bearer {}", token))
354                .header("Accept", "application/vnd.github+json")
355                .json(&body_json)
356                .send()
357                .await
358                .map_err(ApiError::HttpClientError)
359        })
360        .await
361    }
362
363    /// Make an authenticated PUT request to the GitHub API.
364    ///
365    /// Includes automatic retry logic for transient errors.
366    pub async fn put<T: serde::Serialize>(
367        &self,
368        path: &str,
369        body: &T,
370    ) -> Result<reqwest::Response, ApiError> {
371        let path = path.to_string();
372        let body_json = serde_json::to_value(body).map_err(ApiError::JsonError)?;
373
374        self.execute_with_retry("PUT", || async {
375            let (token, url) = self.prepare_request(&path, "PUT").await?;
376
377            // Make authenticated request with JSON body
378            self.client
379                .http_client()
380                .put(&url)
381                .header("Authorization", format!("Bearer {}", token))
382                .header("Accept", "application/vnd.github+json")
383                .json(&body_json)
384                .send()
385                .await
386                .map_err(ApiError::HttpClientError)
387        })
388        .await
389    }
390
391    /// Make an authenticated DELETE request to the GitHub API.
392    ///
393    /// Includes automatic retry logic for transient errors.
394    pub async fn delete(&self, path: &str) -> Result<reqwest::Response, ApiError> {
395        let path = path.to_string();
396        self.execute_with_retry("DELETE", || async {
397            let (token, url) = self.prepare_request(&path, "DELETE").await?;
398
399            // Make authenticated request
400            self.client
401                .http_client()
402                .delete(&url)
403                .header("Authorization", format!("Bearer {}", token))
404                .header("Accept", "application/vnd.github+json")
405                .send()
406                .await
407                .map_err(ApiError::HttpClientError)
408        })
409        .await
410    }
411
412    /// Make an authenticated POST request to the GitHub GraphQL API.
413    ///
414    /// GitHub's GraphQL endpoint always returns HTTP 200 even for errors.
415    /// Application-level errors are detected by checking the `.errors`
416    /// array in the response body; the first error message is surfaced as
417    /// `ApiError::GraphQlError`. On success, the `.data` value is returned.
418    ///
419    /// Retry logic (backoff, 5xx, rate-limit handling) is identical to the
420    /// REST helpers because the transport layer is the same.
421    ///
422    /// # Arguments
423    ///
424    /// * `query`     - GraphQL query or mutation string
425    /// * `variables` - Variables object (serialised to JSON)
426    ///
427    /// # Returns
428    ///
429    /// Returns the `data` field from the GraphQL response as a `serde_json::Value`.
430    ///
431    /// # Errors
432    ///
433    /// - `ApiError::GraphQlError` — GitHub returned `.errors` in the body
434    /// - `ApiError::GraphQlError` — response body contains neither `.errors` nor a
435    ///   non-null `.data` field (malformed or unexpected response shape)
436    /// - `ApiError::AuthenticationFailed` — HTTP 401
437    /// - `ApiError::JsonError` — response body could not be parsed
438    /// - Any other `ApiError` variant for HTTP-level failures
439    pub async fn post_graphql<V: serde::Serialize>(
440        &self,
441        query: &str,
442        variables: V,
443    ) -> Result<serde_json::Value, ApiError> {
444        // json! already returns Value; no outer to_value needed.
445        let body_json = serde_json::json!({ "query": query, "variables": variables });
446
447        let response = self
448            .execute_with_retry("POST graphql", || async {
449                let (token, url) = self.prepare_request("graphql", "POST").await?;
450                self.client
451                    .http_client()
452                    .post(&url)
453                    .header("Authorization", format!("Bearer {}", token))
454                    .header("Accept", "application/vnd.github+json")
455                    .json(&body_json)
456                    .send()
457                    .await
458                    .map_err(ApiError::HttpClientError)
459            })
460            .await?;
461
462        let payload: serde_json::Value =
463            response.json().await.map_err(ApiError::HttpClientError)?;
464
465        // GraphQL returns HTTP 200 even for errors; check .errors[] first.
466        if let Some(errors) = payload.get("errors").and_then(|e| e.as_array()) {
467            // NOT_FOUND type maps directly to ApiError::NotFound.
468            if errors
469                .iter()
470                .any(|e| e.get("type").and_then(|t| t.as_str()) == Some("NOT_FOUND"))
471            {
472                return Err(ApiError::NotFound);
473            }
474            if let Some(first_message) = errors
475                .first()
476                .and_then(|e| e.get("message"))
477                .and_then(|m| m.as_str())
478            {
479                return Err(ApiError::GraphQlError {
480                    message: first_message.to_string(),
481                });
482            }
483            // Errors present but neither NOT_FOUND type nor a parseable message field
484            // (e.g. {"type": "FORBIDDEN"} with no message). Surface a generic error
485            // rather than silently falling through to the data check.
486            return Err(ApiError::GraphQlError {
487                message: "GraphQL response contained errors".to_string(),
488            });
489        }
490
491        if payload.get("data").is_none_or(|v| v.is_null()) {
492            return Err(ApiError::GraphQlError {
493                message: "GraphQL response missing `data` field".to_string(),
494            });
495        }
496
497        Ok(payload["data"].clone())
498    }
499
500    /// Make an authenticated PATCH request to the GitHub API.
501    ///
502    /// Includes automatic retry logic for transient errors.
503    pub async fn patch<T: serde::Serialize>(
504        &self,
505        path: &str,
506        body: &T,
507    ) -> Result<reqwest::Response, ApiError> {
508        let path = path.to_string();
509        let body_json = serde_json::to_value(body).map_err(ApiError::JsonError)?;
510
511        self.execute_with_retry("PATCH", || async {
512            let (token, url) = self.prepare_request(&path, "PATCH").await?;
513
514            // Make authenticated request with JSON body
515            self.client
516                .http_client()
517                .patch(&url)
518                .header("Authorization", format!("Bearer {}", token))
519                .header("Accept", "application/vnd.github+json")
520                .json(&body_json)
521                .send()
522                .await
523                .map_err(ApiError::HttpClientError)
524        })
525        .await
526    }
527
528    /// Make an authenticated DELETE request with a JSON body to the GitHub API.
529    ///
530    /// Used for endpoints that require a body on DELETE (e.g. remove assignees).
531    pub async fn delete_with_body<T: serde::Serialize>(
532        &self,
533        path: &str,
534        body: &T,
535    ) -> Result<reqwest::Response, ApiError> {
536        let path = path.to_string();
537        let body_json = serde_json::to_value(body).map_err(ApiError::JsonError)?;
538
539        self.execute_with_retry("DELETE", || async {
540            let (token, url) = self.prepare_request(&path, "DELETE").await?;
541
542            self.client
543                .http_client()
544                .delete(&url)
545                .header("Authorization", format!("Bearer {}", token))
546                .header("Accept", "application/vnd.github+json")
547                .json(&body_json)
548                .send()
549                .await
550                .map_err(ApiError::HttpClientError)
551        })
552        .await
553    }
554}
555
556impl InstallationClient {
557    /// Auto-paginate a GET endpoint that returns `Vec<T>`.
558    ///
559    /// Drives pagination using the `Link: rel="next"` header (ADR-002).
560    /// `first_page_path` must be the full API path for the first request,
561    /// including any query parameters (e.g. `?per_page=100&state=open`).
562    /// Subsequent pages append `&page=N` to `first_page_path`.
563    pub(crate) async fn fetch_all_pages<T: serde::de::DeserializeOwned>(
564        &self,
565        first_page_path: &str,
566    ) -> Result<Vec<T>, ApiError> {
567        let mut all_items: Vec<T> = Vec::new();
568        let mut path = first_page_path.to_string();
569
570        loop {
571            let response = self.get(&path).await?;
572            let status = response.status();
573            if !status.is_success() {
574                return Err(super::map_http_error(status, response).await);
575            }
576
577            let next_page: Option<u32> = response
578                .headers()
579                .get("Link")
580                .and_then(|h| h.to_str().ok())
581                .and_then(|h| parse_link_header(Some(h)).next)
582                .as_deref()
583                .and_then(extract_page_number);
584
585            let items: Vec<T> = response.json().await.map_err(ApiError::from)?;
586            all_items.extend(items);
587
588            match next_page {
589                Some(page) => path = format!("{}&page={}", first_page_path, page),
590                None => break,
591            }
592        }
593
594        Ok(all_items)
595    }
596
597    /// Access issue-domain operations.
598    ///
599    /// See docs/specs/interfaces/issue-operations.md; ADR-003.
600    pub fn issues(&self) -> crate::client::issue::IssuesClient {
601        crate::client::issue::IssuesClient::new(self.clone())
602    }
603
604    /// Access pull request operations, reviews, and inline comments.
605    ///
606    /// See docs/specs/interfaces/pull-request-operations.md; ADR-003.
607    pub fn pull_requests(&self) -> crate::client::pull_request::PullRequestsClient {
608        crate::client::pull_request::PullRequestsClient::new(self.clone())
609    }
610
611    /// Access repository-level label catalogue operations.
612    ///
613    /// See docs/specs/interfaces/labels-client.md; ADR-003.
614    pub fn labels(&self) -> crate::client::issue::LabelsClient {
615        crate::client::issue::LabelsClient::new(self.clone())
616    }
617
618    /// Access milestone CRUD operations.
619    ///
620    /// See docs/specs/interfaces/milestones-client.md; ADR-003.
621    pub fn milestones(&self) -> crate::client::issue::MilestonesClient {
622        crate::client::issue::MilestonesClient::new(self.clone())
623    }
624
625    /// Access repository, branch, tag, git-ref, and commit operations.
626    ///
627    /// See docs/specs/interfaces/repository-operations.md; ADR-003.
628    pub fn repositories(&self) -> crate::client::repository::RepositoriesClient {
629        crate::client::repository::RepositoriesClient::new(self.clone())
630    }
631
632    /// Access GitHub Actions workflow and run operations.
633    ///
634    /// See docs/specs/interfaces/additional-operations.md; ADR-003.
635    pub fn workflows(&self) -> crate::client::workflow::WorkflowsClient {
636        crate::client::workflow::WorkflowsClient::new(self.clone())
637    }
638
639    /// Access release CRUD operations.
640    ///
641    /// See docs/specs/interfaces/additional-operations.md; ADR-003.
642    pub fn releases(&self) -> crate::client::release::ReleasesClient {
643        crate::client::release::ReleasesClient::new(self.clone())
644    }
645
646    /// Access GitHub Projects V2 operations.
647    ///
648    /// See docs/specs/interfaces/project-operations.md; ADR-003.
649    pub fn projects(&self) -> crate::client::project::ProjectsClient {
650        crate::client::project::ProjectsClient::new(self.clone())
651    }
652}
653
654impl GitHubClient {
655    /// Create an installation-scoped client.
656    ///
657    /// # Arguments
658    ///
659    /// * `installation_id` - The GitHub App installation ID
660    ///
661    /// # Returns
662    ///
663    /// Returns an `InstallationClient` bound to the specified installation.
664    ///
665    /// # Errors
666    ///
667    /// Returns `ApiError` if the installation ID is invalid or inaccessible.
668    pub async fn installation_by_id(
669        &self,
670        installation_id: InstallationId,
671    ) -> Result<InstallationClient, ApiError> {
672        // Create installation client immediately
673        // Token validation will happen on first API call
674        Ok(InstallationClient::new(
675            Arc::new(self.clone()),
676            installation_id,
677        ))
678    }
679}