magi-code 0.63.4

Repository-aware CLI coding agent for terminal work
Documentation
use super::{
    args::FILE_READ_MAX_BYTES,
    process::{BoundedChildProcessLimits, run_bounded_child_process},
};
use crate::{agent::cancellation::AgentCancellation, output::redact_sensitive_text};
use serde_json::Value;
#[cfg(test)]
use std::{path::PathBuf, sync::Mutex, sync::MutexGuard};
use std::{
    process::{Command, Stdio},
    time::Duration,
};

const GH_TIMEOUT: Duration = Duration::from_secs(15);
const GH_POLL: Duration = Duration::from_millis(25);
const GH_STDOUT_MAX_BYTES: usize = 128 * 1024;
const GH_STDERR_MAX_BYTES: usize = 16 * 1024;

#[cfg(test)]
static TEST_GH_COMMAND: Mutex<Option<PathBuf>> = Mutex::new(None);
#[cfg(test)]
static TEST_GH_LOCK: Mutex<()> = Mutex::new(());

#[cfg(test)]
pub(crate) struct TestGhCommandGuard {
    _lock: MutexGuard<'static, ()>,
}

#[cfg(test)]
impl Drop for TestGhCommandGuard {
    fn drop(&mut self) {
        if let Ok(mut command) = TEST_GH_COMMAND.lock() {
            *command = None;
        }
    }
}

#[cfg(test)]
pub(crate) fn set_test_gh_command(path: PathBuf) -> TestGhCommandGuard {
    let lock = TEST_GH_LOCK.lock().unwrap();
    *TEST_GH_COMMAND.lock().unwrap() = Some(path);
    TestGhCommandGuard { _lock: lock }
}

#[cfg(test)]
fn gh_command_path() -> String {
    TEST_GH_COMMAND
        .lock()
        .ok()
        .and_then(|command| command.clone())
        .map(|path| path.to_string_lossy().into_owned())
        .unwrap_or_else(|| "gh".to_string())
}

#[cfg(not(test))]
fn gh_command_path() -> String {
    "gh".to_string()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GitHubResourceKind {
    Issue,
    Pr,
}

pub(crate) fn fetch_github_resource(
    kind: GitHubResourceKind,
    id: &str,
    cancellation: &AgentCancellation,
) -> anyhow::Result<String> {
    validate_numeric_id(id)?;
    let output = run_gh(kind, id, cancellation)?;
    let value: Value = serde_json::from_str(&output)
        .map_err(|error| anyhow::anyhow!("gh returned invalid JSON: {error}"))?;
    Ok(format_github_resource(kind, &value))
}

fn validate_numeric_id(id: &str) -> anyhow::Result<()> {
    if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_digit()) {
        anyhow::bail!("GitHub resource id must be numeric");
    }
    Ok(())
}

fn run_gh(
    kind: GitHubResourceKind,
    id: &str,
    cancellation: &AgentCancellation,
) -> anyhow::Result<String> {
    let mut command = Command::new(gh_command_path());
    match kind {
        GitHubResourceKind::Issue => {
            command.args([
                "issue",
                "view",
                id,
                "--json",
                "number,title,state,author,body,url,labels,comments,createdAt,updatedAt",
            ]);
        }
        GitHubResourceKind::Pr => {
            command.args([
                "pr",
                "view",
                id,
                "--json",
                "number,title,state,author,body,url,headRefName,baseRefName,isDraft,mergeable,reviewDecision,comments,reviews,createdAt,updatedAt",
            ]);
        }
    }
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        command.process_group(0);
    }
    let child = command
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|error| anyhow::anyhow!("failed to spawn gh: {error}"))?;
    let output = run_bounded_child_process(
        child,
        BoundedChildProcessLimits {
            stdout_max_bytes: GH_STDOUT_MAX_BYTES,
            stderr_max_bytes: GH_STDERR_MAX_BYTES,
            timeout: GH_TIMEOUT,
            poll_interval: GH_POLL,
        },
        cancellation,
    )?;
    if output.timed_out {
        anyhow::bail!("gh timed out after {}s", GH_TIMEOUT.as_secs());
    }
    if output.stdout_truncated || output.stderr_truncated {
        anyhow::bail!("gh output exceeded bounded read limit");
    }
    if let Some(warning) = output.cleanup_warning {
        anyhow::bail!("gh cleanup warning: {}", redact_sensitive_text(&warning));
    }
    if !output.status.is_some_and(|status| status.success()) {
        let stderr = redact_sensitive_text(output.stderr.trim());
        anyhow::bail!("gh exited unsuccessfully: {stderr}");
    }
    Ok(redact_sensitive_text(&output.stdout))
}

fn format_github_resource(kind: GitHubResourceKind, value: &Value) -> String {
    let heading = match kind {
        GitHubResourceKind::Issue => "Issue",
        GitHubResourceKind::Pr => "PR",
    };
    let number = value
        .get("number")
        .and_then(Value::as_u64)
        .unwrap_or_default();
    let title = value.get("title").and_then(Value::as_str).unwrap_or("");
    let state = value.get("state").and_then(Value::as_str).unwrap_or("");
    let url = value.get("url").and_then(Value::as_str).unwrap_or("");
    let body = value.get("body").and_then(Value::as_str).unwrap_or("");
    let mut output = format!("# {heading} #{number}: {title}\n\nState: {state}\nURL: {url}\n");
    if kind == GitHubResourceKind::Pr {
        if let (Some(head), Some(base)) = (
            value.get("headRefName").and_then(Value::as_str),
            value.get("baseRefName").and_then(Value::as_str),
        ) {
            output.push_str(&format!("Branch: {head} -> {base}\n"));
        }
        for field in ["isDraft", "mergeable", "reviewDecision"] {
            if let Some(value) = value.get(field) {
                output.push_str(&format!("{field}: {value}\n"));
            }
        }
    }
    output.push_str("\n## Body\n");
    output.push_str(body);
    append_named_array(&mut output, "Labels", value.get("labels"));
    append_named_array(&mut output, "Comments", value.get("comments"));
    if kind == GitHubResourceKind::Pr {
        append_named_array(&mut output, "Reviews", value.get("reviews"));
    }
    if output.len() > FILE_READ_MAX_BYTES as usize {
        output.truncate(FILE_READ_MAX_BYTES as usize);
        while !output.is_char_boundary(output.len()) {
            output.pop();
        }
        output.push_str("\n[truncated]");
    }
    output
}

fn append_named_array(output: &mut String, label: &str, value: Option<&Value>) {
    let Some(items) = value.and_then(Value::as_array) else {
        return;
    };
    if items.is_empty() {
        return;
    }
    output.push_str(&format!("\n\n## {label}\n"));
    for item in items {
        if let Some(name) = item
            .get("author")
            .and_then(|author| author.get("login"))
            .and_then(Value::as_str)
        {
            output.push_str(&format!("\n### {name}\n"));
        }
        if let Some(body) = item.get("body").and_then(Value::as_str) {
            output.push_str(body);
            output.push('\n');
        } else {
            output.push_str(&item.to_string());
            output.push('\n');
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{fs, path::Path};

    #[cfg(unix)]
    fn make_executable(path: &Path) {
        use std::os::unix::fs::PermissionsExt;
        let mut permissions = fs::metadata(path).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(path, permissions).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn github_resource_fetches_issue_with_mock_gh() {
        let temp = tempfile::TempDir::new().unwrap();
        let gh = temp.path().join("gh");
        fs::write(&gh, "#!/bin/sh\nprintf '%s' '{\"number\":239,\"title\":\"Unified read\",\"state\":\"OPEN\",\"url\":\"https://example.test/239\",\"body\":\"Issue body\",\"comments\":[{\"author\":{\"login\":\"ada\"},\"body\":\"comment\"}]}'\n").unwrap();
        make_executable(&gh);
        let _guard = set_test_gh_command(gh);
        let text = fetch_github_resource(
            GitHubResourceKind::Issue,
            "239",
            &AgentCancellation::default(),
        )
        .unwrap();

        assert!(text.contains("# Issue #239: Unified read"));
        assert!(text.contains("Issue body"));
        assert!(text.contains("comment"));
    }

    #[test]
    fn github_resource_rejects_non_numeric_ids() {
        assert!(
            fetch_github_resource(GitHubResourceKind::Pr, "abc", &AgentCancellation::default())
                .is_err()
        );
    }
}