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 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
50pub 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
79type LoginCacheKey = (String, String, String);
81
82static COMMIT_LOGIN_CACHE: OnceLock<Mutex<HashMap<LoginCacheKey, Option<String>>>> =
87 OnceLock::new();
88
89pub 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
110pub 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 {
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
160pub const GITHUB_TOKEN_ENV_LADDER: &[&str] = &["ANODIZER_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"];
169
170pub fn github_token_env_hint() -> String {
178 GITHUB_TOKEN_ENV_LADDER.join(" or ")
179}
180
181pub fn github_token_hint() -> String {
186 format!("set {}, or pass --token", github_token_env_hint())
187}
188
189pub 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
211pub fn resolve_github_token(explicit: Option<&str>) -> Option<String> {
215 resolve_github_token_with_env(explicit, &|key| std::env::var(key).ok())
216}
217
218fn 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
235pub 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
243pub 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 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 return Ok(vec![val]);
277 }
278
279 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 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 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
325fn 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 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
358const 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
367fn 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
382pub 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#[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 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 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 return create_and_push_tag_in(cwd, tag, message, dry_run, sign, log, strict);
495 }
496 return Err(e);
497 }
498 };
499
500 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 #[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 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 #[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 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 let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
556 let redacted = redact_gh_stderr(stderr, None);
557 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 let stderr = "plain error message without credentials";
570 let redacted = redact_gh_stderr(stderr, Some(""));
571 assert_eq!(redacted, stderr);
572 }
573
574 #[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 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 #[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 #[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 #[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 #[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 #[test]
719 fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
720 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 #[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 #[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, true, &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 #[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, true, &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}