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}