返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tools / github / mod.rs
1 //! GitHub context and guarded write tools backed by the `gh` CLI.
2 //!
3 //! Unified surface (piagent phase B): the model sees one tool, `github`,
4 //! with an `action` parameter routing to the per-action logic. The legacy
5 //! `github_*` execution aliases were removed in v0.9.3.
6 //!
7 //! This file is the surface and its guards — which action a call names, and
8 //! whether the input is allowed to run it. The work itself is split by
9 //! responsibility: [`schema`] declares the input contracts, [`actions`] runs
10 //! the actions, [`cli`] builds every `gh`/`git` invocation, and [`shape`]
11 //! turns payloads into tool results.
12
13 use async_trait::async_trait;
14 use serde_json::Value;
15
16 use crate::tools::spec::{
17 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
18 };
19
20 mod actions;
21 mod cli;
22 mod schema;
23 mod shape;
24
25 use actions::{GithubCloseTarget, close_github_thread};
26 use schema::{canonical_schema, legacy_action_schema};
27
28 // The suite at the bottom of this file builds JSON inputs and a recorder path
29 // that nothing in the production surface above names.
30 #[cfg(test)]
31 use serde_json::json;
32 // Unix-only like the recorder helper that returns it (`install_recording_gh`)
33 // — on Windows the test binary compiles without them, and an ungated import
34 // fails `-D warnings`.
35 #[cfg(all(test, unix))]
36 use std::path::PathBuf;
37
38 /// Actions the Plan-mode read-only surface exposes.
39 const READ_ACTIONS: &[&str] = &["issue_context", "pr_context"];
40 const ALL_ACTIONS: &[&str] = &[
41 "issue_context",
42 "pr_context",
43 "comment",
44 "close_issue",
45 "close_pr",
46 ];
47
48 /// Unified GitHub tool.
49 ///
50 /// One struct, one input schema per surface: the canonical `github` tool
51 /// (all actions, or the read-only subset via [`GithubTool::read_only`]) plus
52 /// hidden legacy aliases carrying a `forced_action`.
53 pub struct GithubTool {
54 name: &'static str,
55 forced_action: Option<&'static str>,
56 read_only: bool,
57 }
58
59 impl GithubTool {
60 pub const fn new(name: &'static str) -> Self {
61 Self {
62 name,
63 forced_action: None,
64 read_only: false,
65 }
66 }
67
68 /// Plan-mode variant: only the read-only actions are advertised and routed.
69 pub const fn read_only(name: &'static str) -> Self {
70 Self {
71 name,
72 forced_action: None,
73 read_only: true,
74 }
75 }
76
77 #[cfg(test)]
78 pub const fn alias(name: &'static str, action: &'static str) -> Self {
79 Self {
80 name,
81 forced_action: Some(action),
82 read_only: false,
83 }
84 }
85
86 fn allowed_actions(&self) -> &'static [&'static str] {
87 if self.read_only {
88 READ_ACTIONS
89 } else {
90 ALL_ACTIONS
91 }
92 }
93
94 fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> {
95 let action = match self.forced_action {
96 Some(action) => action,
97 None => input.get("action").and_then(Value::as_str).ok_or_else(|| {
98 ToolError::invalid_input(format!(
99 "github: missing `action` (one of: {})",
100 self.allowed_actions().join(", ")
101 ))
102 })?,
103 };
104 if self.allowed_actions().contains(&action) {
105 Ok(action)
106 } else {
107 Err(ToolError::invalid_input(format!(
108 "github: invalid action `{action}` (one of: {})",
109 self.allowed_actions().join(", ")
110 )))
111 }
112 }
113
114 fn action_is_read(action: &str) -> bool {
115 READ_ACTIONS.contains(&action)
116 }
117 }
118
119 #[async_trait]
120 impl ToolSpec for GithubTool {
121 fn name(&self) -> &'static str {
122 self.name
123 }
124
125 fn model_visible(&self) -> bool {
126 self.forced_action.is_none()
127 }
128
129 fn description(&self) -> &'static str {
130 match self.forced_action {
131 Some("issue_context") => {
132 "Read GitHub issue context using gh. Read-only: body/comments/labels/state are summarized and large bodies become task artifacts when a durable task is active."
133 }
134 Some("pr_context") => {
135 "Read GitHub PR context using gh: body/comments/reviews/check status/files and optional diff artifact. Read-only; no push/merge/close."
136 }
137 Some("comment") => {
138 "Post an evidence-backed GitHub issue/PR comment with gh. Requires approval. Use blocker comments for partial work; do not claim closure without evidence."
139 }
140 Some("close_issue") => {
141 "Close a GitHub issue only when structured acceptance evidence is present and approved. For pull requests use github_close_pr; do not call PRs issues in user-facing output. Never close merely because the agent is stopping."
142 }
143 Some("close_pr") => {
144 "Close a GitHub pull request only when structured acceptance evidence is present and approved. Use this for PRs instead of github_close_issue so the UI, audit trail, and comments keep PR wording clear."
145 }
146 _ if self.read_only => {
147 "Read GitHub issue/PR context using gh. Actions: \"issue_context\" and \"pr_context\"; bodies/comments/labels/state are summarized and large bodies become task artifacts when a durable task is active."
148 }
149 _ => {
150 "Read and guardedly mutate GitHub issues/PRs using gh. Actions: \"issue_context\", \"pr_context\" (read-only; large bodies become task artifacts when a durable task is active), \"comment\" (approval; evidence-backed), \"close_issue\", \"close_pr\" (approval; only with structured acceptance evidence — never close merely because the agent is stopping). No push/merge."
151 }
152 }
153 }
154
155 fn input_schema(&self) -> Value {
156 if let Some(action) = self.forced_action {
157 return legacy_action_schema(action);
158 }
159 canonical_schema(self.allowed_actions(), self.read_only)
160 }
161
162 fn capabilities(&self) -> Vec<ToolCapability> {
163 match self.forced_action {
164 Some(action) if Self::action_is_read(action) => {
165 vec![ToolCapability::ReadOnly, ToolCapability::Network]
166 }
167 Some(_) => vec![ToolCapability::Network, ToolCapability::RequiresApproval],
168 None if self.read_only => vec![ToolCapability::ReadOnly, ToolCapability::Network],
169 None => vec![ToolCapability::Network, ToolCapability::RequiresApproval],
170 }
171 }
172
173 fn approval_requirement(&self) -> ApprovalRequirement {
174 match self.forced_action {
175 Some(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
176 Some(_) => ApprovalRequirement::Required,
177 None if self.read_only => ApprovalRequirement::Auto,
178 None => ApprovalRequirement::Required,
179 }
180 }
181
182 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
183 match self.resolve_action(input) {
184 Ok(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
185 _ => ApprovalRequirement::Required,
186 }
187 }
188
189 fn is_read_only_for(&self, input: &Value) -> bool {
190 match self.resolve_action(input) {
191 Ok(action) => Self::action_is_read(action),
192 Err(_) => self.is_read_only(),
193 }
194 }
195
196 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
197 match self.resolve_action(&input)? {
198 "issue_context" => self.execute_issue_context(&input, context).await,
199 "pr_context" => self.execute_pr_context(&input, context).await,
200 "comment" => self.execute_comment(&input, context).await,
201 "close_issue" => close_github_thread(input, context, GithubCloseTarget::Issue),
202 "close_pr" => close_github_thread(input, context, GithubCloseTarget::Pr),
203 action => Err(ToolError::invalid_input(format!(
204 "github: invalid action `{action}`"
205 ))),
206 }
207 }
208 }
209
210 fn validate_evidence(input: &Value, closing: bool) -> Result<(), ToolError> {
211 let evidence = input
212 .get("evidence")
213 .and_then(Value::as_object)
214 .ok_or_else(|| ToolError::invalid_input("evidence object is required"))?;
215 if closing {
216 let criteria = input
217 .get("acceptance_criteria")
218 .and_then(Value::as_array)
219 .filter(|items| !items.is_empty())
220 .ok_or_else(|| ToolError::invalid_input("acceptance_criteria must be non-empty"))?;
221 if criteria
222 .iter()
223 .any(|item| item.as_str().unwrap_or("").trim().is_empty())
224 {
225 return Err(ToolError::invalid_input(
226 "acceptance_criteria entries must be non-empty",
227 ));
228 }
229 for key in ["files_changed", "tests_run", "final_status"] {
230 if !evidence.contains_key(key) {
231 return Err(ToolError::invalid_input(format!(
232 "closure evidence missing {key}"
233 )));
234 }
235 }
236 }
237 Ok(())
238 }
239
240 #[cfg(test)]
241 mod tests {
242 use super::*;
243 use crate::tools::spec::ToolSpec;
244
245 #[test]
246 fn close_schema_requires_structured_evidence() {
247 let schema = GithubTool::alias("github_close_issue", "close_issue").input_schema();
248 assert!(
249 schema["properties"]["evidence"]["required"]
250 .as_array()
251 .expect("required")
252 .contains(&json!("tests_run"))
253 );
254 }
255
256 #[test]
257 fn close_pr_schema_requires_structured_evidence() {
258 let schema = GithubTool::alias("github_close_pr", "close_pr").input_schema();
259 assert!(
260 schema["properties"]["evidence"]["required"]
261 .as_array()
262 .expect("required")
263 .contains(&json!("tests_run"))
264 );
265 }
266
267 #[test]
268 fn close_tools_distinguish_issue_and_pr_wording() {
269 assert_eq!(GithubCloseTarget::Issue.display(), "issue");
270 assert_eq!(GithubCloseTarget::Pr.display(), "PR");
271 assert!(
272 GithubTool::alias("github_close_issue", "close_issue")
273 .description()
274 .contains("github_close_pr")
275 );
276 assert!(
277 GithubTool::alias("github_close_pr", "close_pr")
278 .description()
279 .contains("pull request")
280 );
281 }
282
283 #[test]
284 fn missing_close_evidence_refuses() {
285 let input = json!({
286 "number": 1,
287 "acceptance_criteria": ["done"],
288 "evidence": { "files_changed": [] }
289 });
290 let err = validate_evidence(&input, true).expect_err("should refuse");
291 assert!(err.to_string().contains("tests_run"));
292 }
293
294 #[test]
295 fn canonical_schema_lists_all_actions() {
296 let schema = GithubTool::new("github").input_schema();
297 let actions = schema["properties"]["action"]["enum"]
298 .as_array()
299 .expect("action enum");
300 for action in [
301 "issue_context",
302 "pr_context",
303 "comment",
304 "close_issue",
305 "close_pr",
306 ] {
307 assert!(
308 actions.iter().any(|value| value.as_str() == Some(action)),
309 "canonical schema must offer action {action}"
310 );
311 }
312 for field in [
313 "number",
314 "target",
315 "body",
316 "evidence",
317 "acceptance_criteria",
318 ] {
319 assert!(
320 schema["properties"][field].is_object(),
321 "canonical schema must carry union field {field}"
322 );
323 }
324 assert_eq!(schema["additionalProperties"], json!(false));
325 }
326
327 #[test]
328 fn read_only_variant_only_offers_read_actions() {
329 let tool = GithubTool::read_only("github");
330 let schema = tool.input_schema();
331 assert_eq!(
332 schema["properties"]["action"]["enum"],
333 json!(["issue_context", "pr_context"])
334 );
335 assert!(!schema["properties"]["body"].is_object());
336 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
337 assert!(tool.is_read_only());
338 }
339
340 #[test]
341 fn aliases_hide_from_model_and_force_action() {
342 let comment = GithubTool::alias("github_comment", "comment");
343 assert!(!comment.model_visible());
344 assert_eq!(comment.name(), "github_comment");
345 assert_eq!(
346 comment.approval_requirement(),
347 ApprovalRequirement::Required
348 );
349 assert!(comment.capabilities().contains(&ToolCapability::Network));
350
351 let issue = GithubTool::alias("github_issue_context", "issue_context");
352 assert_eq!(issue.approval_requirement(), ApprovalRequirement::Auto);
353 assert!(issue.is_read_only_for(&json!({})));
354
355 let canonical = GithubTool::new("github");
356 assert!(canonical.model_visible());
357 assert_eq!(
358 canonical.approval_requirement_for(&json!({"action": "pr_context"})),
359 ApprovalRequirement::Auto
360 );
361 assert_eq!(
362 canonical.approval_requirement_for(&json!({"action": "close_pr"})),
363 ApprovalRequirement::Required
364 );
365 assert!(canonical.is_read_only_for(&json!({"action": "issue_context"})));
366 assert!(!canonical.is_read_only_for(&json!({"action": "comment"})));
367 }
368
369 #[test]
370 fn canonical_rejects_unknown_or_missing_action() {
371 let tool = GithubTool::new("github");
372 let err = tool
373 .resolve_action(&json!({}))
374 .expect_err("missing action must fail");
375 assert!(err.to_string().contains("missing `action`"));
376 let err = tool
377 .resolve_action(&json!({"action": "merge"}))
378 .expect_err("unknown action must fail");
379 assert!(err.to_string().contains("invalid action"));
380
381 let read_only = GithubTool::read_only("github");
382 let err = read_only
383 .resolve_action(&json!({"action": "close_pr"}))
384 .expect_err("read-only surface must reject write actions");
385 assert!(err.to_string().contains("invalid action"));
386 }
387
388 /// Install a `gh` stand-in that appends its argv to `log` and succeeds.
389 ///
390 /// The close path must never reach a real `gh`, so the recorder both
391 /// proves what was attempted and keeps the test from touching GitHub.
392 /// Unix-only like its consumers: the recorder is a `sh` script, and on
393 /// Windows the ungated helper is dead code that fails `-D warnings` —
394 /// this was the unexplained red `Test (windows-latest)` on #5135.
395 #[cfg(unix)]
396 fn install_recording_gh(dir: &std::path::Path, log: &std::path::Path) -> PathBuf {
397 let bin = dir.join("gh-recorder.sh");
398 std::fs::write(
399 &bin,
400 format!(
401 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexit 0\n",
402 log.display()
403 ),
404 )
405 .expect("write recorder");
406 #[cfg(unix)]
407 {
408 use std::os::unix::fs::PermissionsExt;
409 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
410 .expect("chmod recorder");
411 }
412 bin
413 }
414
415 #[cfg(unix)]
416 fn close_input_with_dry_run(dry_run: Value) -> Value {
417 json!({
418 "number": 424_242,
419 "allow_dirty": true,
420 "dry_run": dry_run,
421 "acceptance_criteria": ["done"],
422 "evidence": {
423 "files_changed": ["src/lib.rs"],
424 "tests_run": ["cargo test"],
425 "final_status": "green"
426 }
427 })
428 }
429
430 #[test]
431 #[cfg(unix)]
432 fn stringy_dry_run_never_closes_the_thread() {
433 let tmp = tempfile::tempdir().expect("tempdir");
434 let log = tmp.path().join("gh-calls.log");
435 let bin = install_recording_gh(tmp.path(), &log);
436 let ctx = ToolContext::new(tmp.path());
437
438 let _env = crate::test_support::lock_test_env();
439 // SAFETY: serialized behind the process-wide test env lock.
440 unsafe {
441 std::env::set_var("CODEWHALE_GH_BIN", &bin);
442 }
443 let result = close_github_thread(
444 close_input_with_dry_run(json!("true")),
445 &ctx,
446 GithubCloseTarget::Issue,
447 );
448 // SAFETY: same lock; restores the process environment.
449 unsafe {
450 std::env::remove_var("CODEWHALE_GH_BIN");
451 }
452
453 let invocations = std::fs::read_to_string(&log).unwrap_or_default();
454 assert!(
455 invocations.is_empty(),
456 "a stringy dry_run must not invoke gh at all; got: {invocations}"
457 );
458 let err = result.expect_err("dry_run must not be silently coerced to its default");
459 let err = err.to_string();
460 assert!(err.contains("dry_run"), "error must name the field: {err}");
461 assert!(
462 err.contains("boolean") && err.contains("string"),
463 "error must name expected and received types: {err}"
464 );
465 }
466
467 #[test]
468 #[cfg(unix)]
469 fn real_dry_run_bool_still_short_circuits() {
470 let tmp = tempfile::tempdir().expect("tempdir");
471 let log = tmp.path().join("gh-calls.log");
472 let bin = install_recording_gh(tmp.path(), &log);
473 let ctx = ToolContext::new(tmp.path());
474
475 let _env = crate::test_support::lock_test_env();
476 // SAFETY: serialized behind the process-wide test env lock.
477 unsafe {
478 std::env::set_var("CODEWHALE_GH_BIN", &bin);
479 }
480 let result = close_github_thread(
481 close_input_with_dry_run(json!(true)),
482 &ctx,
483 GithubCloseTarget::Issue,
484 );
485 // SAFETY: same lock; restores the process environment.
486 unsafe {
487 std::env::remove_var("CODEWHALE_GH_BIN");
488 }
489
490 let result = result.expect("a real bool dry_run stays a dry run");
491 assert!(result.success);
492 assert!(result.content.contains("Dry run"), "{}", result.content);
493 assert!(
494 std::fs::read_to_string(&log).unwrap_or_default().is_empty(),
495 "dry run must not invoke gh"
496 );
497 }
498 }
499
499 lines RUST