anodizer_core/git/
github_api.rs1use anyhow::{Context as _, Result, bail};
2use std::process::Command;
3
4use super::git_output;
5use super::remote::detect_github_repo;
6use super::tags::create_and_push_tag;
7
8pub fn gh_api_get(endpoint: &str, token: Option<&str>) -> Result<serde_json::Value> {
13 let mut cmd = Command::new("gh");
14 cmd.args(["api", endpoint]);
15 if let Some(tok) = token {
16 cmd.env("GITHUB_TOKEN", tok);
17 }
18 let output = cmd
19 .stdout(std::process::Stdio::piped())
20 .stderr(std::process::Stdio::piped())
21 .output()
22 .context("failed to spawn gh CLI")?;
23 if !output.status.success() {
24 let stderr_raw = String::from_utf8_lossy(&output.stderr);
25 let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
26 bail!("{}", redact_gh_stderr(&raw, token));
27 }
28 let stdout = String::from_utf8_lossy(&output.stdout);
29 serde_json::from_str(&stdout).context("failed to parse gh api response")
30}
31
32fn redact_gh_stderr(stderr: &str, token: Option<&str>) -> String {
40 let stripped = crate::redact::redact_url_credentials(stderr);
41 let mut env: Vec<(String, String)> = std::env::vars().collect();
42 if let Some(tok) = token
43 && !tok.is_empty()
44 {
45 env.push(("GITHUB_TOKEN".to_string(), tok.to_string()));
46 }
47 crate::redact::string(&stripped, &env)
48}
49
50pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
55 let mut cmd = Command::new("gh");
56 cmd.args(["api", "--paginate", endpoint]);
57 if let Some(tok) = token {
58 cmd.env("GITHUB_TOKEN", tok);
59 }
60 let output = cmd
61 .stdout(std::process::Stdio::piped())
62 .stderr(std::process::Stdio::piped())
63 .output()
64 .context("failed to spawn gh CLI")?;
65
66 if !output.status.success() {
67 let stderr_raw = String::from_utf8_lossy(&output.stderr);
68 let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
69 bail!("{}", redact_gh_stderr(&raw, token));
70 }
71
72 let stdout = String::from_utf8_lossy(&output.stdout);
73
74 if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
77 return Ok(arr);
78 }
79 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
80 return Ok(vec![val]);
82 }
83
84 let mut all_items = Vec::new();
87 for chunk in stdout.split_inclusive(']') {
88 let trimmed = chunk.trim();
89 if trimmed.is_empty() {
90 continue;
91 }
92 if let Ok(serde_json::Value::Array(arr)) =
93 serde_json::from_str::<serde_json::Value>(trimmed)
94 {
95 all_items.extend(arr);
96 } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
97 all_items.push(val);
98 } else {
99 eprintln!(
101 "warning: gh_api_get_paginated: failed to parse JSON chunk (len={}): {:?}",
102 trimmed.len(),
103 &trimmed[..trimmed.len().min(200)]
104 );
105 }
106 }
107 Ok(all_items)
108}
109
110fn gh_api_post(endpoint: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
115 use std::io::Write;
116
117 let body_str = serde_json::to_string(body)?;
118
119 let mut child = Command::new("gh")
120 .args(["api", "--method", "POST", endpoint, "--input", "-"])
121 .stdin(std::process::Stdio::piped())
122 .stdout(std::process::Stdio::piped())
123 .stderr(std::process::Stdio::piped())
124 .spawn()
125 .context("failed to spawn gh CLI")?;
126
127 if let Some(ref mut stdin) = child.stdin {
128 stdin.write_all(body_str.as_bytes())?;
129 }
130 child.stdin.take(); let output = child.wait_with_output()?;
133 if !output.status.success() {
134 let stderr_raw = String::from_utf8_lossy(&output.stderr);
135 let raw = format!("gh api POST {} failed: {}", endpoint, stderr_raw.trim());
141 bail!("{}", crate::redact::redact_process_env(&raw));
142 }
143
144 let response: serde_json::Value = serde_json::from_slice(&output.stdout)
145 .with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
146 Ok(response)
147}
148
149pub fn create_tag_via_github_api(
157 tag: &str,
158 message: &str,
159 dry_run: bool,
160 log: &crate::log::StageLogger,
161 strict: bool,
162) -> Result<()> {
163 if dry_run {
164 log.status(&format!(
165 "(dry-run) would create tag via GitHub API: {} (\"{}\")",
166 tag, message
167 ));
168 return Ok(());
169 }
170
171 let (owner, repo) = detect_github_repo()?;
173
174 let sha = git_output(&["rev-parse", "HEAD"])?;
176
177 let body = serde_json::json!({
179 "tag": tag,
180 "message": message,
181 "object": sha,
182 "type": "commit",
183 "tagger": {
184 "name": git_output(&["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
185 "email": git_output(&["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
186 "date": chrono::Utc::now().to_rfc3339(),
187 }
188 });
189
190 let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
191 let response = match gh_api_post(&tag_endpoint, &body) {
192 Ok(resp) => resp,
193 Err(e) => {
194 if e.to_string().contains("failed to spawn gh CLI") {
195 if strict {
196 anyhow::bail!(
197 "gh CLI not found, cannot create tag via GitHub API (strict mode)"
198 );
199 }
200 log.warn("gh CLI not found, falling back to local git tag + push");
201 return create_and_push_tag(tag, message, dry_run, log, strict);
202 }
203 return Err(e);
204 }
205 };
206
207 let tag_sha = response["sha"]
208 .as_str()
209 .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
210
211 let ref_body = serde_json::json!({
213 "ref": format!("refs/tags/{}", tag),
214 "sha": tag_sha,
215 });
216
217 let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
218 gh_api_post(&ref_endpoint, &ref_body)?;
219
220 Ok(())
221}