Skip to main content

anodizer_core/git/
github_probe.rs

1//! Shared GitHub `GET /repos/{owner}/{repo}` reachability + permission probe.
2//!
3//! Both the publish-stage tap/index preflights and the release-stage
4//! github-release preflight need the same network probe: issue the request
5//! under the shallow retry policy, read the rate-limit headers (not just the
6//! status, so a secondary-rate-limit 403 is separable from an auth 403), and
7//! classify the outcome. Only the *severity mapping* of each outcome differs
8//! between callers — a tap that cannot be pushed is a `Warning`, whereas the
9//! required github-release target is a `Blocker` — so this module returns a
10//! neutral [`RepoProbe`] classification and leaves the `PreflightCheck` mapping
11//! to each caller.
12
13use std::ops::ControlFlow;
14
15use crate::retry::{RetryPolicy, is_retriable, retry_sync};
16
17/// Terminal classification of a single `GET /repos/{owner}/{repo}` probe,
18/// carrying enough to distinguish a transient rate-limit 403 from an auth 403.
19pub enum RepoProbe {
20    /// 2xx — carries the response body for `permissions.push` inspection.
21    Body(String),
22    /// 404 — repo missing under an otherwise-good token.
23    Missing,
24    /// 401 / 403 with NO rate-limit signal — the token cannot access the repo.
25    AuthDenied,
26    /// 429, or a 401 / 403 carrying a rate-limit signal (GitHub returns 403 for
27    /// both secondary-rate-limit and auth denial, distinguishable only by the
28    /// `Retry-After` / `X-RateLimit-Remaining: 0` headers) — transient.
29    RateLimited,
30    /// 5xx, an unexpected status, or a transport failure — verdict unknown.
31    Inconclusive(String),
32}
33
34/// Whether a GitHub response's headers mark it as rate-limited: a `Retry-After`
35/// header (primary or secondary limit) or `X-RateLimit-Remaining: 0`. Header
36/// lookups are case-insensitive ([`reqwest::header::HeaderMap`]).
37pub fn response_is_rate_limited(headers: &reqwest::header::HeaderMap) -> bool {
38    if headers.contains_key("retry-after") {
39        return true;
40    }
41    headers
42        .get("x-ratelimit-remaining")
43        .and_then(|v| v.to_str().ok())
44        .map(|v| v.trim() == "0")
45        .unwrap_or(false)
46}
47
48/// Run the `GET /repos/{owner}/{repo}` request under the shallow probe policy,
49/// reading response headers (not just the status) so a secondary-rate-limit 403
50/// is separable from an auth 403. 5xx and retriable transport errors retry
51/// within `policy`; everything else resolves on the first response.
52///
53/// `token` is optional: a `Some(non-empty)` value adds the `Authorization`
54/// bearer header (an empty string is treated as no token — the unauthenticated
55/// read path), so the required-token callers pass `Some(token)` and the
56/// best-effort callers can pass `None`.
57pub fn github_repo_probe(
58    client: &reqwest::blocking::Client,
59    url: &str,
60    token: Option<&str>,
61    policy: &RetryPolicy,
62) -> RepoProbe {
63    let token = token.map(str::to_string);
64    let outcome = retry_sync(policy, |_attempt| {
65        let mut b = client
66            .get(url)
67            .header("Accept", "application/vnd.github+json")
68            .header("X-GitHub-Api-Version", "2022-11-28");
69        if let Some(ref tok) = token
70            && !tok.is_empty()
71        {
72            b = b.header("Authorization", format!("Bearer {tok}"));
73        }
74        match b.send() {
75            Ok(resp) => {
76                let code = resp.status().as_u16();
77                // Capture the rate-limit verdict from headers BEFORE `text()`
78                // consumes the response.
79                let rate_limited = response_is_rate_limited(resp.headers());
80                if resp.status().is_success() {
81                    Ok(RepoProbe::Body(resp.text().unwrap_or_default()))
82                } else if resp.status().is_server_error() {
83                    Err(ControlFlow::Continue(RepoProbe::Inconclusive(format!(
84                        "HTTP {code}"
85                    ))))
86                } else if code == 429 || ((code == 403 || code == 401) && rate_limited) {
87                    Ok(RepoProbe::RateLimited)
88                } else if code == 404 {
89                    Ok(RepoProbe::Missing)
90                } else if code == 403 || code == 401 {
91                    Ok(RepoProbe::AuthDenied)
92                } else {
93                    Ok(RepoProbe::Inconclusive(format!("unexpected HTTP {code}")))
94                }
95            }
96            Err(e) => {
97                let msg = format!("network failure: {e}");
98                if is_retriable(&e) {
99                    Err(ControlFlow::Continue(RepoProbe::Inconclusive(msg)))
100                } else {
101                    Err(ControlFlow::Break(RepoProbe::Inconclusive(msg)))
102                }
103            }
104        }
105    });
106    // Both the success and the retries-exhausted arm collapse to the same
107    // terminal `RepoProbe`.
108    match outcome {
109        Ok(p) | Err(p) => p,
110    }
111}