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