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/// Error shown when a signed tag is requested on the GitHub-API path: the API
359/// mints the tag object server-side, out of reach of any local GPG/SSH key, so
360/// the request is rejected rather than downgraded to a silently-unsigned tag.
361/// Shared by the real-run guard and the dry-run preview so the two cannot
362/// diverge on wording or on whether the case errors at all.
363const SIGN_VIA_API_UNSUPPORTED: &str = "cannot create a signed tag via the GitHub API: the API creates the tag object \
364     server-side and cannot apply a local signature (sign a tag locally instead of \
365     using git_api_tagging)";
366
367/// Cheap, side-effect-free probe of whether `gh_binary` can be spawned at all.
368///
369/// Runs `<gh> --version` and reports only whether the process launched (exit
370/// status is ignored). Used exclusively so the dry-run preview can mirror the
371/// real run's branch selection — API path vs local-signing fallback — WITHOUT
372/// issuing the real `gh api` tag-creation call.
373fn gh_is_available(gh_binary: &Path) -> bool {
374    Command::new(gh_binary)
375        .arg("--version")
376        .stdout(std::process::Stdio::null())
377        .stderr(std::process::Stdio::null())
378        .status()
379        .is_ok()
380}
381
382/// Create a tag via the GitHub API (using the `gh` CLI).
383///
384/// This avoids the need for local git push access. Requires the `gh` CLI to be
385/// installed and authenticated (`gh auth login`). The GitHub API creates a
386/// lightweight tag object pointing at the HEAD commit on the default branch.
387///
388/// Falls back to [`create_and_push_tag_in`] if `gh` is not available.
389///
390/// `slug` is the resolved repository identity (config override -> remote),
391/// supplied by the caller so the API target and the rest of the pipeline agree
392/// on one owner/repo rather than this function re-deriving its own.
393pub fn create_tag_via_github_api(
394    slug: &RepoSlug,
395    tag: &str,
396    message: &str,
397    dry_run: bool,
398    sign: bool,
399    log: &crate::log::StageLogger,
400    strict: bool,
401) -> Result<()> {
402    create_tag_via_github_api_in(
403        &std::env::current_dir()?,
404        Path::new("gh"),
405        slug,
406        tag,
407        message,
408        dry_run,
409        sign,
410        log,
411        strict,
412    )
413}
414
415/// Path-taking sibling of [`create_tag_via_github_api`].
416///
417/// `cwd` is the repository the tag should be created against (used for the
418/// `git rev-parse HEAD` lookup and the local `git tag -a` fallback when
419/// `gh_binary` is missing). `slug` is the resolved owner/repo. `gh_binary` is
420/// the path to the `gh` CLI; pass `Path::new("gh")` to keep the production
421/// PATH-lookup behavior.
422#[allow(clippy::too_many_arguments)]
423pub fn create_tag_via_github_api_in(
424    cwd: &Path,
425    gh_binary: &Path,
426    slug: &RepoSlug,
427    tag: &str,
428    message: &str,
429    dry_run: bool,
430    sign: bool,
431    log: &crate::log::StageLogger,
432    strict: bool,
433) -> Result<()> {
434    if dry_run {
435        // Only a *signed* request needs to resolve which branch the real run
436        // takes — with `gh` present the API path rejects the signature, with
437        // `gh` missing the local-signing fallback honors it — so probe (a
438        // side-effect-free `gh --version`, never the real `gh api` tag call)
439        // ONLY then, letting a `dry_run + sign` request surface the same
440        // incompatibility the real run would. A non-signed preview never shells
441        // out: it reports the configured API-tagging intent uniformly (the real
442        // run's common `gh`-present branch), so a push preview contacts nothing.
443        if sign {
444            if gh_is_available(gh_binary) {
445                anyhow::bail!("{}", SIGN_VIA_API_UNSUPPORTED);
446            }
447            if strict {
448                anyhow::bail!("gh CLI not found, cannot create tag via GitHub API (strict mode)");
449            }
450            log.warn("gh CLI not found, falling back to local git tag + push");
451            return create_and_push_tag_in(cwd, tag, message, dry_run, sign, log, strict);
452        }
453        log.status(&format!(
454            "(dry-run) would create tag {} via GitHub API (\"{}\")",
455            tag, message
456        ));
457        return Ok(());
458    }
459
460    let owner = slug.owner();
461    let repo = slug.name();
462
463    // Get the current HEAD SHA to point the tag at — via the canonical
464    // single sha resolver, not a private `rev-parse` here.
465    let sha = super::get_head_commit_in(cwd)?;
466
467    let body = serde_json::json!({
468        "tag": tag,
469        "message": message,
470        "object": sha,
471        "type": "commit",
472        "tagger": {
473            "name": git_output_in(cwd, &["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
474            "email": git_output_in(cwd, &["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
475            "date": crate::sde::resolve_now().to_rfc3339(),
476        }
477    });
478
479    let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
480    let response = match gh_api_post_with_binary(gh_binary, &tag_endpoint, &body, log) {
481        Ok(resp) => resp,
482        Err(e) => {
483            if e.to_string().contains("failed to spawn gh CLI") {
484                if strict {
485                    anyhow::bail!(
486                        "gh CLI not found, cannot create tag via GitHub API (strict mode)"
487                    );
488                }
489                log.warn("gh CLI not found, falling back to local git tag + push");
490                // Contract: the API path proper rejects `sign == true` (guarded
491                // below, before the `gh api` POST); only THIS local fallback can
492                // honor signing, because the API-created tag object is minted
493                // server-side and cannot carry a local GPG/SSH signature.
494                return create_and_push_tag_in(cwd, tag, message, dry_run, sign, log, strict);
495            }
496            return Err(e);
497        }
498    };
499
500    // API path proper: `gh` is present and the tag object was minted
501    // server-side, where a local GPG/SSH key cannot reach it. A caller that
502    // requested a signature here would otherwise get a silently-unsigned tag,
503    // so reject rather than downgrade. The gh-missing branch above already
504    // returned through the local-signing fallback, so this never fires for it.
505    if sign {
506        anyhow::bail!("{}", SIGN_VIA_API_UNSUPPORTED);
507    }
508
509    let tag_sha = response["sha"]
510        .as_str()
511        .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
512
513    let ref_body = serde_json::json!({
514        "ref": format!("refs/tags/{}", tag),
515        "sha": tag_sha,
516    });
517
518    let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
519    gh_api_post_with_binary(gh_binary, &ref_endpoint, &ref_body, log)?;
520
521    Ok(())
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    /// `dry_run=true` must short-circuit before any subprocess spawn.
529    #[test]
530    fn create_tag_dry_run_short_circuits() {
531        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
532        let slug = RepoSlug::for_test("owner", "repo");
533        // Even with no git repo / no gh CLI, dry-run must succeed.
534        let result = create_tag_via_github_api(&slug, "v1.0.0", "msg", true, false, &log, false);
535        assert!(result.is_ok(), "dry-run must succeed: {result:?}");
536    }
537
538    /// Redact: the token must be replaced with the literal `$GITHUB_TOKEN`
539    /// placeholder when it appears verbatim in the stderr output. Catches
540    /// the case where `gh` echoes the auth header in a verbose error.
541    #[test]
542    fn redact_gh_stderr_replaces_token_value() {
543        let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
544        let stderr = format!("HTTP 401: token {secret} is invalid");
545        let redacted = redact_gh_stderr(&stderr, Some(secret));
546        // Exact-output, not absence-only: guards the env-value masking
547        // layer — the token is replaced by its `$NAME` placeholder.
548        assert_eq!(redacted, "HTTP 401: token $GITHUB_TOKEN is invalid");
549    }
550
551    #[test]
552    fn redact_gh_stderr_with_no_token_still_strips_url_creds() {
553        // Inline URL credentials must be redacted even with no explicit
554        // token argument.
555        let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
556        let redacted = redact_gh_stderr(stderr, None);
557        // Exact-output, not absence-only: guards the inline URL-credential
558        // stripping layer — userinfo becomes `<redacted>`.
559        assert_eq!(
560            redacted,
561            "auth failed: https://<redacted>@github.com/o/r.git rejected"
562        );
563    }
564
565    #[test]
566    fn redact_gh_stderr_empty_token_is_noop_on_token_field() {
567        // An empty Some("") token must not pollute the env vector with a
568        // zero-length value (that would match every position in the string).
569        let stderr = "plain error message without credentials";
570        let redacted = redact_gh_stderr(stderr, Some(""));
571        assert_eq!(redacted, stderr);
572    }
573
574    /// A missing `gh` binary must degrade to `None` (never an error/panic):
575    /// the changelog pipeline keeps name-based rendering. The failure is
576    /// memoized, so the second call returns the cached `None` without
577    /// re-attempting a spawn.
578    #[test]
579    fn commit_author_login_missing_binary_degrades_to_none_and_caches() {
580        let tmp = tempfile::tempdir().unwrap();
581        let missing = tmp.path().join("nonexistent-gh");
582        let first = commit_author_login_with_binary(
583            &missing,
584            "owner-cal-test",
585            "repo-cal-test",
586            "a@example.com",
587            "0123456789abcdef0123456789abcdef01234567",
588            None,
589        );
590        assert_eq!(first, None, "missing binary must yield None");
591        // Cached-failure path: same (owner, repo, email) key short-circuits
592        // before any spawn attempt.
593        let second = commit_author_login_with_binary(
594            &missing,
595            "owner-cal-test",
596            "repo-cal-test",
597            "a@example.com",
598            "fedcba9876543210fedcba9876543210fedcba98",
599            None,
600        );
601        assert_eq!(second, None);
602    }
603
604    /// Empty inputs short-circuit to `None` without touching the cache or
605    /// spawning anything.
606    #[test]
607    fn commit_author_login_empty_inputs_are_none() {
608        let gh = Path::new("gh");
609        assert_eq!(
610            commit_author_login_with_binary(gh, "", "r", "e", "s", None),
611            None
612        );
613        assert_eq!(
614            commit_author_login_with_binary(gh, "o", "", "e", "s", None),
615            None
616        );
617        assert_eq!(
618            commit_author_login_with_binary(gh, "o", "r", "", "s", None),
619            None
620        );
621        assert_eq!(
622            commit_author_login_with_binary(gh, "o", "r", "e", "", None),
623            None
624        );
625    }
626
627    /// Chain order: explicit beats `ANODIZER_GITHUB_TOKEN` beats
628    /// `GITHUB_TOKEN`; empty strings are absent at every link. Uses a
629    /// map-backed env closure — no process-env mutation, no network.
630    #[test]
631    fn resolve_github_token_chain_order_and_empty_filtering() {
632        let env_with = |pairs: &[(&str, &str)]| {
633            let map: HashMap<String, String> = pairs
634                .iter()
635                .map(|(k, v)| (k.to_string(), v.to_string()))
636                .collect();
637            move |key: &str| map.get(key).cloned()
638        };
639
640        let both = env_with(&[
641            ("ANODIZER_GITHUB_TOKEN", "anod-tok"),
642            ("GITHUB_TOKEN", "gh-tok"),
643        ]);
644        assert_eq!(
645            resolve_github_token_with_env(Some("explicit-tok"), &both),
646            Some("explicit-tok".to_string()),
647            "explicit token must win over both env vars"
648        );
649        assert_eq!(
650            resolve_github_token_with_env(None, &both),
651            Some("anod-tok".to_string()),
652            "ANODIZER_GITHUB_TOKEN must win over GITHUB_TOKEN"
653        );
654
655        let gh_only = env_with(&[("GITHUB_TOKEN", "gh-tok")]);
656        assert_eq!(
657            resolve_github_token_with_env(None, &gh_only),
658            Some("gh-tok".to_string()),
659            "GITHUB_TOKEN alone must resolve (the CI-gap pin)"
660        );
661        assert_eq!(
662            resolve_github_token_with_env(Some(""), &gh_only),
663            Some("gh-tok".to_string()),
664            "empty explicit token must fall through to env"
665        );
666
667        let empty_anod = env_with(&[("ANODIZER_GITHUB_TOKEN", ""), ("GITHUB_TOKEN", "gh-tok")]);
668        assert_eq!(
669            resolve_github_token_with_env(None, &empty_anod),
670            Some("gh-tok".to_string()),
671            "empty ANODIZER_GITHUB_TOKEN (GHA missing-secret materialization) must not short-circuit"
672        );
673
674        let none = env_with(&[]);
675        assert_eq!(resolve_github_token_with_env(None, &none), None);
676        assert_eq!(resolve_github_token_with_env(Some(""), &none), None);
677    }
678
679    /// `gh_api_get_with_binary` must surface a user-actionable spawn
680    /// failure when the binary path doesn't exist on disk.
681    ///
682    /// Drives the function with a temp-dir-relative path that points to
683    /// nothing, asserting the error mentions "spawn gh" so the operator
684    /// can correlate it with their missing-`gh` install state.
685    #[test]
686    fn gh_api_get_with_binary_bails_when_binary_missing() {
687        let tmp = tempfile::tempdir().unwrap();
688        let missing = tmp.path().join("nonexistent-gh");
689        let err = gh_api_get_with_binary(&missing, "/repos/x/y", None)
690            .expect_err("missing binary must error");
691        let msg = err.to_string();
692        assert!(
693            msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
694            "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
695        );
696    }
697
698    /// Same guarantee for the paginated sibling.
699    #[test]
700    fn gh_api_get_paginated_with_binary_bails_when_binary_missing() {
701        let tmp = tempfile::tempdir().unwrap();
702        let missing = tmp.path().join("nonexistent-gh");
703        let err = gh_api_get_paginated_with_binary(&missing, "/repos/x/y", None)
704            .expect_err("missing binary must error");
705        let msg = err.to_string();
706        assert!(
707            msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
708            "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
709        );
710    }
711
712    /// `create_tag_via_github_api_in` with `strict=true` must error
713    /// when `cwd` is not a git repo — the HEAD-sha resolver
714    /// (`get_head_commit_in`) drives `git rev-parse HEAD` and fails there.
715    ///
716    /// Skips when `git` isn't on PATH (mirrors `tool_on_path` patterns
717    /// elsewhere in the suite).
718    #[test]
719    fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
720        // spawn-retry-ok: this is a git *availability* probe — an Err means git
721        // is absent (skip the test via the success() guard), not a transient
722        // spawn-init failure to retry; routing it through the panicking helper
723        // would crash on a git-less host instead of skipping.
724        if !Command::new("git")
725            .arg("--version")
726            .output()
727            .map(|o| o.status.success())
728            .unwrap_or(false)
729        {
730            return;
731        }
732        let tmp = tempfile::tempdir().unwrap();
733        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
734        let slug = RepoSlug::for_test("owner", "repo");
735        let err = create_tag_via_github_api_in(
736            tmp.path(),
737            Path::new("gh"),
738            &slug,
739            "v1.0.0",
740            "msg",
741            false,
742            false,
743            &log,
744            true,
745        )
746        .expect_err("non-git cwd must error");
747        let msg = err.to_string();
748        assert!(
749            msg.contains("git") || msg.contains("remote"),
750            "expected error to mention git or remote, got: {msg}"
751        );
752    }
753
754    /// Dry-run short-circuit must also fire on the cwd-injectable entry
755    /// point — covers the new branch without re-hitting the inner
756    /// detection codepath.
757    #[test]
758    fn create_tag_via_github_api_in_dry_run_short_circuits() {
759        let tmp = tempfile::tempdir().unwrap();
760        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
761        let slug = RepoSlug::for_test("owner", "repo");
762        let result = create_tag_via_github_api_in(
763            tmp.path(),
764            Path::new("gh"),
765            &slug,
766            "v1.0.0",
767            "msg",
768            true,
769            false,
770            &log,
771            false,
772        );
773        assert!(result.is_ok(), "dry-run must succeed: {result:?}");
774    }
775
776    /// P2: with `gh` present, a `dry_run + sign` request on the API path must
777    /// be REJECTED — mirroring the real run's post-POST sign guard — so the
778    /// preview never masks the sign incompatibility. A spawnable stub `gh`
779    /// makes `gh_is_available` true without any real API call being reached
780    /// (the bail fires in the dry-run block first).
781    #[cfg(unix)]
782    #[test]
783    fn create_tag_via_github_api_in_dry_run_sign_rejected_when_gh_present() {
784        use std::os::unix::fs::PermissionsExt;
785        let tmp = tempfile::tempdir().unwrap();
786        let gh = tmp.path().join("gh");
787        std::fs::write(&gh, "#!/bin/sh\necho 'gh version 2.0.0'\n").unwrap();
788        std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).unwrap();
789        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
790        let slug = RepoSlug::for_test("owner", "repo");
791        let err = create_tag_via_github_api_in(
792            tmp.path(),
793            &gh,
794            &slug,
795            "v1.0.0",
796            "msg",
797            true, // dry_run
798            true, // sign
799            &log,
800            false,
801        )
802        .expect_err("dry-run + sign on the API path must be rejected");
803        assert!(
804            err.to_string().contains("signed tag via the GitHub API"),
805            "dry-run rejection must match the real-run guard message, got: {err}"
806        );
807    }
808
809    /// P2 companion: with `gh` ABSENT, `dry_run + sign` must NOT be rejected —
810    /// the real run would take the local-signing fallback, so the preview must
811    /// mirror that (it previews `create_and_push_tag_in` in dry-run, which can
812    /// sign locally) and return Ok rather than erroring on the API-path guard.
813    #[test]
814    fn create_tag_via_github_api_in_dry_run_sign_gh_missing_previews_local() {
815        let tmp = tempfile::tempdir().unwrap();
816        let missing = tmp.path().join("nonexistent-gh");
817        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
818        let slug = RepoSlug::for_test("owner", "repo");
819        let result = create_tag_via_github_api_in(
820            tmp.path(),
821            &missing,
822            &slug,
823            "v1.0.0",
824            "msg",
825            true, // dry_run
826            true, // sign
827            &log,
828            false,
829        );
830        assert!(
831            result.is_ok(),
832            "gh-missing dry-run + sign must preview the local fallback, not reject: {result:?}"
833        );
834    }
835}