| 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 |