1use anyhow::{Context as _, Result, bail};
2use std::path::Path;
3use std::process::Command;
4
5use super::git_output_in;
6use super::remote::detect_github_repo_in;
7use super::tags::create_and_push_tag_in;
8
9pub fn gh_api_get(endpoint: &str, token: Option<&str>) -> Result<serde_json::Value> {
14 gh_api_get_with_binary(Path::new("gh"), endpoint, token)
15}
16
17pub fn gh_api_get_with_binary(
25 gh_binary: &Path,
26 endpoint: &str,
27 token: Option<&str>,
28) -> Result<serde_json::Value> {
29 let mut cmd = Command::new(gh_binary);
30 cmd.args(["api", endpoint]);
31 if let Some(tok) = token {
32 cmd.env("GITHUB_TOKEN", tok);
33 }
34 let output = cmd
35 .stdout(std::process::Stdio::piped())
36 .stderr(std::process::Stdio::piped())
37 .output()
38 .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
39 if !output.status.success() {
40 let stderr_raw = String::from_utf8_lossy(&output.stderr);
41 let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
42 bail!("{}", redact_gh_stderr(&raw, token));
43 }
44 let stdout = String::from_utf8_lossy(&output.stdout);
45 serde_json::from_str(&stdout).context("failed to parse gh api response")
46}
47
48fn redact_gh_stderr(stderr: &str, token: Option<&str>) -> String {
56 let stripped = crate::redact::redact_url_credentials(stderr);
57 let mut env: Vec<(String, String)> = std::env::vars().collect();
58 if let Some(tok) = token
59 && !tok.is_empty()
60 {
61 env.push(("GITHUB_TOKEN".to_string(), tok.to_string()));
62 }
63 crate::redact::string(&stripped, &env)
64}
65
66pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
71 gh_api_get_paginated_with_binary(Path::new("gh"), endpoint, token)
72}
73
74pub fn gh_api_get_paginated_with_binary(
77 gh_binary: &Path,
78 endpoint: &str,
79 token: Option<&str>,
80) -> Result<Vec<serde_json::Value>> {
81 let mut cmd = Command::new(gh_binary);
82 cmd.args(["api", "--paginate", endpoint]);
83 if let Some(tok) = token {
84 cmd.env("GITHUB_TOKEN", tok);
85 }
86 let output = cmd
87 .stdout(std::process::Stdio::piped())
88 .stderr(std::process::Stdio::piped())
89 .output()
90 .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
91
92 if !output.status.success() {
93 let stderr_raw = String::from_utf8_lossy(&output.stderr);
94 let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
95 bail!("{}", redact_gh_stderr(&raw, token));
96 }
97
98 let stdout = String::from_utf8_lossy(&output.stdout);
99
100 if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
103 return Ok(arr);
104 }
105 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
106 return Ok(vec![val]);
108 }
109
110 let mut all_items = Vec::new();
113 for chunk in stdout.split_inclusive(']') {
114 let trimmed = chunk.trim();
115 if trimmed.is_empty() {
116 continue;
117 }
118 if let Ok(serde_json::Value::Array(arr)) =
119 serde_json::from_str::<serde_json::Value>(trimmed)
120 {
121 all_items.extend(arr);
122 } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
123 all_items.push(val);
124 } else {
125 let snippet = &trimmed[..trimmed.len().min(200)];
133 let redacted = crate::redact::redact_process_env(snippet);
134 tracing::warn!(
135 "gh_api_get_paginated: failed to parse JSON chunk ({} bytes): {:?}",
136 trimmed.len(),
137 redacted,
138 );
139 }
140 }
141 Ok(all_items)
142}
143
144fn gh_api_post_with_binary(
148 gh_binary: &Path,
149 endpoint: &str,
150 body: &serde_json::Value,
151) -> Result<serde_json::Value> {
152 use std::io::Write;
153
154 let body_str = serde_json::to_string(body)?;
155
156 let mut child = Command::new(gh_binary)
157 .args(["api", "--method", "POST", endpoint, "--input", "-"])
158 .stdin(std::process::Stdio::piped())
159 .stdout(std::process::Stdio::piped())
160 .stderr(std::process::Stdio::piped())
161 .spawn()
162 .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
163
164 if let Some(ref mut stdin) = child.stdin {
165 stdin.write_all(body_str.as_bytes())?;
166 }
167 child.stdin.take(); let output = child.wait_with_output()?;
170 if !output.status.success() {
171 let stderr_raw = String::from_utf8_lossy(&output.stderr);
172 let raw = format!("gh api POST {} failed: {}", endpoint, stderr_raw.trim());
178 bail!("{}", crate::redact::redact_process_env(&raw));
179 }
180
181 let response: serde_json::Value = serde_json::from_slice(&output.stdout)
182 .with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
183 Ok(response)
184}
185
186pub fn create_tag_via_github_api(
194 tag: &str,
195 message: &str,
196 dry_run: bool,
197 log: &crate::log::StageLogger,
198 strict: bool,
199) -> Result<()> {
200 create_tag_via_github_api_in(
201 &std::env::current_dir()?,
202 Path::new("gh"),
203 tag,
204 message,
205 dry_run,
206 log,
207 strict,
208 )
209}
210
211#[allow(clippy::too_many_arguments)]
219pub fn create_tag_via_github_api_in(
220 cwd: &Path,
221 gh_binary: &Path,
222 tag: &str,
223 message: &str,
224 dry_run: bool,
225 log: &crate::log::StageLogger,
226 strict: bool,
227) -> Result<()> {
228 if dry_run {
229 log.status(&format!(
230 "(dry-run) would create tag via GitHub API: {} (\"{}\")",
231 tag, message
232 ));
233 return Ok(());
234 }
235
236 let (owner, repo) = detect_github_repo_in(cwd)?;
238
239 let sha = git_output_in(cwd, &["rev-parse", "HEAD"])?;
241
242 let body = serde_json::json!({
243 "tag": tag,
244 "message": message,
245 "object": sha,
246 "type": "commit",
247 "tagger": {
248 "name": git_output_in(cwd, &["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
249 "email": git_output_in(cwd, &["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
250 "date": crate::sde::resolve_now().to_rfc3339(),
251 }
252 });
253
254 let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
255 let response = match gh_api_post_with_binary(gh_binary, &tag_endpoint, &body) {
256 Ok(resp) => resp,
257 Err(e) => {
258 if e.to_string().contains("failed to spawn gh CLI") {
259 if strict {
260 anyhow::bail!(
261 "gh CLI not found, cannot create tag via GitHub API (strict mode)"
262 );
263 }
264 log.warn("gh CLI not found, falling back to local git tag + push");
265 return create_and_push_tag_in(cwd, tag, message, dry_run, log, strict);
266 }
267 return Err(e);
268 }
269 };
270
271 let tag_sha = response["sha"]
272 .as_str()
273 .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
274
275 let ref_body = serde_json::json!({
276 "ref": format!("refs/tags/{}", tag),
277 "sha": tag_sha,
278 });
279
280 let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
281 gh_api_post_with_binary(gh_binary, &ref_endpoint, &ref_body)?;
282
283 Ok(())
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
292 fn create_tag_dry_run_short_circuits() {
293 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
294 let result = create_tag_via_github_api("v1.0.0", "msg", true, &log, false);
296 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
297 }
298
299 #[test]
303 fn redact_gh_stderr_replaces_token_value() {
304 let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
305 let stderr = format!("HTTP 401: token {secret} is invalid");
306 let redacted = redact_gh_stderr(&stderr, Some(secret));
307 assert!(
308 !redacted.contains(secret),
309 "token leaked into redacted output: {redacted}"
310 );
311 }
312
313 #[test]
314 fn redact_gh_stderr_with_no_token_still_strips_url_creds() {
315 let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
318 let redacted = redact_gh_stderr(stderr, None);
319 assert!(
320 !redacted.contains("secret-pw"),
321 "URL credential leaked: {redacted}"
322 );
323 }
324
325 #[test]
326 fn redact_gh_stderr_empty_token_is_noop_on_token_field() {
327 let stderr = "plain error message without credentials";
330 let redacted = redact_gh_stderr(stderr, Some(""));
331 assert_eq!(redacted, stderr);
332 }
333
334 #[test]
341 fn gh_api_get_with_binary_bails_when_binary_missing() {
342 let tmp = tempfile::tempdir().unwrap();
343 let missing = tmp.path().join("nonexistent-gh");
344 let err = gh_api_get_with_binary(&missing, "/repos/x/y", None)
345 .expect_err("missing binary must error");
346 let msg = err.to_string();
347 assert!(
348 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
349 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
350 );
351 }
352
353 #[test]
355 fn gh_api_get_paginated_with_binary_bails_when_binary_missing() {
356 let tmp = tempfile::tempdir().unwrap();
357 let missing = tmp.path().join("nonexistent-gh");
358 let err = gh_api_get_paginated_with_binary(&missing, "/repos/x/y", None)
359 .expect_err("missing binary must error");
360 let msg = err.to_string();
361 assert!(
362 msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
363 "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
364 );
365 }
366
367 #[test]
374 fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
375 if Command::new("git")
376 .arg("--version")
377 .output()
378 .map(|o| !o.status.success())
379 .unwrap_or(true)
380 {
381 return;
382 }
383 let tmp = tempfile::tempdir().unwrap();
384 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
385 let err = create_tag_via_github_api_in(
386 tmp.path(),
387 Path::new("gh"),
388 "v1.0.0",
389 "msg",
390 false,
391 &log,
392 true,
393 )
394 .expect_err("non-git cwd must error");
395 let msg = err.to_string();
396 assert!(
397 msg.contains("git") || msg.contains("remote"),
398 "expected error to mention git or remote, got: {msg}"
399 );
400 }
401
402 #[test]
406 fn create_tag_via_github_api_in_dry_run_short_circuits() {
407 let tmp = tempfile::tempdir().unwrap();
408 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
409 let result = create_tag_via_github_api_in(
410 tmp.path(),
411 Path::new("gh"),
412 "v1.0.0",
413 "msg",
414 true,
415 &log,
416 false,
417 );
418 assert!(result.is_ok(), "dry-run must succeed: {result:?}");
419 }
420}