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
11pub 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
19pub 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
35pub 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
66pub 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
95type LoginCacheKey = (String, String, String);
97
98static COMMIT_LOGIN_CACHE: OnceLock<Mutex<HashMap<LoginCacheKey, Option<String>>>> =
103 OnceLock::new();
104
105pub 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
126pub 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 {
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
176pub const GITHUB_TOKEN_ENV_LADDER: &[&str] = &["ANODIZER_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"];
185
186pub fn github_token_env_hint() -> String {
194 GITHUB_TOKEN_ENV_LADDER.join(" or ")
195}
196
197pub fn github_token_hint() -> String {
202 format!("set {}, or pass --token", github_token_env_hint())
203}
204
205pub 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
227pub fn resolve_github_token(explicit: Option<&str>) -> Option<String> {
231 resolve_github_token_with_env(explicit, &|key| std::env::var(key).ok())
232}
233
234fn 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
246fn 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
263pub 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
271pub 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 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 return Ok(vec![val]);
305 }
306
307 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 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 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
353fn 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 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
386const 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
395fn 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
410pub 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#[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 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 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 return create_and_push_tag_in(cwd, tag, message, dry_run, sign, log, strict);
523 }
524 return Err(e);
525 }
526 };
527
528 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 #[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 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 #[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 let redacted = redact_gh_stderr_with_env(&stderr, Some(secret), &[]);
577 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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[test]
750 fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
751 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 #[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 #[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, true, &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 #[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, true, &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}