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 tracing::warn!(
106 len = trimmed.len(),
107 chunk = ?&trimmed[..trimmed.len().min(200)],
108 "gh_api_get_paginated: failed to parse JSON chunk",
109 );
110 }
111 }
112 Ok(all_items)
113}
114
115fn gh_api_post(endpoint: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
120 use std::io::Write;
121
122 let body_str = serde_json::to_string(body)?;
123
124 let mut child = Command::new("gh")
125 .args(["api", "--method", "POST", endpoint, "--input", "-"])
126 .stdin(std::process::Stdio::piped())
127 .stdout(std::process::Stdio::piped())
128 .stderr(std::process::Stdio::piped())
129 .spawn()
130 .context("failed to spawn gh CLI")?;
131
132 if let Some(ref mut stdin) = child.stdin {
133 stdin.write_all(body_str.as_bytes())?;
134 }
135 child.stdin.take(); let output = child.wait_with_output()?;
138 if !output.status.success() {
139 let stderr_raw = String::from_utf8_lossy(&output.stderr);
140 let raw = format!("gh api POST {} failed: {}", endpoint, stderr_raw.trim());
146 bail!("{}", crate::redact::redact_process_env(&raw));
147 }
148
149 let response: serde_json::Value = serde_json::from_slice(&output.stdout)
150 .with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
151 Ok(response)
152}
153
154pub fn create_tag_via_github_api(
162 tag: &str,
163 message: &str,
164 dry_run: bool,
165 log: &crate::log::StageLogger,
166 strict: bool,
167) -> Result<()> {
168 if dry_run {
169 log.status(&format!(
170 "(dry-run) would create tag via GitHub API: {} (\"{}\")",
171 tag, message
172 ));
173 return Ok(());
174 }
175
176 let (owner, repo) = detect_github_repo()?;
178
179 let sha = git_output(&["rev-parse", "HEAD"])?;
181
182 let body = serde_json::json!({
183 "tag": tag,
184 "message": message,
185 "object": sha,
186 "type": "commit",
187 "tagger": {
188 "name": git_output(&["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
189 "email": git_output(&["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
190 "date": crate::sde::resolve_now().to_rfc3339(),
191 }
192 });
193
194 let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
195 let response = match gh_api_post(&tag_endpoint, &body) {
196 Ok(resp) => resp,
197 Err(e) => {
198 if e.to_string().contains("failed to spawn gh CLI") {
199 if strict {
200 anyhow::bail!(
201 "gh CLI not found, cannot create tag via GitHub API (strict mode)"
202 );
203 }
204 log.warn("gh CLI not found, falling back to local git tag + push");
205 return create_and_push_tag(tag, message, dry_run, log, strict);
206 }
207 return Err(e);
208 }
209 };
210
211 let tag_sha = response["sha"]
212 .as_str()
213 .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
214
215 let ref_body = serde_json::json!({
216 "ref": format!("refs/tags/{}", tag),
217 "sha": tag_sha,
218 });
219
220 let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
221 gh_api_post(&ref_endpoint, &ref_body)?;
222
223 Ok(())
224}