返回 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 host;
23 pub(crate) mod report;
24 mod schema;
25 mod shape;
26
27 use actions::{GithubCloseTarget, close_github_thread};
28 use schema::{canonical_schema, legacy_action_schema};
29
30 // The suite at the bottom of this file builds JSON inputs and a recorder path
31 // that nothing in the production surface above names.
32 #[cfg(test)]
33 use serde_json::json;
34 // Unix-only like the recorder helper that returns it (`install_recording_gh`)
35 // — on Windows the test binary compiles without them, and an ungated import
36 // fails `-D warnings`.
37 #[cfg(all(test, unix))]
38 use std::path::PathBuf;
39
40 /// Actions the Plan-mode read-only surface exposes.
41 const READ_ACTIONS: &[&str] = &["issue_context", "pr_context", "report_read"];
42 const ALL_ACTIONS: &[&str] = &[
43 "issue_context",
44 "pr_context",
45 "comment",
46 "close_issue",
47 "close_pr",
48 "report_draft",
49 "report_read",
50 ];
51
52 /// Unified GitHub tool.
53 ///
54 /// One struct, one input schema per surface: the canonical `github` tool
55 /// (all actions, or the read-only subset via [`GithubTool::read_only`]) plus
56 /// hidden legacy aliases carrying a `forced_action`.
57 pub struct GithubTool {
58 name: &'static str,
59 forced_action: Option<&'static str>,
60 read_only: bool,
61 }
62
63 impl GithubTool {
64 pub const fn new(name: &'static str) -> Self {
65 Self {
66 name,
67 forced_action: None,
68 read_only: false,
69 }
70 }
71
72 /// Plan-mode variant: only the read-only actions are advertised and routed.
73 pub const fn read_only(name: &'static str) -> Self {
74 Self {
75 name,
76 forced_action: None,
77 read_only: true,
78 }
79 }
80
81 #[cfg(test)]
82 pub const fn alias(name: &'static str, action: &'static str) -> Self {
83 Self {
84 name,
85 forced_action: Some(action),
86 read_only: false,
87 }
88 }
89
90 fn allowed_actions(&self) -> &'static [&'static str] {
91 if self.read_only {
92 READ_ACTIONS
93 } else {
94 ALL_ACTIONS
95 }
96 }
97
98 fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> {
99 let action = match self.forced_action {
100 Some(action) => action,
101 None => input.get("action").and_then(Value::as_str).ok_or_else(|| {
102 ToolError::invalid_input(format!(
103 "github: missing `action` (one of: {})",
104 self.allowed_actions().join(", ")
105 ))
106 })?,
107 };
108 if self.allowed_actions().contains(&action) {
109 Ok(action)
110 } else {
111 Err(ToolError::invalid_input(format!(
112 "github: invalid action `{action}` (one of: {})",
113 self.allowed_actions().join(", ")
114 )))
115 }
116 }
117
118 fn action_is_read(action: &str) -> bool {
119 READ_ACTIONS.contains(&action)
120 }
121 }
122
123 #[async_trait]
124 impl ToolSpec for GithubTool {
125 fn name(&self) -> &'static str {
126 self.name
127 }
128
129 fn model_visible(&self) -> bool {
130 self.forced_action.is_none()
131 }
132
133 fn description(&self) -> &'static str {
134 match self.forced_action {
135 Some("issue_context") => {
136 "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."
137 }
138 Some("pr_context") => {
139 "Read GitHub PR context using gh: body/comments/reviews/check status/files and optional diff artifact. Read-only; no push/merge/close."
140 }
141 Some("comment") => {
142 "Post an evidence-backed GitHub issue/PR comment with gh. Requires approval. Use blocker comments for partial work; do not claim closure without evidence."
143 }
144 Some("close_issue") => {
145 "Close a GitHub issue only when structured acceptance evidence is present and approved. Rejected when the worktree is dirty unless allow_dirty=true. For pull requests use github_close_pr; do not call PRs issues in user-facing output. Never close merely because the agent is stopping."
146 }
147 Some("close_pr") => {
148 "Close a GitHub pull request only when structured acceptance evidence is present and approved. Rejected when the worktree is dirty unless allow_dirty=true. Use this for PRs instead of github_close_issue so the UI, audit trail, and comments keep PR wording clear."
149 }
150 _ if self.read_only => {
151 "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."
152 }
153 _ => {
154 "GitHub context (issue_context, pr_context) and guarded comment/close_issue/close_pr actions. Closes are rejected when the worktree is dirty unless allow_dirty=true. 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."
155 }
156 }
157 }
158
159 fn input_schema(&self) -> Value {
160 if let Some(action) = self.forced_action {
161 return legacy_action_schema(action);
162 }
163 canonical_schema(self.allowed_actions(), self.read_only)
164 }
165
166 fn capabilities(&self) -> Vec<ToolCapability> {
167 match self.forced_action {
168 Some(action) if Self::action_is_read(action) => {
169 vec![ToolCapability::ReadOnly, ToolCapability::Network]
170 }
171 Some(_) => vec![ToolCapability::Network, ToolCapability::RequiresApproval],
172 None if self.read_only => vec![ToolCapability::ReadOnly, ToolCapability::Network],
173 None => vec![ToolCapability::Network, ToolCapability::RequiresApproval],
174 }
175 }
176
177 fn approval_requirement(&self) -> ApprovalRequirement {
178 match self.forced_action {
179 Some(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
180 Some(_) => ApprovalRequirement::Required,
181 None if self.read_only => ApprovalRequirement::Auto,
182 None => ApprovalRequirement::Required,
183 }
184 }
185
186 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
187 match self.resolve_action(input) {
188 Ok("report_draft") => ApprovalRequirement::Auto,
189 Ok(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
190 _ => ApprovalRequirement::Required,
191 }
192 }
193
194 fn is_read_only_for(&self, input: &Value) -> bool {
195 match self.resolve_action(input) {
196 Ok(action) => Self::action_is_read(action),
197 Err(_) => self.is_read_only(),
198 }
199 }
200
201 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
202 let action = self.resolve_action(&input)?;
203 if context
204 .features
205 .enabled(crate::features::Feature::GithubHost)
206 {
207 let request = host::Request::capture(action, &input, context)?;
208 return crate::extension_host::manager()
209 .execute_github(request, context)
210 .await;
211 }
212 match action {
213 "report_draft" => report::draft(input, context),
214 "report_read" => report::read(input, context),
215 "issue_context" => self.execute_issue_context(&input, context).await,
216 "pr_context" => self.execute_pr_context(&input, context).await,
217 "comment" => self.execute_comment(&input, context).await,
218 "close_issue" => close_github_thread(input, context, GithubCloseTarget::Issue),
219 "close_pr" => close_github_thread(input, context, GithubCloseTarget::Pr),
220 action => Err(ToolError::invalid_input(format!(
221 "github: invalid action `{action}`"
222 ))),
223 }
224 }
225 }
226
227 fn validate_evidence(input: &Value, closing: bool) -> Result<(), ToolError> {
228 let evidence = input
229 .get("evidence")
230 .and_then(Value::as_object)
231 .ok_or_else(|| ToolError::invalid_input("evidence object is required"))?;
232 if closing {
233 let criteria = input
234 .get("acceptance_criteria")
235 .and_then(Value::as_array)
236 .filter(|items| !items.is_empty())
237 .ok_or_else(|| ToolError::invalid_input("acceptance_criteria must be non-empty"))?;
238 if criteria
239 .iter()
240 .any(|item| item.as_str().unwrap_or("").trim().is_empty())
241 {
242 return Err(ToolError::invalid_input(
243 "acceptance_criteria entries must be non-empty",
244 ));
245 }
246 for key in ["files_changed", "tests_run", "final_status"] {
247 if !evidence.contains_key(key) {
248 return Err(ToolError::invalid_input(format!(
249 "closure evidence missing {key}"
250 )));
251 }
252 }
253 }
254 Ok(())
255 }
256
257 #[cfg(test)]
258 mod tests {
259 use super::*;
260 use crate::tools::spec::ToolSpec;
261
262 #[test]
263 fn close_refuses_when_git_cannot_determine_worktree_status() {
264 use crate::dependencies::{ExternalTool, Git};
265
266 let tmp = tempfile::tempdir().expect("tempdir");
267 assert!(
268 Git::output(&["init", "-q"], tmp.path())
269 .unwrap()
270 .status
271 .success()
272 );
273 let context = ToolContext::new(tmp.path());
274 assert!(
275 super::cli::git_status_porcelain(&context)
276 .unwrap()
277 .is_empty()
278 );
279 std::fs::write(tmp.path().join("work.txt"), "uncommitted work").unwrap();
280 assert!(
281 !super::cli::git_status_porcelain(&context)
282 .unwrap()
283 .is_empty()
284 );
285 // A real Git failure can have empty stdout; that is not a clean tree.
286 std::fs::write(tmp.path().join(".git/index"), "invalid index").unwrap();
287 let input = json!({
288 "number": 424_242,
289 "dry_run": true,
290 "acceptance_criteria": ["done"],
291 "evidence": {
292 "files_changed": ["work.txt"],
293 "tests_run": ["local fixture"],
294 "final_status": "green"
295 }
296 });
297 let error = close_github_thread(input, &context, GithubCloseTarget::Issue)
298 .expect_err("unknown worktree state must not authorize closure")
299 .to_string();
300 assert!(error.contains("git status failed"), "{error}");
301 assert!(error.contains("cannot verify"), "{error}");
302 }
303
304 #[test]
305 fn close_schema_requires_structured_evidence() {
306 let schema = GithubTool::alias("github_close_issue", "close_issue").input_schema();
307 assert!(
308 schema["properties"]["evidence"]["required"]
309 .as_array()
310 .expect("required")
311 .contains(&json!("tests_run"))
312 );
313 }
314
315 #[test]
316 fn close_pr_schema_requires_structured_evidence() {
317 let schema = GithubTool::alias("github_close_pr", "close_pr").input_schema();
318 assert!(
319 schema["properties"]["evidence"]["required"]
320 .as_array()
321 .expect("required")
322 .contains(&json!("tests_run"))
323 );
324 }
325
326 #[test]
327 fn close_tools_distinguish_issue_and_pr_wording() {
328 assert_eq!(GithubCloseTarget::Issue.display(), "issue");
329 assert_eq!(GithubCloseTarget::Pr.display(), "PR");
330 assert!(
331 GithubTool::alias("github_close_issue", "close_issue")
332 .description()
333 .contains("github_close_pr")
334 );
335 assert!(
336 GithubTool::alias("github_close_pr", "close_pr")
337 .description()
338 .contains("pull request")
339 );
340 }
341
342 // The dirty-worktree refusal is easy to hit on a real close; the
343 // model-facing text must disclose it and the flag that overrides it.
344 #[test]
345 fn close_disclosure_names_the_dirty_worktree_refusal() {
346 for description in [
347 GithubTool::alias("github_close_issue", "close_issue").description(),
348 GithubTool::alias("github_close_pr", "close_pr").description(),
349 GithubTool::new("github").description(),
350 ] {
351 assert!(
352 description.contains("dirty") && description.contains("allow_dirty"),
353 "close descriptions must disclose the dirty-worktree refusal: {description}"
354 );
355 }
356 let schema = GithubTool::new("github").input_schema();
357 let allow_dirty = schema["properties"]["allow_dirty"]["description"]
358 .as_str()
359 .expect("allow_dirty must carry a description");
360 assert!(
361 allow_dirty.contains("rejected when the worktree is dirty"),
362 "allow_dirty must disclose the refusal it overrides: {allow_dirty}"
363 );
364 }
365
366 /// D03-03: only the schema's `issue`/`pr` select a thread kind. Any other
367 /// spelling is refused before `gh` runs instead of commenting on an issue.
368 #[tokio::test]
369 async fn comment_refuses_a_target_outside_the_schema_enum() {
370 let tmp = tempfile::tempdir().expect("tempdir");
371 let context = ToolContext::new(tmp.path());
372 let tool = GithubTool::new("github");
373 for target in ["PR", "pull_request", "pull"] {
374 let error = tool
375 .execute(
376 json!({
377 "action": "comment",
378 "target": target,
379 "number": 7,
380 "body": "evidence",
381 "evidence": {},
382 "dry_run": true
383 }),
384 &context,
385 )
386 .await
387 .expect_err("an unknown target must not resolve to an issue")
388 .to_string();
389 assert!(error.contains("target must be"), "{target}: {error}");
390 }
391 for target in ["issue", "pr"] {
392 let result = tool
393 .execute(
394 json!({
395 "action": "comment",
396 "target": target,
397 "number": 7,
398 "body": "evidence",
399 "evidence": {},
400 "dry_run": true
401 }),
402 &context,
403 )
404 .await
405 .expect("schema targets stay valid");
406 assert!(result.content.contains(&format!("{target} #7")));
407 }
408 }
409
410 #[test]
411 fn missing_close_evidence_refuses() {
412 let input = json!({
413 "number": 1,
414 "acceptance_criteria": ["done"],
415 "evidence": { "files_changed": [] }
416 });
417 let err = validate_evidence(&input, true).expect_err("should refuse");
418 assert!(err.to_string().contains("tests_run"));
419 }
420
421 #[test]
422 fn canonical_schema_lists_all_actions() {
423 let schema = GithubTool::new("github").input_schema();
424 let actions = schema["properties"]["action"]["enum"]
425 .as_array()
426 .expect("action enum");
427 for action in [
428 "issue_context",
429 "pr_context",
430 "comment",
431 "close_issue",
432 "close_pr",
433 ] {
434 assert!(
435 actions.iter().any(|value| value.as_str() == Some(action)),
436 "canonical schema must offer action {action}"
437 );
438 }
439 for field in [
440 "number",
441 "target",
442 "body",
443 "evidence",
444 "acceptance_criteria",
445 ] {
446 assert!(
447 schema["properties"][field].is_object(),
448 "canonical schema must carry union field {field}"
449 );
450 }
451 assert_eq!(schema["additionalProperties"], json!(false));
452 }
453
454 #[test]
455 fn read_only_variant_only_offers_read_actions() {
456 let tool = GithubTool::read_only("github");
457 let schema = tool.input_schema();
458 assert_eq!(
459 schema["properties"]["action"]["enum"],
460 json!(["issue_context", "pr_context", "report_read"])
461 );
462 assert!(!schema["properties"]["body"].is_object());
463 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
464 assert!(tool.is_read_only());
465 }
466
467 #[test]
468 fn aliases_hide_from_model_and_force_action() {
469 let comment = GithubTool::alias("github_comment", "comment");
470 assert!(!comment.model_visible());
471 assert_eq!(comment.name(), "github_comment");
472 assert_eq!(
473 comment.approval_requirement(),
474 ApprovalRequirement::Required
475 );
476 assert!(comment.capabilities().contains(&ToolCapability::Network));
477
478 let issue = GithubTool::alias("github_issue_context", "issue_context");
479 assert_eq!(issue.approval_requirement(), ApprovalRequirement::Auto);
480 assert!(issue.is_read_only_for(&json!({})));
481
482 let canonical = GithubTool::new("github");
483 assert!(canonical.model_visible());
484 assert_eq!(
485 canonical.approval_requirement_for(&json!({"action": "pr_context"})),
486 ApprovalRequirement::Auto
487 );
488 assert_eq!(
489 canonical.approval_requirement_for(&json!({"action": "close_pr"})),
490 ApprovalRequirement::Required
491 );
492 assert!(canonical.is_read_only_for(&json!({"action": "issue_context"})));
493 assert!(!canonical.is_read_only_for(&json!({"action": "comment"})));
494 }
495
496 #[test]
497 fn canonical_rejects_unknown_or_missing_action() {
498 let tool = GithubTool::new("github");
499 let err = tool
500 .resolve_action(&json!({}))
501 .expect_err("missing action must fail");
502 assert!(err.to_string().contains("missing `action`"));
503 let err = tool
504 .resolve_action(&json!({"action": "merge"}))
505 .expect_err("unknown action must fail");
506 assert!(err.to_string().contains("invalid action"));
507
508 let read_only = GithubTool::read_only("github");
509 let err = read_only
510 .resolve_action(&json!({"action": "close_pr"}))
511 .expect_err("read-only surface must reject write actions");
512 assert!(err.to_string().contains("invalid action"));
513 }
514
515 /// Install a `gh` stand-in that appends its argv to `log` and succeeds.
516 ///
517 /// The close path must never reach a real `gh`, so the recorder both
518 /// proves what was attempted and keeps the test from touching GitHub.
519 /// Unix-only like its consumers: the recorder is a `sh` script, and on
520 /// Windows the ungated helper is dead code that fails `-D warnings` —
521 /// this was the unexplained red `Test (windows-latest)` on #5135.
522 #[cfg(unix)]
523 fn install_recording_gh(dir: &std::path::Path, log: &std::path::Path) -> PathBuf {
524 let bin = dir.join("gh-recorder.sh");
525 std::fs::write(
526 &bin,
527 format!(
528 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexit 0\n",
529 log.display()
530 ),
531 )
532 .expect("write recorder");
533 #[cfg(unix)]
534 {
535 use std::os::unix::fs::PermissionsExt;
536 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
537 .expect("chmod recorder");
538 }
539 bin
540 }
541
542 #[cfg(unix)]
543 fn close_input_with_dry_run(dry_run: Value) -> Value {
544 json!({
545 "number": 424_242,
546 "allow_dirty": true,
547 "dry_run": dry_run,
548 "acceptance_criteria": ["done"],
549 "evidence": {
550 "files_changed": ["src/lib.rs"],
551 "tests_run": ["cargo test"],
552 "final_status": "green"
553 }
554 })
555 }
556
557 #[test]
558 #[cfg(unix)]
559 fn stringy_dry_run_never_closes_the_thread() {
560 let tmp = tempfile::tempdir().expect("tempdir");
561 let log = tmp.path().join("gh-calls.log");
562 let bin = install_recording_gh(tmp.path(), &log);
563 let ctx = ToolContext::new(tmp.path());
564
565 let _env = crate::test_support::lock_test_env();
566 // SAFETY: serialized behind the process-wide test env lock.
567 unsafe {
568 std::env::set_var("CODEWHALE_GH_BIN", &bin);
569 }
570 let result = close_github_thread(
571 close_input_with_dry_run(json!("true")),
572 &ctx,
573 GithubCloseTarget::Issue,
574 );
575 // SAFETY: same lock; restores the process environment.
576 unsafe {
577 std::env::remove_var("CODEWHALE_GH_BIN");
578 }
579
580 let invocations = std::fs::read_to_string(&log).unwrap_or_default();
581 assert!(
582 invocations.is_empty(),
583 "a stringy dry_run must not invoke gh at all; got: {invocations}"
584 );
585 let err = result.expect_err("dry_run must not be silently coerced to its default");
586 let err = err.to_string();
587 assert!(err.contains("dry_run"), "error must name the field: {err}");
588 assert!(
589 err.contains("boolean") && err.contains("string"),
590 "error must name expected and received types: {err}"
591 );
592 }
593
594 #[test]
595 #[cfg(unix)]
596 fn real_dry_run_bool_still_short_circuits() {
597 let tmp = tempfile::tempdir().expect("tempdir");
598 let log = tmp.path().join("gh-calls.log");
599 let bin = install_recording_gh(tmp.path(), &log);
600 let ctx = ToolContext::new(tmp.path());
601
602 let _env = crate::test_support::lock_test_env();
603 // SAFETY: serialized behind the process-wide test env lock.
604 unsafe {
605 std::env::set_var("CODEWHALE_GH_BIN", &bin);
606 }
607 let result = close_github_thread(
608 close_input_with_dry_run(json!(true)),
609 &ctx,
610 GithubCloseTarget::Issue,
611 );
612 // SAFETY: same lock; restores the process environment.
613 unsafe {
614 std::env::remove_var("CODEWHALE_GH_BIN");
615 }
616
617 let result = result.expect("a real bool dry_run stays a dry run");
618 assert!(result.success);
619 assert!(result.content.contains("Dry run"), "{}", result.content);
620 assert!(
621 std::fs::read_to_string(&log).unwrap_or_default().is_empty(),
622 "dry run must not invoke gh"
623 );
624 }
625 }
626
626 lines RUST