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
358pub fn create_tag_via_github_api(
370 slug: &RepoSlug,
371 tag: &str,
372 message: &str,
373 dry_run: bool,
374 log: &crate::log::StageLogger,
375 strict: bool,
376) -> Result<()> {
377 create_tag_via_github_api_in(
378 &std::env::current_dir()?,
379 Path::new("gh"),
380 slug,
381 tag,
382 message,
383 dry_run,
384 log,
385 strict,
386 )
387}
388
389#[allow(clippy::too_many_arguments)]
397pub fn create_tag_via_github_api_in(
398 cwd: &Path,
399 gh_binary: &Path,
400 slug: &RepoSlug,
401 tag: &str,
402 message: &str,
403 dry_run: bool,
404 log: &crate::log::StageLogger,
405 strict: bool,
406) -> Result<()> {
407 if dry_run {
408 log.status(&format!(
409 "(dry-run) would create tag {} via GitHub API (\"{}\")",
410 tag, message
411 ));
412 return Ok(());
413 }
414
415 let owner = slug.owner();
416 let repo = slug.name();
417
418 let sha = super::get_head_commit_in(cwd)?;
421
422 let body = serde_json::json!({
423 "tag": tag,
424 "message": message,
425 "object": sha,
426 "type": "commit",
427 "tagger": {
428 "name": git_output_in(cwd, &["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
429 "email": git_output_in(cwd, &["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
430 "date": crate::sde::resolve_now().to_rfc3339(),
431 }
432 });
433
434 let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
435 let response = match gh_api_post_with_binary(gh_binary, &tag_endpoint, &body, log) {
436 Ok(resp) => resp,
437 Err(e) => {
438 if e.to_string().contains("failed to spawn gh CLI") {
439 if strict {
440 anyhow::bail!(
441 "gh CLI not found, cannot create tag via GitHub API (strict mode)"
442 );
443 }
444 log.warn("gh CLI not found, falling back to local git tag + push");
445 return create_and_push_tag_in(cwd, tag, message, dry_run, log, strict);
446 }
447 return Err(e);
448 }
449 };
450
451 let tag_sha = response["sha"]
452 .as_str()
453 .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
454
455 let ref_body = serde_json::json!({
456 "ref": format!("refs/tags/{}", tag),
457 "sha": tag_sha,
458 });
459
460 let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
461 gh_api_post_with_binary(gh_binary, &ref_endpoint, &ref_body, log)?;
462
463 Ok(())
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
472 fn create_tag_dry_run_short_circuits() {
473 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
474 let slug = RepoSlug::for_test("owner", "repo");
475 let result = create_tag_via_github_api(&slug, "v1.0.0", "msg", true, &log, false);
477 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
478 }
479
480 #[test]
484 fn redact_gh_stderr_replaces_token_value() {
485 let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
486 let stderr = format!("HTTP 401: token {secret} is invalid");
487 let redacted = redact_gh_stderr(&stderr, Some(secret));
488 assert_eq!(redacted, "HTTP 401: token $GITHUB_TOKEN is invalid");
491 }
492
493 #[test]
494 fn redact_gh_stderr_with_no_token_still_strips_url_creds() {
495 let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
498 let redacted = redact_gh_stderr(stderr, None);
499 assert_eq!(
502 redacted,
503 "auth failed: https://<redacted>@github.com/o/r.git rejected"
504 );
505 }
506
507 #[test]
508 fn redact_gh_stderr_empty_token_is_noop_on_token_field() {
509 let stderr = "plain error message without credentials";
512 let redacted = redact_gh_stderr(stderr, Some(""));
513 assert_eq!(redacted, stderr);
514 }
515
516 #[test]
521 fn commit_author_login_missing_binary_degrades_to_none_and_caches() {
522 let tmp = tempfile::tempdir().unwrap();
523 let missing = tmp.path().join("nonexistent-gh");
524 let first = commit_author_login_with_binary(
525 &missing,
526 "owner-cal-test",
527 "repo-cal-test",
528 "a@example.com",
529 "0123456789abcdef0123456789abcdef01234567",
530 None,
531 );
532 assert_eq!(first, None, "missing binary must yield None");
533 let second = commit_author_login_with_binary(
536 &missing,
537 "owner-cal-test",
538 "repo-cal-test",
539 "a@example.com",
540 "fedcba9876543210fedcba9876543210fedcba98",
541 None,
542 );
543 assert_eq!(second, None);
544 }
545
546 #[test]
549 fn commit_author_login_empty_inputs_are_none() {
550 let gh = Path::new("gh");
551 assert_eq!(
552 commit_author_login_with_binary(gh, "", "r", "e", "s", None),
553 None
554 );
555 assert_eq!(
556 commit_author_login_with_binary(gh, "o", "", "e", "s", None),
557 None
558 );
559 assert_eq!(
560 commit_author_login_with_binary(gh, "o", "r", "", "s", None),
561 None
562 );
563 assert_eq!(
564 commit_author_login_with_binary(gh, "o", "r", "e", "", None),
565 None
566 );
567 }
568
569 #[test]
573 fn resolve_github_token_chain_order_and_empty_filtering() {
574 let env_with = |pairs: &[(&str, &str)]| {
575 let map: HashMap<String, String> = pairs
576 .iter()
577 .map(|(k, v)| (k.to_string(), v.to_string()))
578 .collect();
579 move |key: &str| map.get(key).cloned()
580 };
581
582 let both = env_with(&[
583 ("ANODIZER_GITHUB_TOKEN", "anod-tok"),
584 ("GITHUB_TOKEN", "gh-tok"),
585 ]);
586 assert_eq!(
587 resolve_github_token_with_env(Some("explicit-tok"), &both),
588 Some("explicit-tok".to_string()),
589 "explicit token must win over both env vars"
590 );
591 assert_eq!(
592 resolve_github_token_with_env(None, &both),
593 Some("anod-tok".to_string()),
594 "ANODIZER_GITHUB_TOKEN must win over GITHUB_TOKEN"
595 );
596
597 let gh_only = env_with(&[("GITHUB_TOKEN", "gh-tok")]);
598 assert_eq!(
599 resolve_github_token_with_env(None, &gh_only),
600 Some("gh-tok".to_string()),
601 "GITHUB_TOKEN alone must resolve (the CI-gap pin)"
602 );
603 assert_eq!(
604 resolve_github_token_with_env(Some(""), &gh_only),
605 Some("gh-tok".to_string()),
606 "empty explicit token must fall through to env"
607 );
608
609 let empty_anod = env_with(&[("ANODIZER_GITHUB_TOKEN", ""), ("GITHUB_TOKEN", "gh-tok")]);
610 assert_eq!(
611 resolve_github_token_with_env(None, &empty_anod),
612 Some("gh-tok".to_string()),
613 "empty ANODIZER_GITHUB_TOKEN (GHA missing-secret materialization) must not short-circuit"
614 );
615
616 let none = env_with(&[]);
617 assert_eq!(resolve_github_token_with_env(None, &none), None);
618 assert_eq!(resolve_github_token_with_env(Some(""), &none), None);
619 }
620
621 #[test]
628 fn gh_api_get_with_binary_bails_when_binary_missing() {
629 let tmp = tempfile::tempdir().unwrap();
630 let missing = tmp.path().join("nonexistent-gh");
631 let err = gh_api_get_with_binary(&missing, "/repos/x/y", None)
632 .expect_err("missing binary must error");
633 let msg = err.to_string();
634 assert!(
635 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
636 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
637 );
638 }
639
640 #[test]
642 fn gh_api_get_paginated_with_binary_bails_when_binary_missing() {
643 let tmp = tempfile::tempdir().unwrap();
644 let missing = tmp.path().join("nonexistent-gh");
645 let err = gh_api_get_paginated_with_binary(&missing, "/repos/x/y", None)
646 .expect_err("missing binary must error");
647 let msg = err.to_string();
648 assert!(
649 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
650 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
651 );
652 }
653
654 #[test]
661 fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
662 if !Command::new("git")
667 .arg("--version")
668 .output()
669 .map(|o| o.status.success())
670 .unwrap_or(false)
671 {
672 return;
673 }
674 let tmp = tempfile::tempdir().unwrap();
675 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
676 let slug = RepoSlug::for_test("owner", "repo");
677 let err = create_tag_via_github_api_in(
678 tmp.path(),
679 Path::new("gh"),
680 &slug,
681 "v1.0.0",
682 "msg",
683 false,
684 &log,
685 true,
686 )
687 .expect_err("non-git cwd must error");
688 let msg = err.to_string();
689 assert!(
690 msg.contains("git") || msg.contains("remote"),
691 "expected error to mention git or remote, got: {msg}"
692 );
693 }
694
695 #[test]
699 fn create_tag_via_github_api_in_dry_run_short_circuits() {
700 let tmp = tempfile::tempdir().unwrap();
701 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
702 let slug = RepoSlug::for_test("owner", "repo");
703 let result = create_tag_via_github_api_in(
704 tmp.path(),
705 Path::new("gh"),
706 &slug,
707 "v1.0.0",
708 "msg",
709 true,
710 &log,
711 false,
712 );
713 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
714 }
715}