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 two outcomes differs
8//! between callers — a tap that cannot be pushed is a `Warning`, whereas the
9//! required github-release target is a `Blocker` — so
10//! [`github_repo_push_check`] owns the whole probe→[`PreflightCheck`] mapping
11//! (including the `permissions.push` body parse) and each caller supplies
12//! only its [`RepoAccessOutcomes`].
13
14use std::ops::ControlFlow;
15
16use crate::PreflightCheck;
17use crate::log::StageLogger;
18use crate::retry::{RetryLog, RetryPolicy, is_retriable, retry_sync_deadline};
19
20/// Timeout for a single `GET /repos/{owner}/{repo}` preflight probe request.
21/// Shared by every probe caller so the release and publish preflights place
22/// the same bound on how long an unreachable GitHub can stall a run.
23pub const REPO_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
24
25/// Terminal classification of a single `GET /repos/{owner}/{repo}` probe,
26/// carrying enough to distinguish a transient rate-limit 403 from an auth 403.
27pub enum RepoProbe {
28    /// 2xx — carries the response body for `permissions.push` inspection.
29    Body(String),
30    /// 404 — repo missing under an otherwise-good token.
31    Missing,
32    /// 401 / 403 with NO rate-limit signal — the token cannot access the repo.
33    AuthDenied,
34    /// 429, or a 401 / 403 carrying a rate-limit signal (GitHub returns 403 for
35    /// both secondary-rate-limit and auth denial, distinguishable only by the
36    /// `Retry-After` / `X-RateLimit-Remaining: 0` headers) — transient.
37    RateLimited,
38    /// 5xx, an unexpected status, or a transport failure — verdict unknown.
39    Inconclusive(String),
40}
41
42impl std::fmt::Display for RepoProbe {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            RepoProbe::Body(_) => f.write_str("probe succeeded"),
46            RepoProbe::Missing => f.write_str("repo not found (404)"),
47            RepoProbe::AuthDenied => f.write_str("access denied (401/403)"),
48            RepoProbe::RateLimited => f.write_str("rate limited"),
49            RepoProbe::Inconclusive(reason) => f.write_str(reason),
50        }
51    }
52}
53
54/// Whether a GitHub response's headers mark it as rate-limited: a `Retry-After`
55/// header (primary or secondary limit) or `X-RateLimit-Remaining: 0`. Header
56/// lookups are case-insensitive ([`reqwest::header::HeaderMap`]).
57pub fn response_is_rate_limited(headers: &reqwest::header::HeaderMap) -> bool {
58    if headers.contains_key("retry-after") {
59        return true;
60    }
61    headers
62        .get("x-ratelimit-remaining")
63        .and_then(|v| v.to_str().ok())
64        .map(|v| v.trim() == "0")
65        .unwrap_or(false)
66}
67
68/// Whether an error body matches GitHub's *secondary* rate-limit signature:
69/// a 403 / 429 whose `message` mentions "secondary rate limit"
70/// (case-insensitive) or whose `documentation_url` points at
71/// `secondary-rate-limits`.
72///
73/// Body-signature detection exists for callers whose HTTP layer does not
74/// surface response headers (octocrab's `GitHubError` carries only
75/// message / documentation_url / status). Callers that do hold the headers
76/// should prefer [`response_is_rate_limited`]. Keeping the signature strings
77/// here — next to the header detector — means a GitHub body rewording is a
78/// one-place fix for every surface that discriminates 403s.
79pub fn is_secondary_rate_limit_signature(
80    status: u16,
81    message: &str,
82    documentation_url: Option<&str>,
83) -> bool {
84    if status != 403 && status != 429 {
85        return false;
86    }
87    if message.to_lowercase().contains("secondary rate limit") {
88        return true;
89    }
90    documentation_url.is_some_and(|u| u.contains("secondary-rate-limits"))
91}
92
93/// Whether an error body marks the response as rate-limited at all
94/// (primary quota exhaustion or a secondary limit): any 429, or a 403 whose
95/// `message` / `documentation_url` carries a rate-limit signal.
96///
97/// A 403 with NO rate-limit signal is auth denial — the same default
98/// [`github_repo_probe`] applies to header-carrying probes — so callers must
99/// fast-fail those instead of sleeping through a rate-limit backoff.
100pub fn is_rate_limit_signature(
101    status: u16,
102    message: &str,
103    documentation_url: Option<&str>,
104) -> bool {
105    if status == 429 {
106        return true;
107    }
108    if status != 403 {
109        return false;
110    }
111    message.to_lowercase().contains("rate limit")
112        || documentation_url.is_some_and(|u| u.contains("rate-limit"))
113}
114
115/// The two outcomes whose severity + wording genuinely differ between the
116/// preflight callers of [`github_repo_push_check`]: an unwritable repo blocks
117/// the required github-release target but only warns for a tap/index repo,
118/// and the missing/denied wording names what the caller was probing.
119///
120/// Every other arm of the probe→check mapping (the `permissions.push` parse
121/// ladder, the rate-limited / inconclusive / client-build indeterminates —
122/// warnings by default, blockers under strict preflight) is shared policy and
123/// lives in the mapper itself, so the two preflights cannot drift apart on
124/// how the same token+repo is classified.
125pub struct RepoAccessOutcomes {
126    /// Returned when the probe proves `permissions.push == false`.
127    pub push_denied: PreflightCheck,
128    /// Returned when the repo 404s or the token is denied access.
129    pub missing_or_denied: PreflightCheck,
130}
131
132/// One GitHub repo push-probe: the coordinates, the credential, the retry
133/// budget (attempt ladder plus the invocation's wall-clock deadline) the
134/// probe must stay inside, and the strictness the outcome is graded under.
135///
136/// Bundled rather than passed loose because every preflight caller threads
137/// the identical set through a base-resolving outer layer down to a
138/// `url`-taking inner one, and each field is read by the outcome messages.
139#[derive(Clone, Copy)]
140pub struct GithubRepoProbe<'a> {
141    /// Repository owner (user or org) as it appears in the API path.
142    pub owner: &'a str,
143    /// Repository name as it appears in the API path.
144    pub repo: &'a str,
145    /// Bearer credential; `None` (or an empty string) probes unauthenticated.
146    pub token: Option<&'a str>,
147    /// Attempt ladder for the retriable arms (5xx / transport failures).
148    pub policy: &'a RetryPolicy,
149    /// The invocation's wall-clock budget; retries stop once it is spent.
150    pub deadline: Option<std::time::Instant>,
151    /// Strict preflight promotes every indeterminate outcome to a blocker.
152    pub strict: bool,
153}
154
155/// Probe `GET {url}` and map the outcome onto a [`PreflightCheck`].
156///
157/// Builds the probe client, runs [`github_repo_probe`], and classifies:
158///
159/// * 200 + `permissions.push == true` ⇒ `Pass`
160/// * 200 + `permissions.push == false` ⇒ `outcomes.push_denied`
161/// * 200 + `permissions` absent / unparsable body ⇒ indeterminate —
162///   `Warning`, or `Blocker` when `probe.strict`
163/// * 404, or 401 / 403 without a rate-limit signal ⇒ `outcomes.missing_or_denied`
164/// * 429, or 401 / 403 carrying a rate-limit header ⇒ indeterminate (a
165///   transient GitHub rate limit must not abort a release that would
166///   otherwise succeed) — `Warning`, or `Blocker` when `probe.strict`
167/// * 5xx / transport failure / unexpected status ⇒ indeterminate — `Warning`,
168///   or `Blocker` when `probe.strict`
169///
170/// `url` stays a separate argument from `probe`: it is the one input that
171/// differs between a caller's base-resolving layer and its `url`-taking
172/// layer, which a unit test points at a local responder.
173pub fn github_repo_push_check(
174    url: &str,
175    probe: &GithubRepoProbe<'_>,
176    outcomes: RepoAccessOutcomes,
177    log: &StageLogger,
178) -> PreflightCheck {
179    let GithubRepoProbe {
180        owner,
181        repo,
182        token,
183        policy,
184        deadline,
185        strict,
186    } = *probe;
187    let client = match crate::http::blocking_client(REPO_PROBE_TIMEOUT) {
188        Ok(c) => c,
189        Err(e) => {
190            return indeterminate_check(
191                strict,
192                format!(
193                    "could not probe {owner}/{repo} write access ({e}); verify the repo and token manually"
194                ),
195            );
196        }
197    };
198    probe_to_push_check(
199        github_repo_probe(&client, url, token, policy, deadline, log),
200        owner,
201        repo,
202        outcomes,
203        strict,
204    )
205}
206
207/// Wrap an indeterminate probe outcome (the probe could not reach a verdict)
208/// in its effective severity: `Warning` by default so a transient upstream
209/// blip cannot abort an otherwise-valid release, `Blocker` under strict
210/// preflight (fail-closed).
211pub fn indeterminate_check(strict: bool, msg: String) -> PreflightCheck {
212    if strict {
213        PreflightCheck::Blocker(msg)
214    } else {
215        PreflightCheck::Warning(msg)
216    }
217}
218
219/// Pure probe→check mapper backing [`github_repo_push_check`], split out so
220/// the classification arms are unit-testable without an HTTP responder.
221pub fn probe_to_push_check(
222    probe: RepoProbe,
223    owner: &str,
224    repo: &str,
225    outcomes: RepoAccessOutcomes,
226    strict: bool,
227) -> PreflightCheck {
228    match probe {
229        RepoProbe::Body(body) => match serde_json::from_str::<serde_json::Value>(&body) {
230            Ok(v) => match v.pointer("/permissions/push").and_then(|p| p.as_bool()) {
231                Some(true) => PreflightCheck::Pass,
232                Some(false) => outcomes.push_denied,
233                None => indeterminate_check(
234                    strict,
235                    format!(
236                        "could not determine push access to {owner}/{repo} (no permissions in API \
237                         response); verify the token scope manually"
238                    ),
239                ),
240            },
241            Err(_) => indeterminate_check(
242                strict,
243                format!(
244                    "could not parse {owner}/{repo} API response; verify the repo and token manually"
245                ),
246            ),
247        },
248        RepoProbe::Missing | RepoProbe::AuthDenied => outcomes.missing_or_denied,
249        // A secondary-rate-limit 403 is indistinguishable from auth denial by
250        // status alone; the headers prove it transient, so warn (block only
251        // under strict preflight) rather than abort a release whose token is
252        // actually fine.
253        RepoProbe::RateLimited => indeterminate_check(
254            strict,
255            format!(
256                "GitHub API rate-limited while probing {owner}/{repo}; could not verify write \
257                 access — verify the repo and token manually"
258            ),
259        ),
260        RepoProbe::Inconclusive(reason) => indeterminate_check(
261            strict,
262            format!(
263                "could not probe {owner}/{repo} write access ({reason}); verify the repo and token manually"
264            ),
265        ),
266    }
267}
268
269/// Run the `GET /repos/{owner}/{repo}` request under the shallow probe policy,
270/// reading response headers (not just the status) so a secondary-rate-limit 403
271/// is separable from an auth 403. 5xx and retriable transport errors retry
272/// within `policy` and stop once `deadline` — the invocation's wall-clock
273/// budget — is spent; everything else resolves on the first response.
274///
275/// `token` is optional: a `Some(non-empty)` value adds the `Authorization`
276/// bearer header (an empty string is treated as no token — the unauthenticated
277/// read path), so the required-token callers pass `Some(token)` and the
278/// best-effort callers can pass `None`.
279pub fn github_repo_probe(
280    client: &reqwest::blocking::Client,
281    url: &str,
282    token: Option<&str>,
283    policy: &RetryPolicy,
284    deadline: Option<std::time::Instant>,
285    log: &StageLogger,
286) -> RepoProbe {
287    let rlog = RetryLog::new("github repo probe", log);
288    let token = token.map(str::to_string);
289    let outcome = retry_sync_deadline(rlog, policy, deadline, |_attempt| {
290        let mut b = client
291            .get(url)
292            .header("Accept", "application/vnd.github+json")
293            .header("X-GitHub-Api-Version", "2022-11-28");
294        if let Some(ref tok) = token
295            && !tok.is_empty()
296        {
297            b = b.header("Authorization", format!("Bearer {tok}"));
298        }
299        match b.send() {
300            Ok(resp) => {
301                let code = resp.status().as_u16();
302                // Capture the rate-limit verdict from headers BEFORE `text()`
303                // consumes the response.
304                let rate_limited = response_is_rate_limited(resp.headers());
305                if resp.status().is_success() {
306                    Ok(RepoProbe::Body(resp.text().unwrap_or_default()))
307                } else if resp.status().is_server_error() {
308                    Err(ControlFlow::Continue(RepoProbe::Inconclusive(format!(
309                        "HTTP {code}"
310                    ))))
311                } else if code == 429 || ((code == 403 || code == 401) && rate_limited) {
312                    Ok(RepoProbe::RateLimited)
313                } else if code == 404 {
314                    Ok(RepoProbe::Missing)
315                } else if code == 403 || code == 401 {
316                    Ok(RepoProbe::AuthDenied)
317                } else {
318                    Ok(RepoProbe::Inconclusive(format!("unexpected HTTP {code}")))
319                }
320            }
321            Err(e) => {
322                let msg = format!("network failure: {e}");
323                if is_retriable(&e) {
324                    Err(ControlFlow::Continue(RepoProbe::Inconclusive(msg)))
325                } else {
326                    Err(ControlFlow::Break(RepoProbe::Inconclusive(msg)))
327                }
328            }
329        }
330    });
331    // Both the success and the retries-exhausted arm collapse to the same
332    // terminal `RepoProbe`.
333    match outcome {
334        Ok(p) | Err(p) => p,
335    }
336}
337
338#[cfg(test)]
339mod push_check_tests {
340    //! The pure probe→check mapping arms. The HTTP-level probe behavior
341    //! (status/header classification) is pinned by the scripted-responder
342    //! tests at the two preflight call sites; these cover the shared
343    //! severity/parse policy that must not drift between them.
344    use super::*;
345
346    fn outcomes() -> RepoAccessOutcomes {
347        RepoAccessOutcomes {
348            push_denied: PreflightCheck::Blocker("push denied".into()),
349            missing_or_denied: PreflightCheck::Blocker("missing or denied".into()),
350        }
351    }
352
353    #[test]
354    fn push_true_passes() {
355        let probe = RepoProbe::Body(r#"{"permissions":{"push":true}}"#.into());
356        assert_eq!(
357            probe_to_push_check(probe, "o", "r", outcomes(), false),
358            PreflightCheck::Pass
359        );
360    }
361
362    #[test]
363    fn push_false_returns_caller_push_denied() {
364        let probe = RepoProbe::Body(r#"{"permissions":{"push":false}}"#.into());
365        assert_eq!(
366            probe_to_push_check(probe, "o", "r", outcomes(), false),
367            PreflightCheck::Blocker("push denied".into())
368        );
369    }
370
371    #[test]
372    fn permissions_absent_warns() {
373        let probe = RepoProbe::Body(r#"{"full_name":"o/r"}"#.into());
374        match probe_to_push_check(probe, "o", "r", outcomes(), false) {
375            PreflightCheck::Warning(msg) => {
376                assert!(msg.contains("could not determine push access"), "{msg}")
377            }
378            other => panic!("expected Warning, got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn unparsable_body_warns() {
384        let probe = RepoProbe::Body("not json".into());
385        match probe_to_push_check(probe, "o", "r", outcomes(), false) {
386            PreflightCheck::Warning(msg) => {
387                assert!(msg.contains("could not parse o/r"), "{msg}")
388            }
389            other => panic!("expected Warning, got {other:?}"),
390        }
391    }
392
393    #[test]
394    fn missing_and_auth_denied_return_caller_outcome() {
395        for probe in [RepoProbe::Missing, RepoProbe::AuthDenied] {
396            assert_eq!(
397                probe_to_push_check(probe, "o", "r", outcomes(), false),
398                PreflightCheck::Blocker("missing or denied".into())
399            );
400        }
401    }
402
403    #[test]
404    fn rate_limited_warns_never_escalates() {
405        match probe_to_push_check(RepoProbe::RateLimited, "o", "r", outcomes(), false) {
406            PreflightCheck::Warning(msg) => assert!(msg.contains("rate-limited"), "{msg}"),
407            other => panic!("expected Warning, got {other:?}"),
408        }
409    }
410
411    #[test]
412    fn strict_promotes_indeterminate_arms_to_blocker() {
413        for probe in [
414            RepoProbe::RateLimited,
415            RepoProbe::Inconclusive("HTTP 500".into()),
416            RepoProbe::Body(r#"{"full_name":"o/r"}"#.into()),
417            RepoProbe::Body("not json".into()),
418        ] {
419            match probe_to_push_check(probe, "o", "r", outcomes(), true) {
420                PreflightCheck::Blocker(_) => {}
421                other => panic!("strict must promote indeterminate to Blocker, got {other:?}"),
422            }
423        }
424    }
425
426    #[test]
427    fn strict_leaves_definitive_arms_unchanged() {
428        // Definitive outcomes keep the caller-supplied severity — strict only
429        // touches the indeterminate arms.
430        let probe = RepoProbe::Body(r#"{"permissions":{"push":true}}"#.into());
431        assert_eq!(
432            probe_to_push_check(probe, "o", "r", outcomes(), true),
433            PreflightCheck::Pass
434        );
435        assert_eq!(
436            probe_to_push_check(RepoProbe::Missing, "o", "r", outcomes(), true),
437            PreflightCheck::Blocker("missing or denied".into())
438        );
439    }
440
441    #[test]
442    fn inconclusive_warns_with_reason() {
443        let probe = RepoProbe::Inconclusive("HTTP 500".into());
444        match probe_to_push_check(probe, "o", "r", outcomes(), false) {
445            PreflightCheck::Warning(msg) => assert!(msg.contains("HTTP 500"), "{msg}"),
446            other => panic!("expected Warning, got {other:?}"),
447        }
448    }
449}
450
451#[cfg(test)]
452mod rate_limit_signature_tests {
453    use super::*;
454
455    #[test]
456    fn secondary_matches_message_or_doc_url_on_403_and_429() {
457        for status in [403u16, 429] {
458            assert!(is_secondary_rate_limit_signature(
459                status,
460                "You have exceeded a secondary rate limit",
461                None
462            ));
463            assert!(is_secondary_rate_limit_signature(
464                status,
465                "blocked",
466                Some("https://docs.github.com/rest/overview#secondary-rate-limits")
467            ));
468        }
469    }
470
471    #[test]
472    fn secondary_rejects_other_statuses_and_plain_403() {
473        assert!(!is_secondary_rate_limit_signature(
474            500,
475            "secondary rate limit",
476            None
477        ));
478        assert!(!is_secondary_rate_limit_signature(
479            403,
480            "Bad credentials",
481            Some("https://docs.github.com/rest")
482        ));
483    }
484
485    #[test]
486    fn rate_limit_signature_accepts_any_429() {
487        assert!(is_rate_limit_signature(429, "", None));
488    }
489
490    #[test]
491    fn rate_limit_signature_needs_body_signal_on_403() {
492        assert!(is_rate_limit_signature(
493            403,
494            "API rate limit exceeded for user ID 1",
495            None
496        ));
497        assert!(is_rate_limit_signature(
498            403,
499            "forbidden",
500            Some("https://docs.github.com/rest/overview/rate-limits-for-the-rest-api")
501        ));
502        // The 403-without-signal case IS auth denial: sleeping through a
503        // rate-limit backoff on it hides a hard token failure.
504        assert!(!is_rate_limit_signature(
505            403,
506            "Resource not accessible by integration",
507            Some("https://docs.github.com/rest")
508        ));
509        assert!(!is_rate_limit_signature(401, "rate limit", None));
510    }
511}