use async_trait::async_trait;
use serde_json::Value;
use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
mod actions;
mod cli;
mod schema;
mod shape;
use actions::{GithubCloseTarget, close_github_thread};
use schema::{canonical_schema, legacy_action_schema};
#[cfg(test)]
use serde_json::json;
#[cfg(all(test, unix))]
use std::path::PathBuf;
const READ_ACTIONS: &[&str] = &["issue_context", "pr_context"];
const ALL_ACTIONS: &[&str] = &[
"issue_context",
"pr_context",
"comment",
"close_issue",
"close_pr",
];
pub struct GithubTool {
name: &'static str,
forced_action: Option<&'static str>,
read_only: bool,
}
impl GithubTool {
pub const fn new(name: &'static str) -> Self {
Self {
name,
forced_action: None,
read_only: false,
}
}
pub const fn read_only(name: &'static str) -> Self {
Self {
name,
forced_action: None,
read_only: true,
}
}
#[cfg(test)]
pub const fn alias(name: &'static str, action: &'static str) -> Self {
Self {
name,
forced_action: Some(action),
read_only: false,
}
}
fn allowed_actions(&self) -> &'static [&'static str] {
if self.read_only {
READ_ACTIONS
} else {
ALL_ACTIONS
}
}
fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> {
let action = match self.forced_action {
Some(action) => action,
None => input.get("action").and_then(Value::as_str).ok_or_else(|| {
ToolError::invalid_input(format!(
"github: missing `action` (one of: {})",
self.allowed_actions().join(", ")
))
})?,
};
if self.allowed_actions().contains(&action) {
Ok(action)
} else {
Err(ToolError::invalid_input(format!(
"github: invalid action `{action}` (one of: {})",
self.allowed_actions().join(", ")
)))
}
}
fn action_is_read(action: &str) -> bool {
READ_ACTIONS.contains(&action)
}
}
#[async_trait]
impl ToolSpec for GithubTool {
fn name(&self) -> &'static str {
self.name
}
fn model_visible(&self) -> bool {
self.forced_action.is_none()
}
fn description(&self) -> &'static str {
match self.forced_action {
Some("issue_context") => {
"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."
}
Some("pr_context") => {
"Read GitHub PR context using gh: body/comments/reviews/check status/files and optional diff artifact. Read-only; no push/merge/close."
}
Some("comment") => {
"Post an evidence-backed GitHub issue/PR comment with gh. Requires approval. Use blocker comments for partial work; do not claim closure without evidence."
}
Some("close_issue") => {
"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."
}
Some("close_pr") => {
"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."
}
_ if self.read_only => {
"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."
}
_ => {
"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."
}
}
}
fn input_schema(&self) -> Value {
if let Some(action) = self.forced_action {
return legacy_action_schema(action);
}
canonical_schema(self.allowed_actions(), self.read_only)
}
fn capabilities(&self) -> Vec<ToolCapability> {
match self.forced_action {
Some(action) if Self::action_is_read(action) => {
vec![ToolCapability::ReadOnly, ToolCapability::Network]
}
Some(_) => vec![ToolCapability::Network, ToolCapability::RequiresApproval],
None if self.read_only => vec![ToolCapability::ReadOnly, ToolCapability::Network],
None => vec![ToolCapability::Network, ToolCapability::RequiresApproval],
}
}
fn approval_requirement(&self) -> ApprovalRequirement {
match self.forced_action {
Some(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
Some(_) => ApprovalRequirement::Required,
None if self.read_only => ApprovalRequirement::Auto,
None => ApprovalRequirement::Required,
}
}
fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
match self.resolve_action(input) {
Ok(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
_ => ApprovalRequirement::Required,
}
}
fn is_read_only_for(&self, input: &Value) -> bool {
match self.resolve_action(input) {
Ok(action) => Self::action_is_read(action),
Err(_) => self.is_read_only(),
}
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
match self.resolve_action(&input)? {
"issue_context" => self.execute_issue_context(&input, context).await,
"pr_context" => self.execute_pr_context(&input, context).await,
"comment" => self.execute_comment(&input, context).await,
"close_issue" => close_github_thread(input, context, GithubCloseTarget::Issue),
"close_pr" => close_github_thread(input, context, GithubCloseTarget::Pr),
action => Err(ToolError::invalid_input(format!(
"github: invalid action `{action}`"
))),
}
}
}
fn validate_evidence(input: &Value, closing: bool) -> Result<(), ToolError> {
let evidence = input
.get("evidence")
.and_then(Value::as_object)
.ok_or_else(|| ToolError::invalid_input("evidence object is required"))?;
if closing {
let criteria = input
.get("acceptance_criteria")
.and_then(Value::as_array)
.filter(|items| !items.is_empty())
.ok_or_else(|| ToolError::invalid_input("acceptance_criteria must be non-empty"))?;
if criteria
.iter()
.any(|item| item.as_str().unwrap_or("").trim().is_empty())
{
return Err(ToolError::invalid_input(
"acceptance_criteria entries must be non-empty",
));
}
for key in ["files_changed", "tests_run", "final_status"] {
if !evidence.contains_key(key) {
return Err(ToolError::invalid_input(format!(
"closure evidence missing {key}"
)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::spec::ToolSpec;
#[test]
fn close_schema_requires_structured_evidence() {
let schema = GithubTool::alias("github_close_issue", "close_issue").input_schema();
assert!(
schema["properties"]["evidence"]["required"]
.as_array()
.expect("required")
.contains(&json!("tests_run"))
);
}
#[test]
fn close_pr_schema_requires_structured_evidence() {
let schema = GithubTool::alias("github_close_pr", "close_pr").input_schema();
assert!(
schema["properties"]["evidence"]["required"]
.as_array()
.expect("required")
.contains(&json!("tests_run"))
);
}
#[test]
fn close_tools_distinguish_issue_and_pr_wording() {
assert_eq!(GithubCloseTarget::Issue.display(), "issue");
assert_eq!(GithubCloseTarget::Pr.display(), "PR");
assert!(
GithubTool::alias("github_close_issue", "close_issue")
.description()
.contains("github_close_pr")
);
assert!(
GithubTool::alias("github_close_pr", "close_pr")
.description()
.contains("pull request")
);
}
#[test]
fn missing_close_evidence_refuses() {
let input = json!({
"number": 1,
"acceptance_criteria": ["done"],
"evidence": { "files_changed": [] }
});
let err = validate_evidence(&input, true).expect_err("should refuse");
assert!(err.to_string().contains("tests_run"));
}
#[test]
fn canonical_schema_lists_all_actions() {
let schema = GithubTool::new("github").input_schema();
let actions = schema["properties"]["action"]["enum"]
.as_array()
.expect("action enum");
for action in [
"issue_context",
"pr_context",
"comment",
"close_issue",
"close_pr",
] {
assert!(
actions.iter().any(|value| value.as_str() == Some(action)),
"canonical schema must offer action {action}"
);
}
for field in [
"number",
"target",
"body",
"evidence",
"acceptance_criteria",
] {
assert!(
schema["properties"][field].is_object(),
"canonical schema must carry union field {field}"
);
}
assert_eq!(schema["additionalProperties"], json!(false));
}
#[test]
fn read_only_variant_only_offers_read_actions() {
let tool = GithubTool::read_only("github");
let schema = tool.input_schema();
assert_eq!(
schema["properties"]["action"]["enum"],
json!(["issue_context", "pr_context"])
);
assert!(!schema["properties"]["body"].is_object());
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
assert!(tool.is_read_only());
}
#[test]
fn aliases_hide_from_model_and_force_action() {
let comment = GithubTool::alias("github_comment", "comment");
assert!(!comment.model_visible());
assert_eq!(comment.name(), "github_comment");
assert_eq!(
comment.approval_requirement(),
ApprovalRequirement::Required
);
assert!(comment.capabilities().contains(&ToolCapability::Network));
let issue = GithubTool::alias("github_issue_context", "issue_context");
assert_eq!(issue.approval_requirement(), ApprovalRequirement::Auto);
assert!(issue.is_read_only_for(&json!({})));
let canonical = GithubTool::new("github");
assert!(canonical.model_visible());
assert_eq!(
canonical.approval_requirement_for(&json!({"action": "pr_context"})),
ApprovalRequirement::Auto
);
assert_eq!(
canonical.approval_requirement_for(&json!({"action": "close_pr"})),
ApprovalRequirement::Required
);
assert!(canonical.is_read_only_for(&json!({"action": "issue_context"})));
assert!(!canonical.is_read_only_for(&json!({"action": "comment"})));
}
#[test]
fn canonical_rejects_unknown_or_missing_action() {
let tool = GithubTool::new("github");
let err = tool
.resolve_action(&json!({}))
.expect_err("missing action must fail");
assert!(err.to_string().contains("missing `action`"));
let err = tool
.resolve_action(&json!({"action": "merge"}))
.expect_err("unknown action must fail");
assert!(err.to_string().contains("invalid action"));
let read_only = GithubTool::read_only("github");
let err = read_only
.resolve_action(&json!({"action": "close_pr"}))
.expect_err("read-only surface must reject write actions");
assert!(err.to_string().contains("invalid action"));
}
#[cfg(unix)]
fn install_recording_gh(dir: &std::path::Path, log: &std::path::Path) -> PathBuf {
let bin = dir.join("gh-recorder.sh");
std::fs::write(
&bin,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexit 0\n",
log.display()
),
)
.expect("write recorder");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
.expect("chmod recorder");
}
bin
}
#[cfg(unix)]
fn close_input_with_dry_run(dry_run: Value) -> Value {
json!({
"number": 424_242,
"allow_dirty": true,
"dry_run": dry_run,
"acceptance_criteria": ["done"],
"evidence": {
"files_changed": ["src/lib.rs"],
"tests_run": ["cargo test"],
"final_status": "green"
}
})
}
#[test]
#[cfg(unix)]
fn stringy_dry_run_never_closes_the_thread() {
let tmp = tempfile::tempdir().expect("tempdir");
let log = tmp.path().join("gh-calls.log");
let bin = install_recording_gh(tmp.path(), &log);
let ctx = ToolContext::new(tmp.path());
let _env = crate::test_support::lock_test_env();
unsafe {
std::env::set_var("CODEWHALE_GH_BIN", &bin);
}
let result = close_github_thread(
close_input_with_dry_run(json!("true")),
&ctx,
GithubCloseTarget::Issue,
);
unsafe {
std::env::remove_var("CODEWHALE_GH_BIN");
}
let invocations = std::fs::read_to_string(&log).unwrap_or_default();
assert!(
invocations.is_empty(),
"a stringy dry_run must not invoke gh at all; got: {invocations}"
);
let err = result.expect_err("dry_run must not be silently coerced to its default");
let err = err.to_string();
assert!(err.contains("dry_run"), "error must name the field: {err}");
assert!(
err.contains("boolean") && err.contains("string"),
"error must name expected and received types: {err}"
);
}
#[test]
#[cfg(unix)]
fn real_dry_run_bool_still_short_circuits() {
let tmp = tempfile::tempdir().expect("tempdir");
let log = tmp.path().join("gh-calls.log");
let bin = install_recording_gh(tmp.path(), &log);
let ctx = ToolContext::new(tmp.path());
let _env = crate::test_support::lock_test_env();
unsafe {
std::env::set_var("CODEWHALE_GH_BIN", &bin);
}
let result = close_github_thread(
close_input_with_dry_run(json!(true)),
&ctx,
GithubCloseTarget::Issue,
);
unsafe {
std::env::remove_var("CODEWHALE_GH_BIN");
}
let result = result.expect("a real bool dry_run stays a dry run");
assert!(result.success);
assert!(result.content.contains("Dry run"), "{}", result.content);
assert!(
std::fs::read_to_string(&log).unwrap_or_default().is_empty(),
"dry run must not invoke gh"
);
}
}