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::remote::detect_github_repo_in;
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 fn resolve_github_token_with_env(
141 explicit: Option<&str>,
142 env: &dyn Fn(&str) -> Option<String>,
143) -> Option<String> {
144 let non_empty = |s: String| if s.is_empty() { None } else { Some(s) };
145 explicit
146 .filter(|t| !t.is_empty())
147 .map(str::to_string)
148 .or_else(|| env("ANODIZER_GITHUB_TOKEN").and_then(non_empty))
149 .or_else(|| env("GITHUB_TOKEN").and_then(non_empty))
150}
151
152pub fn resolve_github_token(explicit: Option<&str>) -> Option<String> {
156 resolve_github_token_with_env(explicit, &|key| std::env::var(key).ok())
157}
158
159fn redact_gh_stderr(stderr: &str, token: Option<&str>) -> String {
167 let stripped = crate::redact::redact_url_credentials(stderr);
168 let mut env: Vec<(String, String)> = std::env::vars().collect();
169 if let Some(tok) = token
170 && !tok.is_empty()
171 {
172 env.push(("GITHUB_TOKEN".to_string(), tok.to_string()));
173 }
174 crate::redact::string(&stripped, &env)
175}
176
177pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
182 gh_api_get_paginated_with_binary(Path::new("gh"), endpoint, token)
183}
184
185pub fn gh_api_get_paginated_with_binary(
188 gh_binary: &Path,
189 endpoint: &str,
190 token: Option<&str>,
191) -> Result<Vec<serde_json::Value>> {
192 let mut cmd = Command::new(gh_binary);
193 cmd.args(["api", "--paginate", endpoint]);
194 if let Some(tok) = token {
195 cmd.env("GITHUB_TOKEN", tok);
196 }
197 let output = cmd
198 .stdout(std::process::Stdio::piped())
199 .stderr(std::process::Stdio::piped())
200 .output()
201 .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
202
203 if !output.status.success() {
204 let stderr_raw = String::from_utf8_lossy(&output.stderr);
205 let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
206 bail!("{}", redact_gh_stderr(&raw, token));
207 }
208
209 let stdout = String::from_utf8_lossy(&output.stdout);
210
211 if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
214 return Ok(arr);
215 }
216 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
217 return Ok(vec![val]);
219 }
220
221 let mut all_items = Vec::new();
224 for chunk in stdout.split_inclusive(']') {
225 let trimmed = chunk.trim();
226 if trimmed.is_empty() {
227 continue;
228 }
229 if let Ok(serde_json::Value::Array(arr)) =
230 serde_json::from_str::<serde_json::Value>(trimmed)
231 {
232 all_items.extend(arr);
233 } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
234 all_items.push(val);
235 } else {
236 let snippet = &trimmed[..trimmed.len().min(200)];
244 let redacted = crate::redact::redact_process_env(snippet);
245 tracing::warn!(
246 "gh_api_get_paginated: failed to parse JSON chunk ({} bytes): {:?}",
247 trimmed.len(),
248 redacted,
249 );
250 }
251 }
252 Ok(all_items)
253}
254
255fn gh_api_post_with_binary(
259 gh_binary: &Path,
260 endpoint: &str,
261 body: &serde_json::Value,
262) -> Result<serde_json::Value> {
263 use std::io::Write;
264
265 let body_str = serde_json::to_string(body)?;
266
267 let mut child = Command::new(gh_binary)
268 .args(["api", "--method", "POST", endpoint, "--input", "-"])
269 .stdin(std::process::Stdio::piped())
270 .stdout(std::process::Stdio::piped())
271 .stderr(std::process::Stdio::piped())
272 .spawn()
273 .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
274
275 if let Some(ref mut stdin) = child.stdin {
276 stdin.write_all(body_str.as_bytes())?;
277 }
278 child.stdin.take(); let output = child.wait_with_output()?;
281 if !output.status.success() {
282 let stderr_raw = String::from_utf8_lossy(&output.stderr);
283 let raw = format!("gh api POST {} failed: {}", endpoint, stderr_raw.trim());
289 bail!("{}", crate::redact::redact_process_env(&raw));
290 }
291
292 let response: serde_json::Value = serde_json::from_slice(&output.stdout)
293 .with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
294 Ok(response)
295}
296
297pub fn create_tag_via_github_api(
305 tag: &str,
306 message: &str,
307 dry_run: bool,
308 log: &crate::log::StageLogger,
309 strict: bool,
310) -> Result<()> {
311 create_tag_via_github_api_in(
312 &std::env::current_dir()?,
313 Path::new("gh"),
314 tag,
315 message,
316 dry_run,
317 log,
318 strict,
319 )
320}
321
322#[allow(clippy::too_many_arguments)]
330pub fn create_tag_via_github_api_in(
331 cwd: &Path,
332 gh_binary: &Path,
333 tag: &str,
334 message: &str,
335 dry_run: bool,
336 log: &crate::log::StageLogger,
337 strict: bool,
338) -> Result<()> {
339 if dry_run {
340 log.status(&format!(
341 "(dry-run) would create tag {} via GitHub API (\"{}\")",
342 tag, message
343 ));
344 return Ok(());
345 }
346
347 let (owner, repo) = detect_github_repo_in(cwd)?;
349
350 let sha = git_output_in(cwd, &["rev-parse", "HEAD"])?;
352
353 let body = serde_json::json!({
354 "tag": tag,
355 "message": message,
356 "object": sha,
357 "type": "commit",
358 "tagger": {
359 "name": git_output_in(cwd, &["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
360 "email": git_output_in(cwd, &["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
361 "date": crate::sde::resolve_now().to_rfc3339(),
362 }
363 });
364
365 let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
366 let response = match gh_api_post_with_binary(gh_binary, &tag_endpoint, &body) {
367 Ok(resp) => resp,
368 Err(e) => {
369 if e.to_string().contains("failed to spawn gh CLI") {
370 if strict {
371 anyhow::bail!(
372 "gh CLI not found, cannot create tag via GitHub API (strict mode)"
373 );
374 }
375 log.warn("gh CLI not found, falling back to local git tag + push");
376 return create_and_push_tag_in(cwd, tag, message, dry_run, log, strict);
377 }
378 return Err(e);
379 }
380 };
381
382 let tag_sha = response["sha"]
383 .as_str()
384 .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
385
386 let ref_body = serde_json::json!({
387 "ref": format!("refs/tags/{}", tag),
388 "sha": tag_sha,
389 });
390
391 let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
392 gh_api_post_with_binary(gh_binary, &ref_endpoint, &ref_body)?;
393
394 Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
403 fn create_tag_dry_run_short_circuits() {
404 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
405 let result = create_tag_via_github_api("v1.0.0", "msg", true, &log, false);
407 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
408 }
409
410 #[test]
414 fn redact_gh_stderr_replaces_token_value() {
415 let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
416 let stderr = format!("HTTP 401: token {secret} is invalid");
417 let redacted = redact_gh_stderr(&stderr, Some(secret));
418 assert!(
419 !redacted.contains(secret),
420 "token leaked into redacted output: {redacted}"
421 );
422 }
423
424 #[test]
425 fn redact_gh_stderr_with_no_token_still_strips_url_creds() {
426 let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
429 let redacted = redact_gh_stderr(stderr, None);
430 assert!(
431 !redacted.contains("secret-pw"),
432 "URL credential leaked: {redacted}"
433 );
434 }
435
436 #[test]
437 fn redact_gh_stderr_empty_token_is_noop_on_token_field() {
438 let stderr = "plain error message without credentials";
441 let redacted = redact_gh_stderr(stderr, Some(""));
442 assert_eq!(redacted, stderr);
443 }
444
445 #[test]
450 fn commit_author_login_missing_binary_degrades_to_none_and_caches() {
451 let tmp = tempfile::tempdir().unwrap();
452 let missing = tmp.path().join("nonexistent-gh");
453 let first = commit_author_login_with_binary(
454 &missing,
455 "owner-cal-test",
456 "repo-cal-test",
457 "a@example.com",
458 "0123456789abcdef0123456789abcdef01234567",
459 None,
460 );
461 assert_eq!(first, None, "missing binary must yield None");
462 let second = commit_author_login_with_binary(
465 &missing,
466 "owner-cal-test",
467 "repo-cal-test",
468 "a@example.com",
469 "fedcba9876543210fedcba9876543210fedcba98",
470 None,
471 );
472 assert_eq!(second, None);
473 }
474
475 #[test]
478 fn commit_author_login_empty_inputs_are_none() {
479 let gh = Path::new("gh");
480 assert_eq!(
481 commit_author_login_with_binary(gh, "", "r", "e", "s", None),
482 None
483 );
484 assert_eq!(
485 commit_author_login_with_binary(gh, "o", "", "e", "s", None),
486 None
487 );
488 assert_eq!(
489 commit_author_login_with_binary(gh, "o", "r", "", "s", None),
490 None
491 );
492 assert_eq!(
493 commit_author_login_with_binary(gh, "o", "r", "e", "", None),
494 None
495 );
496 }
497
498 #[test]
502 fn resolve_github_token_chain_order_and_empty_filtering() {
503 let env_with = |pairs: &[(&str, &str)]| {
504 let map: HashMap<String, String> = pairs
505 .iter()
506 .map(|(k, v)| (k.to_string(), v.to_string()))
507 .collect();
508 move |key: &str| map.get(key).cloned()
509 };
510
511 let both = env_with(&[
512 ("ANODIZER_GITHUB_TOKEN", "anod-tok"),
513 ("GITHUB_TOKEN", "gh-tok"),
514 ]);
515 assert_eq!(
516 resolve_github_token_with_env(Some("explicit-tok"), &both),
517 Some("explicit-tok".to_string()),
518 "explicit token must win over both env vars"
519 );
520 assert_eq!(
521 resolve_github_token_with_env(None, &both),
522 Some("anod-tok".to_string()),
523 "ANODIZER_GITHUB_TOKEN must win over GITHUB_TOKEN"
524 );
525
526 let gh_only = env_with(&[("GITHUB_TOKEN", "gh-tok")]);
527 assert_eq!(
528 resolve_github_token_with_env(None, &gh_only),
529 Some("gh-tok".to_string()),
530 "GITHUB_TOKEN alone must resolve (the CI-gap pin)"
531 );
532 assert_eq!(
533 resolve_github_token_with_env(Some(""), &gh_only),
534 Some("gh-tok".to_string()),
535 "empty explicit token must fall through to env"
536 );
537
538 let empty_anod = env_with(&[("ANODIZER_GITHUB_TOKEN", ""), ("GITHUB_TOKEN", "gh-tok")]);
539 assert_eq!(
540 resolve_github_token_with_env(None, &empty_anod),
541 Some("gh-tok".to_string()),
542 "empty ANODIZER_GITHUB_TOKEN (GHA missing-secret materialization) must not short-circuit"
543 );
544
545 let none = env_with(&[]);
546 assert_eq!(resolve_github_token_with_env(None, &none), None);
547 assert_eq!(resolve_github_token_with_env(Some(""), &none), None);
548 }
549
550 #[test]
557 fn gh_api_get_with_binary_bails_when_binary_missing() {
558 let tmp = tempfile::tempdir().unwrap();
559 let missing = tmp.path().join("nonexistent-gh");
560 let err = gh_api_get_with_binary(&missing, "/repos/x/y", None)
561 .expect_err("missing binary must error");
562 let msg = err.to_string();
563 assert!(
564 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
565 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
566 );
567 }
568
569 #[test]
571 fn gh_api_get_paginated_with_binary_bails_when_binary_missing() {
572 let tmp = tempfile::tempdir().unwrap();
573 let missing = tmp.path().join("nonexistent-gh");
574 let err = gh_api_get_paginated_with_binary(&missing, "/repos/x/y", None)
575 .expect_err("missing binary must error");
576 let msg = err.to_string();
577 assert!(
578 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
579 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
580 );
581 }
582
583 #[test]
590 fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
591 if Command::new("git")
592 .arg("--version")
593 .output()
594 .map(|o| !o.status.success())
595 .unwrap_or(true)
596 {
597 return;
598 }
599 let tmp = tempfile::tempdir().unwrap();
600 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
601 let err = create_tag_via_github_api_in(
602 tmp.path(),
603 Path::new("gh"),
604 "v1.0.0",
605 "msg",
606 false,
607 &log,
608 true,
609 )
610 .expect_err("non-git cwd must error");
611 let msg = err.to_string();
612 assert!(
613 msg.contains("git") || msg.contains("remote"),
614 "expected error to mention git or remote, got: {msg}"
615 );
616 }
617
618 #[test]
622 fn create_tag_via_github_api_in_dry_run_short_circuits() {
623 let tmp = tempfile::tempdir().unwrap();
624 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
625 let result = create_tag_via_github_api_in(
626 tmp.path(),
627 Path::new("gh"),
628 "v1.0.0",
629 "msg",
630 true,
631 &log,
632 false,
633 );
634 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
635 }
636}