Skip to main content

anodizer_core/git/
github_api.rs

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
9/// GET a GitHub API endpoint via the `gh` CLI (single request, no pagination).
10///
11/// Returns the parsed JSON response. Useful for endpoints that return a single
12/// object (e.g. the Compare API) rather than a paginated array.
13pub 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
17/// GET a GitHub API endpoint via `gh_binary` (single request, no pagination).
18///
19/// Path-taking sibling of [`gh_api_get`] so tests can point at a missing or
20/// stub binary inside a `tempfile::tempdir()` without mutating `PATH`.
21/// When `gh_binary` has no separator (e.g. `Path::new("gh")`),
22/// [`Command::new`] falls back to a PATH lookup — the production
23/// behavior — so the wrapper is a true no-op in normal use.
24pub 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
48/// Redact secrets from `gh` CLI stderr before interpolating into a bail
49/// message. `token` is the `GITHUB_TOKEN` value passed to the
50/// subprocess; if the user-supplied token leaks (e.g. via a verbose `gh`
51/// error that echoes the auth header), it is replaced with `$GITHUB_TOKEN`
52/// regardless of whether the value matches the `redact::is_secret`
53/// heuristics. Also strips inline URL credentials and any other secret
54/// env-var values reachable from the parent process env.
55fn 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
66/// GET a GitHub API endpoint via the `gh` CLI, with pagination.
67///
68/// Returns a JSON array of all pages concatenated. The caller is responsible for
69/// ensuring that `gh` is installed and authenticated.
70pub 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
74/// Paginated GET via `gh_binary`. Path-taking sibling of
75/// [`gh_api_get_paginated`].
76pub 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    // Try parsing the entire response first before falling back to splitting.
101    // This avoids the split_inclusive(']') approach corrupting non-array responses.
102    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        // Single object response (e.g. non-list endpoint) — wrap in a vec.
107        return Ok(vec![val]);
108    }
109
110    // Whole-parse failed — gh --paginate may return multiple JSON arrays
111    // concatenated (e.g. `[...][...]`). Split on `]` boundaries and parse each chunk.
112    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            // Log unparseable chunks so corrupt data doesn't go unnoticed.
126            // The chunk may carry secret-shaped request/response data, and the
127            // tracing subscriber performs NO redaction of its own — so redact
128            // here (process-env secret values + inline URL credentials) before
129            // emitting. Cap the logged chunk at 200 bytes — an HTTP body in an
130            // error context should convey "what server said" without dumping a
131            // multi-MB stack trace to the user's terminal.
132            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
144/// POST via `gh_binary`. Internal helper consumed by
145/// [`create_tag_via_github_api_in`]; takes an explicit binary path so
146/// tests can drive the failure path against a missing or stub binary.
147fn 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(); // close stdin
168
169    let output = child.wait_with_output()?;
170    if !output.status.success() {
171        let stderr_raw = String::from_utf8_lossy(&output.stderr);
172        // `gh_api_post` does not currently accept a token argument, but
173        // routing through `redact_process_env` still covers any token
174        // exported as `GITHUB_TOKEN` / `GH_TOKEN` in the parent env, plus
175        // inline URL credentials. Redact the full bail string so an
176        // endpoint containing a secret-shaped path segment is also covered.
177        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
186/// Create a tag via the GitHub API (using the `gh` CLI).
187///
188/// This avoids the need for local git push access. Requires the `gh` CLI to be
189/// installed and authenticated (`gh auth login`). The GitHub API creates a
190/// lightweight tag object pointing at the HEAD commit on the default branch.
191///
192/// Falls back to [`create_and_push_tag_in`] if `gh` is not available.
193pub 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/// Path-taking sibling of [`create_tag_via_github_api`].
212///
213/// `cwd` is the repository the tag should be created against (used for
214/// `git remote get-url origin` and `git rev-parse HEAD` lookups, plus
215/// the local `git tag -a` fallback when `gh_binary` is missing).
216/// `gh_binary` is the path to the `gh` CLI; pass `Path::new("gh")` to
217/// keep the production PATH-lookup behavior.
218#[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    // Detect owner/repo from the origin remote.
237    let (owner, repo) = detect_github_repo_in(cwd)?;
238
239    // Get the current HEAD SHA to point the tag at.
240    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    /// `dry_run=true` must short-circuit before any subprocess spawn.
291    #[test]
292    fn create_tag_dry_run_short_circuits() {
293        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
294        // Even with no git repo / no gh CLI, dry-run must succeed.
295        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    /// Redact: the token must be replaced with the literal `$GITHUB_TOKEN`
300    /// placeholder when it appears verbatim in the stderr output. Catches
301    /// the case where `gh` echoes the auth header in a verbose error.
302    #[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        // Inline URL credentials must be redacted even with no explicit
316        // token argument.
317        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        // An empty Some("") token must not pollute the env vector with a
328        // zero-length value (that would match every position in the string).
329        let stderr = "plain error message without credentials";
330        let redacted = redact_gh_stderr(stderr, Some(""));
331        assert_eq!(redacted, stderr);
332    }
333
334    /// `gh_api_get_with_binary` must surface a user-actionable spawn
335    /// failure when the binary path doesn't exist on disk.
336    ///
337    /// Drives the function with a temp-dir-relative path that points to
338    /// nothing, asserting the error mentions "spawn gh" so the operator
339    /// can correlate it with their missing-`gh` install state.
340    #[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    /// Same guarantee for the paginated sibling.
354    #[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    /// `create_tag_via_github_api_in` with `strict=true` must error
368    /// when `cwd` is not a git repo — the inner `detect_github_repo_in`
369    /// drives `git remote get-url origin` and fails there.
370    ///
371    /// Skips when `git` isn't on PATH (mirrors `tool_on_path` patterns
372    /// elsewhere in the suite).
373    #[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    /// Dry-run short-circuit must also fire on the cwd-injectable entry
403    /// point — covers the new branch without re-hitting the inner
404    /// detection codepath.
405    #[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}