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 resolve_github_token_with_env(
150 explicit: Option<&str>,
151 env: &dyn Fn(&str) -> Option<String>,
152) -> Option<String> {
153 let non_empty = |s: String| if s.is_empty() { None } else { Some(s) };
154 let explicit = explicit.filter(|t| !t.is_empty()).map(str::to_string);
155 GITHUB_TOKEN_ENV_LADDER.iter().fold(explicit, |acc, var| {
156 acc.or_else(|| env(var).and_then(non_empty))
157 })
158}
159
160pub fn resolve_github_token(explicit: Option<&str>) -> Option<String> {
164 resolve_github_token_with_env(explicit, &|key| std::env::var(key).ok())
165}
166
167fn redact_gh_stderr(stderr: &str, token: Option<&str>) -> String {
175 let mut env: Vec<(String, String)> = std::env::vars().collect();
176 if let Some(tok) = token
177 && !tok.is_empty()
178 {
179 env.push(("GITHUB_TOKEN".to_string(), tok.to_string()));
180 }
181 crate::redact::with_env(stderr, &env)
182}
183
184pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
189 gh_api_get_paginated_with_binary(Path::new("gh"), endpoint, token)
190}
191
192pub fn gh_api_get_paginated_with_binary(
195 gh_binary: &Path,
196 endpoint: &str,
197 token: Option<&str>,
198) -> Result<Vec<serde_json::Value>> {
199 let mut cmd = Command::new(gh_binary);
200 cmd.args(["api", "--paginate", endpoint]);
201 if let Some(tok) = token {
202 cmd.env("GITHUB_TOKEN", tok);
203 }
204 let output = cmd
205 .stdout(std::process::Stdio::piped())
206 .stderr(std::process::Stdio::piped())
207 .output()
208 .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
209
210 if !output.status.success() {
211 let stderr_raw = String::from_utf8_lossy(&output.stderr);
212 let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
213 bail!("{}", redact_gh_stderr(&raw, token));
214 }
215
216 let stdout = String::from_utf8_lossy(&output.stdout);
217
218 if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
221 return Ok(arr);
222 }
223 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
224 return Ok(vec![val]);
226 }
227
228 let mut all_items = Vec::new();
231 for chunk in stdout.split_inclusive(']') {
232 let trimmed = chunk.trim();
233 if trimmed.is_empty() {
234 continue;
235 }
236 if let Ok(serde_json::Value::Array(arr)) =
237 serde_json::from_str::<serde_json::Value>(trimmed)
238 {
239 all_items.extend(arr);
240 } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
241 all_items.push(val);
242 } else {
243 let snippet = &trimmed[..trimmed.len().min(200)];
251 let redacted = crate::redact::redact_process_env(snippet);
252 tracing::warn!(
253 "gh_api_get_paginated: failed to parse JSON chunk ({} bytes): {:?}",
254 trimmed.len(),
255 redacted,
256 );
257 }
258 }
259
260 if all_items.is_empty() && !stdout.trim().is_empty() {
265 let raw = format!(
266 "gh api GET {endpoint} exited 0 but returned a body that could not be parsed as JSON"
267 );
268 bail!("{}", redact_gh_stderr(&raw, token));
269 }
270
271 Ok(all_items)
272}
273
274fn gh_api_post_with_binary(
278 gh_binary: &Path,
279 endpoint: &str,
280 body: &serde_json::Value,
281 log: &crate::log::StageLogger,
282) -> Result<serde_json::Value> {
283 let body_str = serde_json::to_string(body)?;
284
285 let mut cmd = Command::new(gh_binary);
286 cmd.args(["api", "--method", "POST", endpoint, "--input", "-"]);
287
288 let redacting_log = log.clone().with_env(std::env::vars().collect::<Vec<_>>());
295 let output = crate::run::run_checked_with_stdin(
296 &mut cmd,
297 body_str.as_bytes(),
298 &redacting_log,
299 "gh CLI",
300 )?;
301
302 let response: serde_json::Value = serde_json::from_slice(&output.stdout)
303 .with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
304 Ok(response)
305}
306
307pub fn create_tag_via_github_api(
319 slug: &RepoSlug,
320 tag: &str,
321 message: &str,
322 dry_run: bool,
323 log: &crate::log::StageLogger,
324 strict: bool,
325) -> Result<()> {
326 create_tag_via_github_api_in(
327 &std::env::current_dir()?,
328 Path::new("gh"),
329 slug,
330 tag,
331 message,
332 dry_run,
333 log,
334 strict,
335 )
336}
337
338#[allow(clippy::too_many_arguments)]
346pub fn create_tag_via_github_api_in(
347 cwd: &Path,
348 gh_binary: &Path,
349 slug: &RepoSlug,
350 tag: &str,
351 message: &str,
352 dry_run: bool,
353 log: &crate::log::StageLogger,
354 strict: bool,
355) -> Result<()> {
356 if dry_run {
357 log.status(&format!(
358 "(dry-run) would create tag {} via GitHub API (\"{}\")",
359 tag, message
360 ));
361 return Ok(());
362 }
363
364 let owner = slug.owner();
365 let repo = slug.name();
366
367 let sha = super::get_head_commit_in(cwd)?;
370
371 let body = serde_json::json!({
372 "tag": tag,
373 "message": message,
374 "object": sha,
375 "type": "commit",
376 "tagger": {
377 "name": git_output_in(cwd, &["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
378 "email": git_output_in(cwd, &["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
379 "date": crate::sde::resolve_now().to_rfc3339(),
380 }
381 });
382
383 let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
384 let response = match gh_api_post_with_binary(gh_binary, &tag_endpoint, &body, log) {
385 Ok(resp) => resp,
386 Err(e) => {
387 if e.to_string().contains("failed to spawn gh CLI") {
388 if strict {
389 anyhow::bail!(
390 "gh CLI not found, cannot create tag via GitHub API (strict mode)"
391 );
392 }
393 log.warn("gh CLI not found, falling back to local git tag + push");
394 return create_and_push_tag_in(cwd, tag, message, dry_run, log, strict);
395 }
396 return Err(e);
397 }
398 };
399
400 let tag_sha = response["sha"]
401 .as_str()
402 .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
403
404 let ref_body = serde_json::json!({
405 "ref": format!("refs/tags/{}", tag),
406 "sha": tag_sha,
407 });
408
409 let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
410 gh_api_post_with_binary(gh_binary, &ref_endpoint, &ref_body, log)?;
411
412 Ok(())
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
421 fn create_tag_dry_run_short_circuits() {
422 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
423 let slug = RepoSlug::for_test("owner", "repo");
424 let result = create_tag_via_github_api(&slug, "v1.0.0", "msg", true, &log, false);
426 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
427 }
428
429 #[test]
433 fn redact_gh_stderr_replaces_token_value() {
434 let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
435 let stderr = format!("HTTP 401: token {secret} is invalid");
436 let redacted = redact_gh_stderr(&stderr, Some(secret));
437 assert_eq!(redacted, "HTTP 401: token $GITHUB_TOKEN is invalid");
440 }
441
442 #[test]
443 fn redact_gh_stderr_with_no_token_still_strips_url_creds() {
444 let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
447 let redacted = redact_gh_stderr(stderr, None);
448 assert_eq!(
451 redacted,
452 "auth failed: https://<redacted>@github.com/o/r.git rejected"
453 );
454 }
455
456 #[test]
457 fn redact_gh_stderr_empty_token_is_noop_on_token_field() {
458 let stderr = "plain error message without credentials";
461 let redacted = redact_gh_stderr(stderr, Some(""));
462 assert_eq!(redacted, stderr);
463 }
464
465 #[test]
470 fn commit_author_login_missing_binary_degrades_to_none_and_caches() {
471 let tmp = tempfile::tempdir().unwrap();
472 let missing = tmp.path().join("nonexistent-gh");
473 let first = commit_author_login_with_binary(
474 &missing,
475 "owner-cal-test",
476 "repo-cal-test",
477 "a@example.com",
478 "0123456789abcdef0123456789abcdef01234567",
479 None,
480 );
481 assert_eq!(first, None, "missing binary must yield None");
482 let second = commit_author_login_with_binary(
485 &missing,
486 "owner-cal-test",
487 "repo-cal-test",
488 "a@example.com",
489 "fedcba9876543210fedcba9876543210fedcba98",
490 None,
491 );
492 assert_eq!(second, None);
493 }
494
495 #[test]
498 fn commit_author_login_empty_inputs_are_none() {
499 let gh = Path::new("gh");
500 assert_eq!(
501 commit_author_login_with_binary(gh, "", "r", "e", "s", None),
502 None
503 );
504 assert_eq!(
505 commit_author_login_with_binary(gh, "o", "", "e", "s", None),
506 None
507 );
508 assert_eq!(
509 commit_author_login_with_binary(gh, "o", "r", "", "s", None),
510 None
511 );
512 assert_eq!(
513 commit_author_login_with_binary(gh, "o", "r", "e", "", None),
514 None
515 );
516 }
517
518 #[test]
522 fn resolve_github_token_chain_order_and_empty_filtering() {
523 let env_with = |pairs: &[(&str, &str)]| {
524 let map: HashMap<String, String> = pairs
525 .iter()
526 .map(|(k, v)| (k.to_string(), v.to_string()))
527 .collect();
528 move |key: &str| map.get(key).cloned()
529 };
530
531 let both = env_with(&[
532 ("ANODIZER_GITHUB_TOKEN", "anod-tok"),
533 ("GITHUB_TOKEN", "gh-tok"),
534 ]);
535 assert_eq!(
536 resolve_github_token_with_env(Some("explicit-tok"), &both),
537 Some("explicit-tok".to_string()),
538 "explicit token must win over both env vars"
539 );
540 assert_eq!(
541 resolve_github_token_with_env(None, &both),
542 Some("anod-tok".to_string()),
543 "ANODIZER_GITHUB_TOKEN must win over GITHUB_TOKEN"
544 );
545
546 let gh_only = env_with(&[("GITHUB_TOKEN", "gh-tok")]);
547 assert_eq!(
548 resolve_github_token_with_env(None, &gh_only),
549 Some("gh-tok".to_string()),
550 "GITHUB_TOKEN alone must resolve (the CI-gap pin)"
551 );
552 assert_eq!(
553 resolve_github_token_with_env(Some(""), &gh_only),
554 Some("gh-tok".to_string()),
555 "empty explicit token must fall through to env"
556 );
557
558 let empty_anod = env_with(&[("ANODIZER_GITHUB_TOKEN", ""), ("GITHUB_TOKEN", "gh-tok")]);
559 assert_eq!(
560 resolve_github_token_with_env(None, &empty_anod),
561 Some("gh-tok".to_string()),
562 "empty ANODIZER_GITHUB_TOKEN (GHA missing-secret materialization) must not short-circuit"
563 );
564
565 let none = env_with(&[]);
566 assert_eq!(resolve_github_token_with_env(None, &none), None);
567 assert_eq!(resolve_github_token_with_env(Some(""), &none), None);
568 }
569
570 #[test]
577 fn gh_api_get_with_binary_bails_when_binary_missing() {
578 let tmp = tempfile::tempdir().unwrap();
579 let missing = tmp.path().join("nonexistent-gh");
580 let err = gh_api_get_with_binary(&missing, "/repos/x/y", None)
581 .expect_err("missing binary must error");
582 let msg = err.to_string();
583 assert!(
584 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
585 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
586 );
587 }
588
589 #[test]
591 fn gh_api_get_paginated_with_binary_bails_when_binary_missing() {
592 let tmp = tempfile::tempdir().unwrap();
593 let missing = tmp.path().join("nonexistent-gh");
594 let err = gh_api_get_paginated_with_binary(&missing, "/repos/x/y", None)
595 .expect_err("missing binary must error");
596 let msg = err.to_string();
597 assert!(
598 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
599 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
600 );
601 }
602
603 #[test]
610 fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
611 if !Command::new("git")
616 .arg("--version")
617 .output()
618 .map(|o| o.status.success())
619 .unwrap_or(false)
620 {
621 return;
622 }
623 let tmp = tempfile::tempdir().unwrap();
624 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
625 let slug = RepoSlug::for_test("owner", "repo");
626 let err = create_tag_via_github_api_in(
627 tmp.path(),
628 Path::new("gh"),
629 &slug,
630 "v1.0.0",
631 "msg",
632 false,
633 &log,
634 true,
635 )
636 .expect_err("non-git cwd must error");
637 let msg = err.to_string();
638 assert!(
639 msg.contains("git") || msg.contains("remote"),
640 "expected error to mention git or remote, got: {msg}"
641 );
642 }
643
644 #[test]
648 fn create_tag_via_github_api_in_dry_run_short_circuits() {
649 let tmp = tempfile::tempdir().unwrap();
650 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
651 let slug = RepoSlug::for_test("owner", "repo");
652 let result = create_tag_via_github_api_in(
653 tmp.path(),
654 Path::new("gh"),
655 &slug,
656 "v1.0.0",
657 "msg",
658 true,
659 &log,
660 false,
661 );
662 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
663 }
664}