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
50type LoginCacheKey = (String, String, String);
52
53static COMMIT_LOGIN_CACHE: OnceLock<Mutex<HashMap<LoginCacheKey, Option<String>>>> =
58 OnceLock::new();
59
60pub fn commit_author_login(
72 owner: &str,
73 repo: &str,
74 email: &str,
75 sha: &str,
76 token: Option<&str>,
77) -> Option<String> {
78 commit_author_login_with_binary(Path::new("gh"), owner, repo, email, sha, token)
79}
80
81pub fn commit_author_login_with_binary(
84 gh_binary: &Path,
85 owner: &str,
86 repo: &str,
87 email: &str,
88 sha: &str,
89 token: Option<&str>,
90) -> Option<String> {
91 if owner.is_empty() || repo.is_empty() || email.is_empty() || sha.is_empty() {
92 return None;
93 }
94 let key = (owner.to_string(), repo.to_string(), email.to_string());
95 let cache = COMMIT_LOGIN_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
96 {
99 let guard = match cache.lock() {
100 Ok(g) => g,
101 Err(poisoned) => poisoned.into_inner(),
102 };
103 if let Some(hit) = guard.get(&key) {
104 return hit.clone();
105 }
106 }
107 let endpoint = format!("/repos/{owner}/{repo}/commits/{sha}");
108 let resolved = match gh_api_get_with_binary(gh_binary, &endpoint, token) {
109 Ok(v) => v
110 .pointer("/author/login")
111 .and_then(|l| l.as_str())
112 .filter(|s| !s.is_empty())
113 .map(str::to_string),
114 Err(e) => {
115 tracing::debug!(
116 "commit_author_login: lookup for {} failed (keeping name-based rendering): {}",
117 email,
118 e
119 );
120 None
121 }
122 };
123 let mut guard = match cache.lock() {
124 Ok(g) => g,
125 Err(poisoned) => poisoned.into_inner(),
126 };
127 guard.insert(key, resolved.clone());
128 resolved
129}
130
131pub const GITHUB_TOKEN_ENV_LADDER: &[&str] = &["ANODIZER_GITHUB_TOKEN", "GITHUB_TOKEN"];
138
139pub fn github_token_env_hint() -> String {
146 GITHUB_TOKEN_ENV_LADDER.join(" or ")
147}
148
149pub fn github_token_hint() -> String {
153 format!("set {}, or pass --token", github_token_env_hint())
154}
155
156pub fn resolve_github_token_with_env(
167 explicit: Option<&str>,
168 env: &dyn Fn(&str) -> Option<String>,
169) -> Option<String> {
170 let non_empty = |s: String| if s.is_empty() { None } else { Some(s) };
171 let explicit = explicit.filter(|t| !t.is_empty()).map(str::to_string);
172 GITHUB_TOKEN_ENV_LADDER.iter().fold(explicit, |acc, var| {
173 acc.or_else(|| env(var).and_then(non_empty))
174 })
175}
176
177pub fn resolve_github_token(explicit: Option<&str>) -> Option<String> {
181 resolve_github_token_with_env(explicit, &|key| std::env::var(key).ok())
182}
183
184fn redact_gh_stderr(stderr: &str, token: Option<&str>) -> String {
192 let mut env: Vec<(String, String)> = std::env::vars().collect();
193 if let Some(tok) = token
194 && !tok.is_empty()
195 {
196 env.push(("GITHUB_TOKEN".to_string(), tok.to_string()));
197 }
198 crate::redact::with_env(stderr, &env)
199}
200
201pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
206 gh_api_get_paginated_with_binary(Path::new("gh"), endpoint, token)
207}
208
209pub fn gh_api_get_paginated_with_binary(
212 gh_binary: &Path,
213 endpoint: &str,
214 token: Option<&str>,
215) -> Result<Vec<serde_json::Value>> {
216 let mut cmd = Command::new(gh_binary);
217 cmd.args(["api", "--paginate", endpoint]);
218 if let Some(tok) = token {
219 cmd.env("GITHUB_TOKEN", tok);
220 }
221 let output = cmd
222 .stdout(std::process::Stdio::piped())
223 .stderr(std::process::Stdio::piped())
224 .output()
225 .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
226
227 if !output.status.success() {
228 let stderr_raw = String::from_utf8_lossy(&output.stderr);
229 let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
230 bail!("{}", redact_gh_stderr(&raw, token));
231 }
232
233 let stdout = String::from_utf8_lossy(&output.stdout);
234
235 if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
238 return Ok(arr);
239 }
240 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
241 return Ok(vec![val]);
243 }
244
245 let mut all_items = Vec::new();
248 for chunk in stdout.split_inclusive(']') {
249 let trimmed = chunk.trim();
250 if trimmed.is_empty() {
251 continue;
252 }
253 if let Ok(serde_json::Value::Array(arr)) =
254 serde_json::from_str::<serde_json::Value>(trimmed)
255 {
256 all_items.extend(arr);
257 } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
258 all_items.push(val);
259 } else {
260 let snippet = &trimmed[..trimmed.len().min(200)];
268 let redacted = crate::redact::redact_process_env(snippet);
269 tracing::warn!(
270 "gh_api_get_paginated: failed to parse JSON chunk ({} bytes): {:?}",
271 trimmed.len(),
272 redacted,
273 );
274 }
275 }
276
277 if all_items.is_empty() && !stdout.trim().is_empty() {
282 let raw = format!(
283 "gh api GET {endpoint} exited 0 but returned a body that could not be parsed as JSON"
284 );
285 bail!("{}", redact_gh_stderr(&raw, token));
286 }
287
288 Ok(all_items)
289}
290
291fn gh_api_post_with_binary(
295 gh_binary: &Path,
296 endpoint: &str,
297 body: &serde_json::Value,
298 log: &crate::log::StageLogger,
299) -> Result<serde_json::Value> {
300 let body_str = serde_json::to_string(body)?;
301
302 let mut cmd = Command::new(gh_binary);
303 cmd.args(["api", "--method", "POST", endpoint, "--input", "-"]);
304
305 let redacting_log = log.clone().with_env(std::env::vars().collect::<Vec<_>>());
312 let output = crate::run::run_checked_with_stdin(
313 &mut cmd,
314 body_str.as_bytes(),
315 &redacting_log,
316 "gh CLI",
317 )?;
318
319 let response: serde_json::Value = serde_json::from_slice(&output.stdout)
320 .with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
321 Ok(response)
322}
323
324pub fn create_tag_via_github_api(
336 slug: &RepoSlug,
337 tag: &str,
338 message: &str,
339 dry_run: bool,
340 log: &crate::log::StageLogger,
341 strict: bool,
342) -> Result<()> {
343 create_tag_via_github_api_in(
344 &std::env::current_dir()?,
345 Path::new("gh"),
346 slug,
347 tag,
348 message,
349 dry_run,
350 log,
351 strict,
352 )
353}
354
355#[allow(clippy::too_many_arguments)]
363pub fn create_tag_via_github_api_in(
364 cwd: &Path,
365 gh_binary: &Path,
366 slug: &RepoSlug,
367 tag: &str,
368 message: &str,
369 dry_run: bool,
370 log: &crate::log::StageLogger,
371 strict: bool,
372) -> Result<()> {
373 if dry_run {
374 log.status(&format!(
375 "(dry-run) would create tag {} via GitHub API (\"{}\")",
376 tag, message
377 ));
378 return Ok(());
379 }
380
381 let owner = slug.owner();
382 let repo = slug.name();
383
384 let sha = super::get_head_commit_in(cwd)?;
387
388 let body = serde_json::json!({
389 "tag": tag,
390 "message": message,
391 "object": sha,
392 "type": "commit",
393 "tagger": {
394 "name": git_output_in(cwd, &["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
395 "email": git_output_in(cwd, &["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
396 "date": crate::sde::resolve_now().to_rfc3339(),
397 }
398 });
399
400 let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
401 let response = match gh_api_post_with_binary(gh_binary, &tag_endpoint, &body, log) {
402 Ok(resp) => resp,
403 Err(e) => {
404 if e.to_string().contains("failed to spawn gh CLI") {
405 if strict {
406 anyhow::bail!(
407 "gh CLI not found, cannot create tag via GitHub API (strict mode)"
408 );
409 }
410 log.warn("gh CLI not found, falling back to local git tag + push");
411 return create_and_push_tag_in(cwd, tag, message, dry_run, log, strict);
412 }
413 return Err(e);
414 }
415 };
416
417 let tag_sha = response["sha"]
418 .as_str()
419 .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
420
421 let ref_body = serde_json::json!({
422 "ref": format!("refs/tags/{}", tag),
423 "sha": tag_sha,
424 });
425
426 let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
427 gh_api_post_with_binary(gh_binary, &ref_endpoint, &ref_body, log)?;
428
429 Ok(())
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
438 fn create_tag_dry_run_short_circuits() {
439 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
440 let slug = RepoSlug::for_test("owner", "repo");
441 let result = create_tag_via_github_api(&slug, "v1.0.0", "msg", true, &log, false);
443 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
444 }
445
446 #[test]
450 fn redact_gh_stderr_replaces_token_value() {
451 let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
452 let stderr = format!("HTTP 401: token {secret} is invalid");
453 let redacted = redact_gh_stderr(&stderr, Some(secret));
454 assert_eq!(redacted, "HTTP 401: token $GITHUB_TOKEN is invalid");
457 }
458
459 #[test]
460 fn redact_gh_stderr_with_no_token_still_strips_url_creds() {
461 let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
464 let redacted = redact_gh_stderr(stderr, None);
465 assert_eq!(
468 redacted,
469 "auth failed: https://<redacted>@github.com/o/r.git rejected"
470 );
471 }
472
473 #[test]
474 fn redact_gh_stderr_empty_token_is_noop_on_token_field() {
475 let stderr = "plain error message without credentials";
478 let redacted = redact_gh_stderr(stderr, Some(""));
479 assert_eq!(redacted, stderr);
480 }
481
482 #[test]
487 fn commit_author_login_missing_binary_degrades_to_none_and_caches() {
488 let tmp = tempfile::tempdir().unwrap();
489 let missing = tmp.path().join("nonexistent-gh");
490 let first = commit_author_login_with_binary(
491 &missing,
492 "owner-cal-test",
493 "repo-cal-test",
494 "a@example.com",
495 "0123456789abcdef0123456789abcdef01234567",
496 None,
497 );
498 assert_eq!(first, None, "missing binary must yield None");
499 let second = commit_author_login_with_binary(
502 &missing,
503 "owner-cal-test",
504 "repo-cal-test",
505 "a@example.com",
506 "fedcba9876543210fedcba9876543210fedcba98",
507 None,
508 );
509 assert_eq!(second, None);
510 }
511
512 #[test]
515 fn commit_author_login_empty_inputs_are_none() {
516 let gh = Path::new("gh");
517 assert_eq!(
518 commit_author_login_with_binary(gh, "", "r", "e", "s", None),
519 None
520 );
521 assert_eq!(
522 commit_author_login_with_binary(gh, "o", "", "e", "s", None),
523 None
524 );
525 assert_eq!(
526 commit_author_login_with_binary(gh, "o", "r", "", "s", None),
527 None
528 );
529 assert_eq!(
530 commit_author_login_with_binary(gh, "o", "r", "e", "", None),
531 None
532 );
533 }
534
535 #[test]
539 fn resolve_github_token_chain_order_and_empty_filtering() {
540 let env_with = |pairs: &[(&str, &str)]| {
541 let map: HashMap<String, String> = pairs
542 .iter()
543 .map(|(k, v)| (k.to_string(), v.to_string()))
544 .collect();
545 move |key: &str| map.get(key).cloned()
546 };
547
548 let both = env_with(&[
549 ("ANODIZER_GITHUB_TOKEN", "anod-tok"),
550 ("GITHUB_TOKEN", "gh-tok"),
551 ]);
552 assert_eq!(
553 resolve_github_token_with_env(Some("explicit-tok"), &both),
554 Some("explicit-tok".to_string()),
555 "explicit token must win over both env vars"
556 );
557 assert_eq!(
558 resolve_github_token_with_env(None, &both),
559 Some("anod-tok".to_string()),
560 "ANODIZER_GITHUB_TOKEN must win over GITHUB_TOKEN"
561 );
562
563 let gh_only = env_with(&[("GITHUB_TOKEN", "gh-tok")]);
564 assert_eq!(
565 resolve_github_token_with_env(None, &gh_only),
566 Some("gh-tok".to_string()),
567 "GITHUB_TOKEN alone must resolve (the CI-gap pin)"
568 );
569 assert_eq!(
570 resolve_github_token_with_env(Some(""), &gh_only),
571 Some("gh-tok".to_string()),
572 "empty explicit token must fall through to env"
573 );
574
575 let empty_anod = env_with(&[("ANODIZER_GITHUB_TOKEN", ""), ("GITHUB_TOKEN", "gh-tok")]);
576 assert_eq!(
577 resolve_github_token_with_env(None, &empty_anod),
578 Some("gh-tok".to_string()),
579 "empty ANODIZER_GITHUB_TOKEN (GHA missing-secret materialization) must not short-circuit"
580 );
581
582 let none = env_with(&[]);
583 assert_eq!(resolve_github_token_with_env(None, &none), None);
584 assert_eq!(resolve_github_token_with_env(Some(""), &none), None);
585 }
586
587 #[test]
594 fn gh_api_get_with_binary_bails_when_binary_missing() {
595 let tmp = tempfile::tempdir().unwrap();
596 let missing = tmp.path().join("nonexistent-gh");
597 let err = gh_api_get_with_binary(&missing, "/repos/x/y", None)
598 .expect_err("missing binary must error");
599 let msg = err.to_string();
600 assert!(
601 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
602 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
603 );
604 }
605
606 #[test]
608 fn gh_api_get_paginated_with_binary_bails_when_binary_missing() {
609 let tmp = tempfile::tempdir().unwrap();
610 let missing = tmp.path().join("nonexistent-gh");
611 let err = gh_api_get_paginated_with_binary(&missing, "/repos/x/y", None)
612 .expect_err("missing binary must error");
613 let msg = err.to_string();
614 assert!(
615 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
616 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
617 );
618 }
619
620 #[test]
627 fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
628 if !Command::new("git")
633 .arg("--version")
634 .output()
635 .map(|o| o.status.success())
636 .unwrap_or(false)
637 {
638 return;
639 }
640 let tmp = tempfile::tempdir().unwrap();
641 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
642 let slug = RepoSlug::for_test("owner", "repo");
643 let err = create_tag_via_github_api_in(
644 tmp.path(),
645 Path::new("gh"),
646 &slug,
647 "v1.0.0",
648 "msg",
649 false,
650 &log,
651 true,
652 )
653 .expect_err("non-git cwd must error");
654 let msg = err.to_string();
655 assert!(
656 msg.contains("git") || msg.contains("remote"),
657 "expected error to mention git or remote, got: {msg}"
658 );
659 }
660
661 #[test]
665 fn create_tag_via_github_api_in_dry_run_short_circuits() {
666 let tmp = tempfile::tempdir().unwrap();
667 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
668 let slug = RepoSlug::for_test("owner", "repo");
669 let result = create_tag_via_github_api_in(
670 tmp.path(),
671 Path::new("gh"),
672 &slug,
673 "v1.0.0",
674 "msg",
675 true,
676 &log,
677 false,
678 );
679 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
680 }
681}