Skip to main content

anodizer_core/git/
github_api.rs

1use 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
8/// GET a GitHub API endpoint via the `gh` CLI (single request, no pagination).
9///
10/// Returns the parsed JSON response. Useful for endpoints that return a single
11/// object (e.g. the Compare API) rather than a paginated array.
12pub 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
32/// Redact secrets from `gh` CLI stderr before interpolating into a bail
33/// message. `token` is the `GITHUB_TOKEN` value passed to the
34/// subprocess; if the user-supplied token leaks (e.g. via a verbose `gh`
35/// error that echoes the auth header), it is replaced with `$GITHUB_TOKEN`
36/// regardless of whether the value matches the `redact::is_secret`
37/// heuristics. Also strips inline URL credentials and any other secret
38/// env-var values reachable from the parent process env.
39fn 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
50/// GET a GitHub API endpoint via the `gh` CLI, with pagination.
51///
52/// Returns a JSON array of all pages concatenated. The caller is responsible for
53/// ensuring that `gh` is installed and authenticated.
54pub 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    // Try parsing the entire response first before falling back to splitting.
75    // This avoids the split_inclusive(']') approach corrupting non-array responses.
76    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        // Single object response (e.g. non-list endpoint) — wrap in a vec.
81        return Ok(vec![val]);
82    }
83
84    // Whole-parse failed — gh --paginate may return multiple JSON arrays
85    // concatenated (e.g. `[...][...]`). Split on `]` boundaries and parse each chunk.
86    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            // Log unparseable chunks so corrupt data doesn't go unnoticed.
100            // Route through `tracing::warn!` so subscriber-level redaction
101            // applies (the chunk may carry templated request data). Cap
102            // the logged chunk at 200 bytes — an HTTP body in an error
103            // context should convey "what server said" without dumping a
104            // multi-MB stack trace to the user's terminal.
105            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
115/// POST a JSON body to a GitHub API endpoint via the `gh` CLI.
116///
117/// Returns the parsed JSON response on success. The caller is responsible for
118/// ensuring that `gh` is installed and authenticated.
119fn 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(); // close stdin
136
137    let output = child.wait_with_output()?;
138    if !output.status.success() {
139        let stderr_raw = String::from_utf8_lossy(&output.stderr);
140        // `gh_api_post` does not currently accept a token argument, but
141        // routing through `redact_process_env` still covers any token
142        // exported as `GITHUB_TOKEN` / `GH_TOKEN` in the parent env, plus
143        // inline URL credentials. Redact the full bail string so an
144        // endpoint containing a secret-shaped path segment is also covered.
145        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
154/// Create a tag via the GitHub API (using the `gh` CLI).
155///
156/// This avoids the need for local git push access. Requires the `gh` CLI to be
157/// installed and authenticated (`gh auth login`). The GitHub API creates a
158/// lightweight tag object pointing at the HEAD commit on the default branch.
159///
160/// Falls back to [`create_and_push_tag`] if `gh` is not available.
161pub 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    // Detect owner/repo from the origin remote.
177    let (owner, repo) = detect_github_repo()?;
178
179    // Get the current HEAD SHA to point the tag at.
180    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}