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