Skip to main content

sparrow/
github.rs

1//! GitHub Action / remote PR workflow helpers.
2//!
3//! These functions thinly wrap the `gh` CLI so that workflow steps can call
4//! `sparrow github review|status|logs` from inside an Action. They never
5//! invent results: a missing `gh` binary or missing `GITHUB_TOKEN` returns a
6//! clear error.
7
8use std::process::Command;
9
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct ReviewPlan {
12    pub pr: u64,
13    pub model: Option<String>,
14    pub allowed_tools: Vec<String>,
15    pub dry_run: bool,
16    pub diff_preview: String,
17}
18
19/// Returns Err with a clear message when the environment is not set up for
20/// GitHub Action use. Used by every subcommand so the action fails loudly when
21/// secrets are missing instead of silently no-oping.
22pub fn require_action_env() -> anyhow::Result<()> {
23    if std::env::var("GITHUB_TOKEN")
24        .ok()
25        .filter(|s| !s.is_empty())
26        .is_none()
27    {
28        anyhow::bail!(
29            "GITHUB_TOKEN is not set. The Sparrow GitHub Action requires a token \
30             with `pull-requests: write` and `contents: read` permissions."
31        );
32    }
33    if !gh_available() {
34        anyhow::bail!(
35            "`gh` CLI is not on PATH. The Sparrow GitHub Action depends on the \
36             official GitHub CLI being installed on the runner."
37        );
38    }
39    Ok(())
40}
41
42pub fn gh_available() -> bool {
43    Command::new("gh")
44        .arg("--version")
45        .stdout(std::process::Stdio::null())
46        .stderr(std::process::Stdio::null())
47        .status()
48        .map(|s| s.success())
49        .unwrap_or(false)
50}
51
52/// Builds a `ReviewPlan` from inputs. With `dry_run = true` this never shells
53/// out and is safe in tests.
54pub fn plan_review(
55    pr: u64,
56    model: Option<String>,
57    allowed_tools: Option<String>,
58    dry_run: bool,
59) -> ReviewPlan {
60    let tools = allowed_tools
61        .map(|s| {
62            s.split(',')
63                .map(|t| t.trim().to_string())
64                .filter(|t| !t.is_empty())
65                .collect()
66        })
67        .unwrap_or_default();
68    ReviewPlan {
69        pr,
70        model,
71        allowed_tools: tools,
72        dry_run,
73        diff_preview: String::new(),
74    }
75}
76
77/// Fetch the diff for `pr` via `gh pr diff <pr>`. Returns the stdout text on
78/// success, otherwise an error that includes stderr — never a silent empty
79/// string.
80pub fn fetch_pr_diff(pr: u64) -> anyhow::Result<String> {
81    let out = Command::new("gh")
82        .args(["pr", "diff", &pr.to_string()])
83        .output()?;
84    if !out.status.success() {
85        anyhow::bail!(
86            "gh pr diff {} failed (exit {:?}): {}",
87            pr,
88            out.status.code(),
89            String::from_utf8_lossy(&out.stderr)
90        );
91    }
92    Ok(String::from_utf8_lossy(&out.stdout).to_string())
93}
94
95/// Returns `gh run list --limit 5` as raw text. Used by `sparrow github status`.
96pub fn ci_status() -> anyhow::Result<String> {
97    let out = Command::new("gh")
98        .args(["run", "list", "--limit", "5"])
99        .output()?;
100    if !out.status.success() {
101        anyhow::bail!(
102            "gh run list failed (exit {:?}): {}",
103            out.status.code(),
104            String::from_utf8_lossy(&out.stderr)
105        );
106    }
107    Ok(String::from_utf8_lossy(&out.stdout).to_string())
108}
109
110/// Returns `gh run view <id> --log` as raw text. Used by `sparrow github logs`.
111pub fn ci_logs(run_id: &str) -> anyhow::Result<String> {
112    let out = Command::new("gh")
113        .args(["run", "view", run_id, "--log"])
114        .output()?;
115    if !out.status.success() {
116        anyhow::bail!(
117            "gh run view {} --log failed (exit {:?}): {}",
118            run_id,
119            out.status.code(),
120            String::from_utf8_lossy(&out.stderr)
121        );
122    }
123    Ok(String::from_utf8_lossy(&out.stdout).to_string())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn missing_token_is_clear_error() {
132        // Force-unset; safe in test process.
133        unsafe {
134            std::env::remove_var("GITHUB_TOKEN");
135        }
136        let res = require_action_env();
137        assert!(res.is_err());
138        let msg = res.unwrap_err().to_string();
139        assert!(msg.contains("GITHUB_TOKEN"), "got: {}", msg);
140    }
141
142    #[test]
143    fn plan_review_parses_tools_csv() {
144        let plan = plan_review(
145            42,
146            Some("claude-sonnet-4-6".into()),
147            Some("fs_read, edit, search".into()),
148            true,
149        );
150        assert_eq!(plan.pr, 42);
151        assert_eq!(plan.model.as_deref(), Some("claude-sonnet-4-6"));
152        assert_eq!(plan.allowed_tools, vec!["fs_read", "edit", "search"]);
153        assert!(plan.dry_run);
154    }
155
156    #[test]
157    fn plan_review_handles_no_tools() {
158        let plan = plan_review(1, None, None, true);
159        assert!(plan.allowed_tools.is_empty());
160        assert!(plan.model.is_none());
161    }
162
163    #[test]
164    fn plan_review_trims_empty_csv_entries() {
165        let plan = plan_review(7, None, Some(" , fs_read , ".into()), false);
166        assert_eq!(plan.allowed_tools, vec!["fs_read"]);
167    }
168}