1use 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
19pub 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
52pub 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
77pub 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
95pub 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
110pub 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 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}