use anyhow::{Context as _, Result, bail};
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::sync::{Mutex, OnceLock};
use super::git_output_in;
use super::slug::RepoSlug;
use super::tags::create_and_push_tag_in;
pub fn gh_api_get(endpoint: &str, token: Option<&str>) -> Result<serde_json::Value> {
gh_api_get_with_binary(Path::new("gh"), endpoint, token)
}
pub fn gh_api_get_with_binary(
gh_binary: &Path,
endpoint: &str,
token: Option<&str>,
) -> Result<serde_json::Value> {
let mut cmd = Command::new(gh_binary);
cmd.args(["api", endpoint]);
if let Some(tok) = token {
cmd.env("GITHUB_TOKEN", tok);
}
let output = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
if !output.status.success() {
let stderr_raw = String::from_utf8_lossy(&output.stderr);
let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
bail!("{}", redact_gh_stderr(&raw, token));
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).context("failed to parse gh api response")
}
type LoginCacheKey = (String, String, String);
static COMMIT_LOGIN_CACHE: OnceLock<Mutex<HashMap<LoginCacheKey, Option<String>>>> =
OnceLock::new();
pub fn commit_author_login(
owner: &str,
repo: &str,
email: &str,
sha: &str,
token: Option<&str>,
) -> Option<String> {
commit_author_login_with_binary(Path::new("gh"), owner, repo, email, sha, token)
}
pub fn commit_author_login_with_binary(
gh_binary: &Path,
owner: &str,
repo: &str,
email: &str,
sha: &str,
token: Option<&str>,
) -> Option<String> {
if owner.is_empty() || repo.is_empty() || email.is_empty() || sha.is_empty() {
return None;
}
let key = (owner.to_string(), repo.to_string(), email.to_string());
let cache = COMMIT_LOGIN_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
{
let guard = match cache.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(hit) = guard.get(&key) {
return hit.clone();
}
}
let endpoint = format!("/repos/{owner}/{repo}/commits/{sha}");
let resolved = match gh_api_get_with_binary(gh_binary, &endpoint, token) {
Ok(v) => v
.pointer("/author/login")
.and_then(|l| l.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string),
Err(e) => {
tracing::debug!(
"commit_author_login: lookup for {} failed (keeping name-based rendering): {}",
email,
e
);
None
}
};
let mut guard = match cache.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.insert(key, resolved.clone());
resolved
}
pub fn resolve_github_token_with_env(
explicit: Option<&str>,
env: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
let non_empty = |s: String| if s.is_empty() { None } else { Some(s) };
explicit
.filter(|t| !t.is_empty())
.map(str::to_string)
.or_else(|| env("ANODIZER_GITHUB_TOKEN").and_then(non_empty))
.or_else(|| env("GITHUB_TOKEN").and_then(non_empty))
}
pub fn resolve_github_token(explicit: Option<&str>) -> Option<String> {
resolve_github_token_with_env(explicit, &|key| std::env::var(key).ok())
}
fn redact_gh_stderr(stderr: &str, token: Option<&str>) -> String {
let mut env: Vec<(String, String)> = std::env::vars().collect();
if let Some(tok) = token
&& !tok.is_empty()
{
env.push(("GITHUB_TOKEN".to_string(), tok.to_string()));
}
crate::redact::with_env(stderr, &env)
}
pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
gh_api_get_paginated_with_binary(Path::new("gh"), endpoint, token)
}
pub fn gh_api_get_paginated_with_binary(
gh_binary: &Path,
endpoint: &str,
token: Option<&str>,
) -> Result<Vec<serde_json::Value>> {
let mut cmd = Command::new(gh_binary);
cmd.args(["api", "--paginate", endpoint]);
if let Some(tok) = token {
cmd.env("GITHUB_TOKEN", tok);
}
let output = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
if !output.status.success() {
let stderr_raw = String::from_utf8_lossy(&output.stderr);
let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
bail!("{}", redact_gh_stderr(&raw, token));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
return Ok(arr);
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
return Ok(vec![val]);
}
let mut all_items = Vec::new();
for chunk in stdout.split_inclusive(']') {
let trimmed = chunk.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(serde_json::Value::Array(arr)) =
serde_json::from_str::<serde_json::Value>(trimmed)
{
all_items.extend(arr);
} else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
all_items.push(val);
} else {
let snippet = &trimmed[..trimmed.len().min(200)];
let redacted = crate::redact::redact_process_env(snippet);
tracing::warn!(
"gh_api_get_paginated: failed to parse JSON chunk ({} bytes): {:?}",
trimmed.len(),
redacted,
);
}
}
Ok(all_items)
}
fn gh_api_post_with_binary(
gh_binary: &Path,
endpoint: &str,
body: &serde_json::Value,
log: &crate::log::StageLogger,
) -> Result<serde_json::Value> {
let body_str = serde_json::to_string(body)?;
let mut cmd = Command::new(gh_binary);
cmd.args(["api", "--method", "POST", endpoint, "--input", "-"]);
let redacting_log = log.clone().with_env(std::env::vars().collect::<Vec<_>>());
let output = crate::run::run_checked_with_stdin(
&mut cmd,
body_str.as_bytes(),
&redacting_log,
"gh CLI",
)?;
let response: serde_json::Value = serde_json::from_slice(&output.stdout)
.with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
Ok(response)
}
pub fn create_tag_via_github_api(
slug: &RepoSlug,
tag: &str,
message: &str,
dry_run: bool,
log: &crate::log::StageLogger,
strict: bool,
) -> Result<()> {
create_tag_via_github_api_in(
&std::env::current_dir()?,
Path::new("gh"),
slug,
tag,
message,
dry_run,
log,
strict,
)
}
#[allow(clippy::too_many_arguments)]
pub fn create_tag_via_github_api_in(
cwd: &Path,
gh_binary: &Path,
slug: &RepoSlug,
tag: &str,
message: &str,
dry_run: bool,
log: &crate::log::StageLogger,
strict: bool,
) -> Result<()> {
if dry_run {
log.status(&format!(
"(dry-run) would create tag {} via GitHub API (\"{}\")",
tag, message
));
return Ok(());
}
let owner = slug.owner();
let repo = slug.name();
let sha = super::get_head_commit_in(cwd)?;
let body = serde_json::json!({
"tag": tag,
"message": message,
"object": sha,
"type": "commit",
"tagger": {
"name": git_output_in(cwd, &["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
"email": git_output_in(cwd, &["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
"date": crate::sde::resolve_now().to_rfc3339(),
}
});
let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
let response = match gh_api_post_with_binary(gh_binary, &tag_endpoint, &body, log) {
Ok(resp) => resp,
Err(e) => {
if e.to_string().contains("failed to spawn gh CLI") {
if strict {
anyhow::bail!(
"gh CLI not found, cannot create tag via GitHub API (strict mode)"
);
}
log.warn("gh CLI not found, falling back to local git tag + push");
return create_and_push_tag_in(cwd, tag, message, dry_run, log, strict);
}
return Err(e);
}
};
let tag_sha = response["sha"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
let ref_body = serde_json::json!({
"ref": format!("refs/tags/{}", tag),
"sha": tag_sha,
});
let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
gh_api_post_with_binary(gh_binary, &ref_endpoint, &ref_body, log)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_tag_dry_run_short_circuits() {
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let slug = RepoSlug::for_test("owner", "repo");
let result = create_tag_via_github_api(&slug, "v1.0.0", "msg", true, &log, false);
assert!(result.is_ok(), "dry-run must succeed: {result:?}");
}
#[test]
fn redact_gh_stderr_replaces_token_value() {
let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
let stderr = format!("HTTP 401: token {secret} is invalid");
let redacted = redact_gh_stderr(&stderr, Some(secret));
assert_eq!(redacted, "HTTP 401: token $GITHUB_TOKEN is invalid");
}
#[test]
fn redact_gh_stderr_with_no_token_still_strips_url_creds() {
let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
let redacted = redact_gh_stderr(stderr, None);
assert_eq!(
redacted,
"auth failed: https://<redacted>@github.com/o/r.git rejected"
);
}
#[test]
fn redact_gh_stderr_empty_token_is_noop_on_token_field() {
let stderr = "plain error message without credentials";
let redacted = redact_gh_stderr(stderr, Some(""));
assert_eq!(redacted, stderr);
}
#[test]
fn commit_author_login_missing_binary_degrades_to_none_and_caches() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("nonexistent-gh");
let first = commit_author_login_with_binary(
&missing,
"owner-cal-test",
"repo-cal-test",
"a@example.com",
"0123456789abcdef0123456789abcdef01234567",
None,
);
assert_eq!(first, None, "missing binary must yield None");
let second = commit_author_login_with_binary(
&missing,
"owner-cal-test",
"repo-cal-test",
"a@example.com",
"fedcba9876543210fedcba9876543210fedcba98",
None,
);
assert_eq!(second, None);
}
#[test]
fn commit_author_login_empty_inputs_are_none() {
let gh = Path::new("gh");
assert_eq!(
commit_author_login_with_binary(gh, "", "r", "e", "s", None),
None
);
assert_eq!(
commit_author_login_with_binary(gh, "o", "", "e", "s", None),
None
);
assert_eq!(
commit_author_login_with_binary(gh, "o", "r", "", "s", None),
None
);
assert_eq!(
commit_author_login_with_binary(gh, "o", "r", "e", "", None),
None
);
}
#[test]
fn resolve_github_token_chain_order_and_empty_filtering() {
let env_with = |pairs: &[(&str, &str)]| {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |key: &str| map.get(key).cloned()
};
let both = env_with(&[
("ANODIZER_GITHUB_TOKEN", "anod-tok"),
("GITHUB_TOKEN", "gh-tok"),
]);
assert_eq!(
resolve_github_token_with_env(Some("explicit-tok"), &both),
Some("explicit-tok".to_string()),
"explicit token must win over both env vars"
);
assert_eq!(
resolve_github_token_with_env(None, &both),
Some("anod-tok".to_string()),
"ANODIZER_GITHUB_TOKEN must win over GITHUB_TOKEN"
);
let gh_only = env_with(&[("GITHUB_TOKEN", "gh-tok")]);
assert_eq!(
resolve_github_token_with_env(None, &gh_only),
Some("gh-tok".to_string()),
"GITHUB_TOKEN alone must resolve (the CI-gap pin)"
);
assert_eq!(
resolve_github_token_with_env(Some(""), &gh_only),
Some("gh-tok".to_string()),
"empty explicit token must fall through to env"
);
let empty_anod = env_with(&[("ANODIZER_GITHUB_TOKEN", ""), ("GITHUB_TOKEN", "gh-tok")]);
assert_eq!(
resolve_github_token_with_env(None, &empty_anod),
Some("gh-tok".to_string()),
"empty ANODIZER_GITHUB_TOKEN (GHA missing-secret materialization) must not short-circuit"
);
let none = env_with(&[]);
assert_eq!(resolve_github_token_with_env(None, &none), None);
assert_eq!(resolve_github_token_with_env(Some(""), &none), None);
}
#[test]
fn gh_api_get_with_binary_bails_when_binary_missing() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("nonexistent-gh");
let err = gh_api_get_with_binary(&missing, "/repos/x/y", None)
.expect_err("missing binary must error");
let msg = err.to_string();
assert!(
msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
"expected actionable error mentioning spawn gh or the binary path, got: {msg}"
);
}
#[test]
fn gh_api_get_paginated_with_binary_bails_when_binary_missing() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("nonexistent-gh");
let err = gh_api_get_paginated_with_binary(&missing, "/repos/x/y", None)
.expect_err("missing binary must error");
let msg = err.to_string();
assert!(
msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
"expected actionable error mentioning spawn gh or the binary path, got: {msg}"
);
}
#[test]
fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
if !Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
return;
}
let tmp = tempfile::tempdir().unwrap();
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let slug = RepoSlug::for_test("owner", "repo");
let err = create_tag_via_github_api_in(
tmp.path(),
Path::new("gh"),
&slug,
"v1.0.0",
"msg",
false,
&log,
true,
)
.expect_err("non-git cwd must error");
let msg = err.to_string();
assert!(
msg.contains("git") || msg.contains("remote"),
"expected error to mention git or remote, got: {msg}"
);
}
#[test]
fn create_tag_via_github_api_in_dry_run_short_circuits() {
let tmp = tempfile::tempdir().unwrap();
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let slug = RepoSlug::for_test("owner", "repo");
let result = create_tag_via_github_api_in(
tmp.path(),
Path::new("gh"),
&slug,
"v1.0.0",
"msg",
true,
&log,
false,
);
assert!(result.is_ok(), "dry-run must succeed: {result:?}");
}
}