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()
);
}
}