Skip to main content

anodizer_core/git/
github_api.rs

1use anyhow::{Context as _, Result, bail};
2use std::collections::HashMap;
3use std::path::Path;
4use std::process::Command;
5use std::sync::{Mutex, OnceLock};
6
7use super::git_output_in;
8use super::slug::RepoSlug;
9use super::tags::create_and_push_tag_in;
10
11/// GET a GitHub API endpoint via the `gh` CLI (single request, no pagination).
12///
13/// Returns the parsed JSON response. Useful for endpoints that return a single
14/// object (e.g. the Compare API) rather than a paginated array.
15pub fn gh_api_get(endpoint: &str, token: Option<&str>) -> Result<serde_json::Value> {
16    gh_api_get_with_binary(Path::new("gh"), endpoint, token)
17}
18
19/// GET a GitHub API endpoint via `gh_binary` (single request, no pagination).
20///
21/// Path-taking sibling of [`gh_api_get`] so tests can point at a missing or
22/// stub binary inside a `tempfile::tempdir()` without mutating `PATH`.
23/// When `gh_binary` has no separator (e.g. `Path::new("gh")`),
24/// [`Command::new`] falls back to a PATH lookup — the production
25/// behavior — so the wrapper is a true no-op in normal use.
26pub fn gh_api_get_with_binary(
27    gh_binary: &Path,
28    endpoint: &str,
29    token: Option<&str>,
30) -> Result<serde_json::Value> {
31    let mut cmd = Command::new(gh_binary);
32    cmd.args(["api", endpoint]);
33    if let Some(tok) = token {
34        cmd.env("GITHUB_TOKEN", tok);
35    }
36    let output = cmd
37        .stdout(std::process::Stdio::piped())
38        .stderr(std::process::Stdio::piped())
39        .output()
40        .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
41    if !output.status.success() {
42        let stderr_raw = String::from_utf8_lossy(&output.stderr);
43        let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
44        bail!("{}", redact_gh_stderr(&raw, token));
45    }
46    let stdout = String::from_utf8_lossy(&output.stdout);
47    serde_json::from_str(&stdout).context("failed to parse gh api response")
48}
49
50/// DELETE a GitHub API endpoint via `gh_binary`.
51///
52/// Path-taking like [`gh_api_get_with_binary`] so tests can point at a stub
53/// binary without mutating `PATH`. Returns `Ok(())` on success; the error
54/// message carries the redacted `gh` stderr (including the HTTP status
55/// shape, e.g. `HTTP 404`) so callers can classify already-absent targets.
56pub fn gh_api_delete_with_binary(
57    gh_binary: &Path,
58    endpoint: &str,
59    token: Option<&str>,
60) -> Result<()> {
61    let mut cmd = Command::new(gh_binary);
62    cmd.args(["api", "-X", "DELETE", endpoint]);
63    if let Some(tok) = token {
64        cmd.env("GITHUB_TOKEN", tok);
65    }
66    let output = cmd
67        .stdout(std::process::Stdio::piped())
68        .stderr(std::process::Stdio::piped())
69        .output()
70        .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
71    if !output.status.success() {
72        let stderr_raw = String::from_utf8_lossy(&output.stderr);
73        let raw = format!("gh api DELETE {} failed: {}", endpoint, stderr_raw.trim());
74        bail!("{}", redact_gh_stderr(&raw, token));
75    }
76    Ok(())
77}
78
79/// Cache key for [`COMMIT_LOGIN_CACHE`]: `(owner, repo, author_email)`.
80type LoginCacheKey = (String, String, String);
81
82/// Process-wide memo of commit-author login lookups. Failed lookups are
83/// cached as `None` so an offline / unauthenticated run costs at most one
84/// API attempt per unique author email, even when several CLI entry points
85/// render changelogs for many crates in the same invocation.
86static COMMIT_LOGIN_CACHE: OnceLock<Mutex<HashMap<LoginCacheKey, Option<String>>>> =
87    OnceLock::new();
88
89/// Resolve a commit author's GitHub login from a representative commit SHA
90/// via `GET /repos/{owner}/{repo}/commits/{sha}` → `.author.login`.
91///
92/// Best-effort by design: any failure (no `gh`, no auth, offline, unknown
93/// SHA, commit email not linked to a GitHub account) returns `None` with at
94/// most a debug-level trace — callers fall back to name-based rendering and
95/// must never fail a release pipeline over a missing login.
96///
97/// Results (including failures) are memoized process-wide per
98/// `(owner, repo, email)`, so each unique author email costs one API call
99/// per run regardless of how many commits or crates reference it.
100pub fn commit_author_login(
101    owner: &str,
102    repo: &str,
103    email: &str,
104    sha: &str,
105    token: Option<&str>,
106) -> Option<String> {
107    commit_author_login_with_binary(Path::new("gh"), owner, repo, email, sha, token)
108}
109
110/// Path-taking sibling of [`commit_author_login`] so tests can point at a
111/// missing or stub binary without mutating `PATH`.
112pub fn commit_author_login_with_binary(
113    gh_binary: &Path,
114    owner: &str,
115    repo: &str,
116    email: &str,
117    sha: &str,
118    token: Option<&str>,
119) -> Option<String> {
120    if owner.is_empty() || repo.is_empty() || email.is_empty() || sha.is_empty() {
121        return None;
122    }
123    let key = (owner.to_string(), repo.to_string(), email.to_string());
124    let cache = COMMIT_LOGIN_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
125    // A poisoned lock only means another thread panicked mid-insert; the map
126    // itself is still a valid memo, so recover it rather than panic here.
127    {
128        let guard = match cache.lock() {
129            Ok(g) => g,
130            Err(poisoned) => poisoned.into_inner(),
131        };
132        if let Some(hit) = guard.get(&key) {
133            return hit.clone();
134        }
135    }
136    let endpoint = format!("/repos/{owner}/{repo}/commits/{sha}");
137    let resolved = match gh_api_get_with_binary(gh_binary, &endpoint, token) {
138        Ok(v) => v
139            .pointer("/author/login")
140            .and_then(|l| l.as_str())
141            .filter(|s| !s.is_empty())
142            .map(str::to_string),
143        Err(e) => {
144            tracing::debug!(
145                "commit_author_login: lookup for {} failed (keeping name-based rendering): {}",
146                email,
147                e
148            );
149            None
150        }
151    };
152    let mut guard = match cache.lock() {
153        Ok(g) => g,
154        Err(poisoned) => poisoned.into_inner(),
155    };
156    guard.insert(key, resolved.clone());
157    resolved
158}
159
160/// The ordered env-var ladder consulted (after any explicit CLI/context
161/// value) when resolving a GitHub token: `ANODIZER_GITHUB_TOKEN` is preferred,
162/// then `GITHUB_TOKEN`, then `GH_TOKEN` (the `gh` CLI's conventional var, so
163/// runners configured only for `gh` still authenticate). This is the single
164/// source of truth for the ladder —
165/// [`resolve_github_token_with_env`] (the only real reader) consumes it, and
166/// the config-aware preflight builds its `EnvAnyOf` lists from it so the
167/// validated set cannot drift from the set the resolver actually reads.
168pub const GITHUB_TOKEN_ENV_LADDER: &[&str] = &["ANODIZER_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"];
169
170/// The env-var fragment of a "no GitHub token" remediation hint, rendered
171/// from [`GITHUB_TOKEN_ENV_LADDER`] in actual resolution-precedence order:
172/// `ANODIZER_GITHUB_TOKEN or GITHUB_TOKEN or GH_TOKEN`. Error messages
173/// interpolate this
174/// (or [`github_token_hint`]) instead of restating the vars, so a
175/// hand-spelled hint can never list the ladder in the wrong order or go
176/// stale when a var is added or renamed.
177pub fn github_token_env_hint() -> String {
178    GITHUB_TOKEN_ENV_LADDER.join(" or ")
179}
180
181/// Full remediation hint for surfaces that also accept a `--token` flag:
182/// `set ANODIZER_GITHUB_TOKEN or GITHUB_TOKEN or GH_TOKEN, or pass
183/// --token`, rendered
184/// from [`GITHUB_TOKEN_ENV_LADDER`].
185pub fn github_token_hint() -> String {
186    format!("set {}, or pass --token", github_token_env_hint())
187}
188
189/// Resolve the GitHub token for API calls through the codebase-standard
190/// chain: explicit value (CLI flag / context option) → the
191/// [`GITHUB_TOKEN_ENV_LADDER`] (`ANODIZER_GITHUB_TOKEN` → `GITHUB_TOKEN` →
192/// `GH_TOKEN`).
193/// Empty strings count as absent at every link — GitHub Actions materializes
194/// missing secrets as `""`, which must not short-circuit the fallback to the
195/// next link.
196///
197/// `env` is the injectable env source (pass `Context::env_var` or a
198/// map-backed closure in tests) so the chain is testable without mutating
199/// process env.
200pub fn resolve_github_token_with_env(
201    explicit: Option<&str>,
202    env: &dyn Fn(&str) -> Option<String>,
203) -> Option<String> {
204    let non_empty = |s: String| if s.is_empty() { None } else { Some(s) };
205    let explicit = explicit.filter(|t| !t.is_empty()).map(str::to_string);
206    GITHUB_TOKEN_ENV_LADDER.iter().fold(explicit, |acc, var| {
207        acc.or_else(|| env(var).and_then(non_empty))
208    })
209}
210
211/// Process-env wrapper of [`resolve_github_token_with_env`] for call sites
212/// without a `Context` (e.g. the `bump`/`tag` changelog-sync write path,
213/// whose commands expose no `--token` flag).
214pub fn resolve_github_token(explicit: Option<&str>) -> Option<String> {
215    resolve_github_token_with_env(explicit, &|key| std::env::var(key).ok())
216}
217
218/// Redact secrets from `gh` CLI stderr before interpolating into a bail
219/// message. `token` is the `GITHUB_TOKEN` value passed to the
220/// subprocess; if the user-supplied token leaks (e.g. via a verbose `gh`
221/// error that echoes the auth header), it is replaced with `$GITHUB_TOKEN`
222/// regardless of whether the value matches the `redact::is_secret`
223/// heuristics. Also strips inline URL credentials and any other secret
224/// env-var values reachable from the parent process env.
225fn redact_gh_stderr(stderr: &str, token: Option<&str>) -> String {
226    let mut env: Vec<(String, String)> = std::env::vars().collect();
227    if let Some(tok) = token
228        && !tok.is_empty()
229    {
230        env.push(("GITHUB_TOKEN".to_string(), tok.to_string()));
231    }
232    crate::redact::with_env(stderr, &env)
233}
234
235/// GET a GitHub API endpoint via the `gh` CLI, with pagination.
236///
237/// Returns a JSON array of all pages concatenated. The caller is responsible for
238/// ensuring that `gh` is installed and authenticated.
239pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
240    gh_api_get_paginated_with_binary(Path::new("gh"), endpoint, token)
241}
242
243/// Paginated GET via `gh_binary`. Path-taking sibling of
244/// [`gh_api_get_paginated`].
245pub fn gh_api_get_paginated_with_binary(
246    gh_binary: &Path,
247    endpoint: &str,
248    token: Option<&str>,
249) -> Result<Vec<serde_json::Value>> {
250    let mut cmd = Command::new(gh_binary);
251    cmd.args(["api", "--paginate", endpoint]);
252    if let Some(tok) = token {
253        cmd.env("GITHUB_TOKEN", tok);
254    }
255    let output = cmd
256        .stdout(std::process::Stdio::piped())
257        .stderr(std::process::Stdio::piped())
258        .output()
259        .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
260
261    if !output.status.success() {
262        let stderr_raw = String::from_utf8_lossy(&output.stderr);
263        let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
264        bail!("{}", redact_gh_stderr(&raw, token));
265    }
266
267    let stdout = String::from_utf8_lossy(&output.stdout);
268
269    // Try parsing the entire response first before falling back to splitting.
270    // This avoids the split_inclusive(']') approach corrupting non-array responses.
271    if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
272        return Ok(arr);
273    }
274    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
275        // Single object response (e.g. non-list endpoint) — wrap in a vec.
276        return Ok(vec![val]);
277    }
278
279    // Whole-parse failed — gh --paginate may return multiple JSON arrays
280    // concatenated (e.g. `[...][...]`). Split on `]` boundaries and parse each chunk.
281    let mut all_items = Vec::new();
282    for chunk in stdout.split_inclusive(']') {
283        let trimmed = chunk.trim();
284        if trimmed.is_empty() {
285            continue;
286        }
287        if let Ok(serde_json::Value::Array(arr)) =
288            serde_json::from_str::<serde_json::Value>(trimmed)
289        {
290            all_items.extend(arr);
291        } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
292            all_items.push(val);
293        } else {
294            // Log unparseable chunks so corrupt data doesn't go unnoticed.
295            // The chunk may carry secret-shaped request/response data, and the
296            // tracing subscriber performs NO redaction of its own — so redact
297            // here (process-env secret values + inline URL credentials) before
298            // emitting. Cap the logged chunk at 200 bytes — an HTTP body in an
299            // error context should convey "what server said" without dumping a
300            // multi-MB stack trace to the user's terminal.
301            let snippet = &trimmed[..trimmed.len().min(200)];
302            let redacted = crate::redact::redact_process_env(snippet);
303            tracing::warn!(
304                "gh_api_get_paginated: failed to parse JSON chunk ({} bytes): {:?}",
305                trimmed.len(),
306                redacted,
307            );
308        }
309    }
310
311    // A non-empty body that yielded zero items means the whole-parse failed AND
312    // every chunk failed to parse — garbled stdout from a zero-exit `gh`. Returning
313    // an empty vec here would be indistinguishable from a genuine "no results",
314    // letting a caller wrongly conclude a release/asset is absent. Fail loud instead.
315    if all_items.is_empty() && !stdout.trim().is_empty() {
316        let raw = format!(
317            "gh api GET {endpoint} exited 0 but returned a body that could not be parsed as JSON"
318        );
319        bail!("{}", redact_gh_stderr(&raw, token));
320    }
321
322    Ok(all_items)
323}
324
325/// POST via `gh_binary`. Internal helper consumed by
326/// [`create_tag_via_github_api_in`]; takes an explicit binary path so
327/// tests can drive the failure path against a missing or stub binary.
328fn gh_api_post_with_binary(
329    gh_binary: &Path,
330    endpoint: &str,
331    body: &serde_json::Value,
332    log: &crate::log::StageLogger,
333) -> Result<serde_json::Value> {
334    let body_str = serde_json::to_string(body)?;
335
336    let mut cmd = Command::new(gh_binary);
337    cmd.args(["api", "--method", "POST", endpoint, "--input", "-"]);
338
339    // `gh api` may echo a token from the parent env (`GITHUB_TOKEN` /
340    // `GH_TOKEN`) on stderr; carry the full process env on a logger clone so
341    // the helper's redaction matches the prior `redact_process_env` coverage
342    // (broader than this caller's attached env). The "gh CLI" label keeps the
343    // spawn-failure string the caller pattern-matches on
344    // (`failed to spawn gh CLI`) for its git-fallback decision.
345    let redacting_log = log.clone().with_env(std::env::vars().collect::<Vec<_>>());
346    let output = crate::run::run_checked_with_stdin(
347        &mut cmd,
348        body_str.as_bytes(),
349        &redacting_log,
350        "gh CLI",
351    )?;
352
353    let response: serde_json::Value = serde_json::from_slice(&output.stdout)
354        .with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
355    Ok(response)
356}
357
358/// Create a tag via the GitHub API (using the `gh` CLI).
359///
360/// This avoids the need for local git push access. Requires the `gh` CLI to be
361/// installed and authenticated (`gh auth login`). The GitHub API creates a
362/// lightweight tag object pointing at the HEAD commit on the default branch.
363///
364/// Falls back to [`create_and_push_tag_in`] if `gh` is not available.
365///
366/// `slug` is the resolved repository identity (config override -> remote),
367/// supplied by the caller so the API target and the rest of the pipeline agree
368/// on one owner/repo rather than this function re-deriving its own.
369pub fn create_tag_via_github_api(
370    slug: &RepoSlug,
371    tag: &str,
372    message: &str,
373    dry_run: bool,
374    log: &crate::log::StageLogger,
375    strict: bool,
376) -> Result<()> {
377    create_tag_via_github_api_in(
378        &std::env::current_dir()?,
379        Path::new("gh"),
380        slug,
381        tag,
382        message,
383        dry_run,
384        log,
385        strict,
386    )
387}
388
389/// Path-taking sibling of [`create_tag_via_github_api`].
390///
391/// `cwd` is the repository the tag should be created against (used for the
392/// `git rev-parse HEAD` lookup and the local `git tag -a` fallback when
393/// `gh_binary` is missing). `slug` is the resolved owner/repo. `gh_binary` is
394/// the path to the `gh` CLI; pass `Path::new("gh")` to keep the production
395/// PATH-lookup behavior.
396#[allow(clippy::too_many_arguments)]
397pub fn create_tag_via_github_api_in(
398    cwd: &Path,
399    gh_binary: &Path,
400    slug: &RepoSlug,
401    tag: &str,
402    message: &str,
403    dry_run: bool,
404    log: &crate::log::StageLogger,
405    strict: bool,
406) -> Result<()> {
407    if dry_run {
408        log.status(&format!(
409            "(dry-run) would create tag {} via GitHub API (\"{}\")",
410            tag, message
411        ));
412        return Ok(());
413    }
414
415    let owner = slug.owner();
416    let repo = slug.name();
417
418    // Get the current HEAD SHA to point the tag at — via the canonical
419    // single sha resolver, not a private `rev-parse` here.
420    let sha = super::get_head_commit_in(cwd)?;
421
422    let body = serde_json::json!({
423        "tag": tag,
424        "message": message,
425        "object": sha,
426        "type": "commit",
427        "tagger": {
428            "name": git_output_in(cwd, &["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
429            "email": git_output_in(cwd, &["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
430            "date": crate::sde::resolve_now().to_rfc3339(),
431        }
432    });
433
434    let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
435    let response = match gh_api_post_with_binary(gh_binary, &tag_endpoint, &body, log) {
436        Ok(resp) => resp,
437        Err(e) => {
438            if e.to_string().contains("failed to spawn gh CLI") {
439                if strict {
440                    anyhow::bail!(
441                        "gh CLI not found, cannot create tag via GitHub API (strict mode)"
442                    );
443                }
444                log.warn("gh CLI not found, falling back to local git tag + push");
445                return create_and_push_tag_in(cwd, tag, message, dry_run, log, strict);
446            }
447            return Err(e);
448        }
449    };
450
451    let tag_sha = response["sha"]
452        .as_str()
453        .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
454
455    let ref_body = serde_json::json!({
456        "ref": format!("refs/tags/{}", tag),
457        "sha": tag_sha,
458    });
459
460    let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
461    gh_api_post_with_binary(gh_binary, &ref_endpoint, &ref_body, log)?;
462
463    Ok(())
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    /// `dry_run=true` must short-circuit before any subprocess spawn.
471    #[test]
472    fn create_tag_dry_run_short_circuits() {
473        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
474        let slug = RepoSlug::for_test("owner", "repo");
475        // Even with no git repo / no gh CLI, dry-run must succeed.
476        let result = create_tag_via_github_api(&slug, "v1.0.0", "msg", true, &log, false);
477        assert!(result.is_ok(), "dry-run must succeed: {result:?}");
478    }
479
480    /// Redact: the token must be replaced with the literal `$GITHUB_TOKEN`
481    /// placeholder when it appears verbatim in the stderr output. Catches
482    /// the case where `gh` echoes the auth header in a verbose error.
483    #[test]
484    fn redact_gh_stderr_replaces_token_value() {
485        let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
486        let stderr = format!("HTTP 401: token {secret} is invalid");
487        let redacted = redact_gh_stderr(&stderr, Some(secret));
488        // Exact-output, not absence-only: guards the env-value masking
489        // layer — the token is replaced by its `$NAME` placeholder.
490        assert_eq!(redacted, "HTTP 401: token $GITHUB_TOKEN is invalid");
491    }
492
493    #[test]
494    fn redact_gh_stderr_with_no_token_still_strips_url_creds() {
495        // Inline URL credentials must be redacted even with no explicit
496        // token argument.
497        let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
498        let redacted = redact_gh_stderr(stderr, None);
499        // Exact-output, not absence-only: guards the inline URL-credential
500        // stripping layer — userinfo becomes `<redacted>`.
501        assert_eq!(
502            redacted,
503            "auth failed: https://<redacted>@github.com/o/r.git rejected"
504        );
505    }
506
507    #[test]
508    fn redact_gh_stderr_empty_token_is_noop_on_token_field() {
509        // An empty Some("") token must not pollute the env vector with a
510        // zero-length value (that would match every position in the string).
511        let stderr = "plain error message without credentials";
512        let redacted = redact_gh_stderr(stderr, Some(""));
513        assert_eq!(redacted, stderr);
514    }
515
516    /// A missing `gh` binary must degrade to `None` (never an error/panic):
517    /// the changelog pipeline keeps name-based rendering. The failure is
518    /// memoized, so the second call returns the cached `None` without
519    /// re-attempting a spawn.
520    #[test]
521    fn commit_author_login_missing_binary_degrades_to_none_and_caches() {
522        let tmp = tempfile::tempdir().unwrap();
523        let missing = tmp.path().join("nonexistent-gh");
524        let first = commit_author_login_with_binary(
525            &missing,
526            "owner-cal-test",
527            "repo-cal-test",
528            "a@example.com",
529            "0123456789abcdef0123456789abcdef01234567",
530            None,
531        );
532        assert_eq!(first, None, "missing binary must yield None");
533        // Cached-failure path: same (owner, repo, email) key short-circuits
534        // before any spawn attempt.
535        let second = commit_author_login_with_binary(
536            &missing,
537            "owner-cal-test",
538            "repo-cal-test",
539            "a@example.com",
540            "fedcba9876543210fedcba9876543210fedcba98",
541            None,
542        );
543        assert_eq!(second, None);
544    }
545
546    /// Empty inputs short-circuit to `None` without touching the cache or
547    /// spawning anything.
548    #[test]
549    fn commit_author_login_empty_inputs_are_none() {
550        let gh = Path::new("gh");
551        assert_eq!(
552            commit_author_login_with_binary(gh, "", "r", "e", "s", None),
553            None
554        );
555        assert_eq!(
556            commit_author_login_with_binary(gh, "o", "", "e", "s", None),
557            None
558        );
559        assert_eq!(
560            commit_author_login_with_binary(gh, "o", "r", "", "s", None),
561            None
562        );
563        assert_eq!(
564            commit_author_login_with_binary(gh, "o", "r", "e", "", None),
565            None
566        );
567    }
568
569    /// Chain order: explicit beats `ANODIZER_GITHUB_TOKEN` beats
570    /// `GITHUB_TOKEN`; empty strings are absent at every link. Uses a
571    /// map-backed env closure — no process-env mutation, no network.
572    #[test]
573    fn resolve_github_token_chain_order_and_empty_filtering() {
574        let env_with = |pairs: &[(&str, &str)]| {
575            let map: HashMap<String, String> = pairs
576                .iter()
577                .map(|(k, v)| (k.to_string(), v.to_string()))
578                .collect();
579            move |key: &str| map.get(key).cloned()
580        };
581
582        let both = env_with(&[
583            ("ANODIZER_GITHUB_TOKEN", "anod-tok"),
584            ("GITHUB_TOKEN", "gh-tok"),
585        ]);
586        assert_eq!(
587            resolve_github_token_with_env(Some("explicit-tok"), &both),
588            Some("explicit-tok".to_string()),
589            "explicit token must win over both env vars"
590        );
591        assert_eq!(
592            resolve_github_token_with_env(None, &both),
593            Some("anod-tok".to_string()),
594            "ANODIZER_GITHUB_TOKEN must win over GITHUB_TOKEN"
595        );
596
597        let gh_only = env_with(&[("GITHUB_TOKEN", "gh-tok")]);
598        assert_eq!(
599            resolve_github_token_with_env(None, &gh_only),
600            Some("gh-tok".to_string()),
601            "GITHUB_TOKEN alone must resolve (the CI-gap pin)"
602        );
603        assert_eq!(
604            resolve_github_token_with_env(Some(""), &gh_only),
605            Some("gh-tok".to_string()),
606            "empty explicit token must fall through to env"
607        );
608
609        let empty_anod = env_with(&[("ANODIZER_GITHUB_TOKEN", ""), ("GITHUB_TOKEN", "gh-tok")]);
610        assert_eq!(
611            resolve_github_token_with_env(None, &empty_anod),
612            Some("gh-tok".to_string()),
613            "empty ANODIZER_GITHUB_TOKEN (GHA missing-secret materialization) must not short-circuit"
614        );
615
616        let none = env_with(&[]);
617        assert_eq!(resolve_github_token_with_env(None, &none), None);
618        assert_eq!(resolve_github_token_with_env(Some(""), &none), None);
619    }
620
621    /// `gh_api_get_with_binary` must surface a user-actionable spawn
622    /// failure when the binary path doesn't exist on disk.
623    ///
624    /// Drives the function with a temp-dir-relative path that points to
625    /// nothing, asserting the error mentions "spawn gh" so the operator
626    /// can correlate it with their missing-`gh` install state.
627    #[test]
628    fn gh_api_get_with_binary_bails_when_binary_missing() {
629        let tmp = tempfile::tempdir().unwrap();
630        let missing = tmp.path().join("nonexistent-gh");
631        let err = gh_api_get_with_binary(&missing, "/repos/x/y", None)
632            .expect_err("missing binary must error");
633        let msg = err.to_string();
634        assert!(
635            msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
636            "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
637        );
638    }
639
640    /// Same guarantee for the paginated sibling.
641    #[test]
642    fn gh_api_get_paginated_with_binary_bails_when_binary_missing() {
643        let tmp = tempfile::tempdir().unwrap();
644        let missing = tmp.path().join("nonexistent-gh");
645        let err = gh_api_get_paginated_with_binary(&missing, "/repos/x/y", None)
646            .expect_err("missing binary must error");
647        let msg = err.to_string();
648        assert!(
649            msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
650            "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
651        );
652    }
653
654    /// `create_tag_via_github_api_in` with `strict=true` must error
655    /// when `cwd` is not a git repo — the HEAD-sha resolver
656    /// (`get_head_commit_in`) drives `git rev-parse HEAD` and fails there.
657    ///
658    /// Skips when `git` isn't on PATH (mirrors `tool_on_path` patterns
659    /// elsewhere in the suite).
660    #[test]
661    fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
662        // spawn-retry-ok: this is a git *availability* probe — an Err means git
663        // is absent (skip the test via the success() guard), not a transient
664        // spawn-init failure to retry; routing it through the panicking helper
665        // would crash on a git-less host instead of skipping.
666        if !Command::new("git")
667            .arg("--version")
668            .output()
669            .map(|o| o.status.success())
670            .unwrap_or(false)
671        {
672            return;
673        }
674        let tmp = tempfile::tempdir().unwrap();
675        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
676        let slug = RepoSlug::for_test("owner", "repo");
677        let err = create_tag_via_github_api_in(
678            tmp.path(),
679            Path::new("gh"),
680            &slug,
681            "v1.0.0",
682            "msg",
683            false,
684            &log,
685            true,
686        )
687        .expect_err("non-git cwd must error");
688        let msg = err.to_string();
689        assert!(
690            msg.contains("git") || msg.contains("remote"),
691            "expected error to mention git or remote, got: {msg}"
692        );
693    }
694
695    /// Dry-run short-circuit must also fire on the cwd-injectable entry
696    /// point — covers the new branch without re-hitting the inner
697    /// detection codepath.
698    #[test]
699    fn create_tag_via_github_api_in_dry_run_short_circuits() {
700        let tmp = tempfile::tempdir().unwrap();
701        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
702        let slug = RepoSlug::for_test("owner", "repo");
703        let result = create_tag_via_github_api_in(
704            tmp.path(),
705            Path::new("gh"),
706            &slug,
707            "v1.0.0",
708            "msg",
709            true,
710            &log,
711            false,
712        );
713        assert!(result.is_ok(), "dry-run must succeed: {result:?}");
714    }
715}