返回 CodeWhale
cli.rs
根目录 / crates / tui / src / tools / github / cli.rs
1 //! Command construction: every `gh` and `git` process this tool runs.
2 //!
3 //! Nothing above this file assembles an argv or resolves a binary path, so
4 //! "what did the tool actually shell out to" has exactly one answer.
5
6 use std::process::Command;
7
8 use serde_json::Value;
9
10 use crate::dependencies::ExternalTool;
11 use crate::tools::spec::{ToolContext, ToolError};
12
13 const DEFAULT_GH: &str = "/opt/homebrew/bin/gh";
14 const FALLBACK_GH_PATHS: &[&str] = &[
15 "/usr/bin/gh", // Linux system package manager
16 "/usr/local/bin/gh", // macOS Intel Homebrew / manual install
17 "/home/linuxbrew/.linuxbrew/bin/gh", // Linux Homebrew (official prefix)
18 "/opt/homebrew/bin/gh", // macOS Apple Silicon Homebrew
19 ];
20
21 fn gh_bin() -> String {
22 if let Ok(bin) = std::env::var("CODEWHALE_GH_BIN").or_else(|_| std::env::var("DEEPSEEK_GH_BIN"))
23 {
24 return bin;
25 }
26 for path in FALLBACK_GH_PATHS {
27 if std::path::Path::new(path).is_file() {
28 return path.to_string();
29 }
30 }
31 DEFAULT_GH.to_string()
32 }
33
34 pub(super) fn run_gh_text(context: &ToolContext, args: &[&str]) -> Result<String, ToolError> {
35 let out = Command::new(gh_bin())
36 .args(args)
37 .current_dir(&context.workspace)
38 .output()
39 .map_err(|e| {
40 if e.kind() == std::io::ErrorKind::NotFound {
41 ToolError::not_available("gh CLI not found; install it or set DEEPSEEK_GH_BIN")
42 } else {
43 ToolError::execution_failed(format!("failed to run gh: {e}"))
44 }
45 })?;
46 if !out.status.success() {
47 return Err(ToolError::execution_failed(format!(
48 "gh {} failed: {}",
49 args.join(" "),
50 String::from_utf8_lossy(&out.stderr).trim()
51 )));
52 }
53 Ok(String::from_utf8_lossy(&out.stdout).to_string())
54 }
55
56 pub(super) fn run_gh_json(context: &ToolContext, args: &[&str]) -> Result<Value, ToolError> {
57 let text = run_gh_text(context, args)?;
58 serde_json::from_str(&text).map_err(|e| ToolError::execution_failed(e.to_string()))
59 }
60
61 pub(super) fn ensure_github_repo(context: &ToolContext) -> Result<(), ToolError> {
62 let out = crate::dependencies::Git::output(
63 &["rev-parse", "--is-inside-work-tree"],
64 &context.workspace,
65 )
66 .map_err(|e| ToolError::execution_failed(format!("failed to run git: {e}")))?;
67 if out.status.success() {
68 Ok(())
69 } else {
70 Err(ToolError::not_available(
71 "current workspace is not a git repository",
72 ))
73 }
74 }
75
76 pub(super) fn git_status_porcelain(context: &ToolContext) -> Result<String, ToolError> {
77 let out = crate::dependencies::Git::output(&["status", "--porcelain"], &context.workspace)
78 .map_err(|e| ToolError::execution_failed(format!("failed to run git status: {e}")))?;
79 if !out.status.success() {
80 return Err(ToolError::execution_failed(format!(
81 "git status failed ({}); cannot verify that the worktree is clean",
82 out.status
83 )));
84 }
85 Ok(String::from_utf8_lossy(&out.stdout).to_string())
86 }
87
88 /// One captured Core repo selection, reused for every step (including comment-before-close).
89 pub(super) struct HostTarget {
90 repo: String,
91 host: String,
92 }
93 pub(super) async fn host_target(
94 context: &ToolContext,
95 cancel: &tokio_util::sync::CancellationToken,
96 ) -> Result<HostTarget, ToolError> {
97 let override_repo = std::env::var("GH_REPO")
98 .ok()
99 .filter(|value| !value.is_empty());
100 let (host, repo) = if let Some(repo) = override_repo {
101 let parts = repo.split('/').collect::<Vec<_>>();
102 match parts.as_slice() {
103 [owner, name] => (
104 std::env::var("GH_HOST").unwrap_or_else(|_| "github.com".into()),
105 format!("{owner}/{name}"),
106 ),
107 [host, owner, name] => (host.to_string(), format!("{owner}/{name}")),
108 _ => {
109 return Err(ToolError::invalid_input(
110 "GitHub repository override is invalid",
111 ));
112 }
113 }
114 } else {
115 let remote = host_git(context, &["remote", "get-url", "origin"], cancel).await?;
116 let remote = remote.trim();
117 let parsed = if remote.contains("://") {
118 url::Url::parse(remote)
119 } else if let Some((host, path)) = remote.split_once(':') {
120 url::Url::parse(&format!("ssh://{host}/{path}"))
121 } else {
122 return Err(ToolError::not_available(
123 "GitHub requires a network origin repository",
124 ));
125 };
126 let url = parsed.map_err(|_| ToolError::invalid_input("GitHub origin is invalid"))?;
127 if !matches!(url.scheme(), "https" | "http" | "ssh")
128 || url.password().is_some()
129 || url.query().is_some()
130 || url.fragment().is_some()
131 {
132 return Err(ToolError::invalid_input("GitHub origin is invalid"));
133 }
134 let host = std::env::var("GH_HOST")
135 .ok()
136 .filter(|host| !host.is_empty())
137 .or_else(|| url.host_str().map(str::to_string))
138 .ok_or_else(|| ToolError::invalid_input("GitHub origin host missing"))?;
139 (
140 host,
141 url.path()
142 .trim_matches('/')
143 .trim_end_matches(".git")
144 .to_string(),
145 )
146 };
147 if host.is_empty()
148 || host
149 .chars()
150 .any(|c| !c.is_ascii_alphanumeric() && !matches!(c, '.' | '-'))
151 || repo.split('/').count() != 2
152 || repo.split('/').any(|part| {
153 part.is_empty()
154 || part
155 .chars()
156 .any(|c| !c.is_ascii_alphanumeric() && !matches!(c, '.' | '_' | '-'))
157 })
158 {
159 return Err(ToolError::invalid_input(
160 "GitHub repository selection is invalid",
161 ));
162 }
163 Ok(HostTarget {
164 repo: format!("{host}/{repo}"),
165 host,
166 })
167 }
168 pub(super) async fn host_gh(
169 context: &ToolContext,
170 target: &HostTarget,
171 args: &[&str],
172 input: Option<&[u8]>,
173 cancel: &tokio_util::sync::CancellationToken,
174 ) -> Result<String, ToolError> {
175 use crate::network_policy::Decision;
176 if context
177 .tool_authority
178 .as_ref()
179 .is_some_and(|authority| authority.network_access == Some(false))
180 {
181 return Err(ToolError::permission_denied(
182 "GitHub network access exceeds the current authority",
183 ));
184 }
185 if context
186 .network_policy
187 .as_ref()
188 .is_some_and(|policy| policy.evaluate(&target.host, "github") != Decision::Allow)
189 {
190 return Err(ToolError::permission_denied(
191 "GitHub access is blocked or awaiting network approval",
192 ));
193 }
194 let mut command = if let Ok(binary) =
195 std::env::var("CODEWHALE_GH_BIN").or_else(|_| std::env::var("DEEPSEEK_GH_BIN"))
196 {
197 tokio::process::Command::new(binary)
198 } else {
199 crate::dependencies::Gh::tokio_command()
200 .ok_or_else(|| ToolError::not_available("gh CLI is unavailable"))?
201 };
202 crate::utils::suppress_tokio_console_window(&mut command);
203 command
204 .args(args)
205 .args(["--repo", &target.repo])
206 .env("GH_HOST", &target.host)
207 .env_remove("GH_REPO")
208 .env("GH_PROMPT_DISABLED", "1")
209 .env("GH_PAGER", "cat")
210 .env("GIT_TERMINAL_PROMPT", "0")
211 .current_dir(&context.workspace);
212 host_output(&mut command, input.unwrap_or_default(), cancel, "GitHub").await
213 }
214 pub(super) async fn host_git(
215 context: &ToolContext,
216 args: &[&str],
217 cancel: &tokio_util::sync::CancellationToken,
218 ) -> Result<String, ToolError> {
219 let mut command = crate::dependencies::Git::tokio_command()
220 .ok_or_else(|| ToolError::not_available("git is unavailable"))?;
221 command.args(args).current_dir(&context.workspace);
222 host_output(&mut command, &[], cancel, "Git repository inspection").await
223 }
224 async fn host_output(
225 command: &mut tokio::process::Command,
226 input: &[u8],
227 cancel: &tokio_util::sync::CancellationToken,
228 label: &str,
229 ) -> Result<String, ToolError> {
230 if cancel.is_cancelled() {
231 return Err(ToolError::not_available("GitHub operation cancelled"));
232 }
233 let output = crate::process_tree::contained_output_with_input_bounded(
234 command,
235 input.to_vec(),
236 1024 * 1024,
237 64 * 1024,
238 cancel.cancelled(),
239 )
240 .await
241 .map_err(|_| {
242 ToolError::execution_failed(format!(
243 "{label} process failed or exceeded its output bound"
244 ))
245 })?;
246 if output.stopped {
247 return Err(ToolError::cancelled("GitHub operation cancelled"));
248 }
249 if !output.output.status.success() {
250 return Err(ToolError::execution_failed(format!(
251 "{label} process failed; private command arguments and diagnostics were withheld"
252 )));
253 }
254 String::from_utf8(output.output.stdout)
255 .map_err(|_| ToolError::execution_failed(format!("{label} output was not UTF-8")))
256 }
257
258 #[cfg(all(test, unix))]
259 mod host_tests {
260 use super::*;
261 #[tokio::test(flavor = "current_thread")]
262 async fn bounded_github_output_refuses_oversize_and_cancelled_children() {
263 let mut command = tokio::process::Command::new("sh");
264 command.args(["-c", "head -c 1048584 /dev/zero"]);
265 let error = host_output(
266 &mut command,
267 &[],
268 &tokio_util::sync::CancellationToken::new(),
269 "GitHub",
270 )
271 .await
272 .unwrap_err()
273 .to_string();
274 assert!(error.contains("bound"));
275 let mut command = tokio::process::Command::new("sh");
276 command.args(["-c", "sleep 30"]);
277 let cancel = tokio_util::sync::CancellationToken::new();
278 let stop = cancel.clone();
279 let (done_tx, done_rx) = tokio::sync::oneshot::channel();
280 tokio::spawn(async move {
281 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
282 stop.cancel();
283 done_tx.send(()).unwrap();
284 });
285 let result = tokio::time::timeout(
286 std::time::Duration::from_secs(2),
287 host_output(&mut command, &[], &cancel, "GitHub"),
288 )
289 .await
290 .unwrap();
291 assert!(result.unwrap_err().to_string().contains("cancelled"));
292 done_rx.await.unwrap();
293 }
294 }
295
295 lines RUST