use agent_client_protocol as acp; use serde::{Deserialize, Serialize}; use super::{ExtResult, parse_params, to_ext_response}; use crate::agent::MvpAgent; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PrStatusRequest { pub cwd: String, pub branch: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PrStatusResponse { pub pr: Option, pub updated_session_ids: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PrData { pub url: String, pub state: String, pub is_in_merge_queue: bool, pub number: Option, pub title: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct GhPrViewResponse { state: Option, url: Option, is_draft: Option, number: Option, title: Option, } #[derive(Debug, Deserialize)] struct GhGraphqlResponse { data: Option, } #[derive(Debug, Deserialize)] struct GhGraphqlData { resource: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct GhGraphqlPullRequest { is_in_merge_queue: Option, } pub async fn handle(_agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { match args.method.as_ref() { "x.ai/pr/status" => { let req = parse_params::(args)?; to_ext_response(handle_pr_status(&req.cwd, &req.branch).await) } _ => Err(acp::Error::method_not_found()), } } async fn handle_pr_status(cwd: &str, branch: &str) -> anyhow::Result { Ok(PrStatusResponse { pr: gh_pr_view_by_branch(cwd, branch).await, updated_session_ids: Vec::new(), }) } async fn gh_pr_view_by_branch(cwd: &str, branch: &str) -> Option { let mut cmd = tokio::process::Command::new("gh"); cmd.args([ "pr", "view", branch, "--json", "state,url,isDraft,number,title", ]) .current_dir(cwd) .stdin(std::process::Stdio::null()); xai_grok_tools::util::detach_command(&mut cmd); cmd.envs(xai_grok_tools::util::pager_env()); let output = cmd.output().await.ok()?; if !output.status.success() { return None; } let parsed = serde_json::from_slice::(&output.stdout).ok()?; let url = parsed.url?; let state = match parsed .state .as_deref() .map(str::to_ascii_lowercase) .as_deref() { Some("merged") => "merged", Some("closed") => "closed", _ if parsed.is_draft.unwrap_or(false) => "draft", _ => "open", }; let is_in_merge_queue = state == "open" && gh_pr_is_in_merge_queue(cwd, &url).await; Some(PrData { url, state: state.to_string(), is_in_merge_queue, number: parsed.number, title: parsed.title, }) } /// `gh pr view --json` does not expose `isInMergeQueue`; query GraphQL via `gh api`. async fn gh_pr_is_in_merge_queue(cwd: &str, pr_url: &str) -> bool { const QUERY: &str = "query($url: URI!) { resource(url: $url) { ... on PullRequest { isInMergeQueue } } }"; let mut cmd = tokio::process::Command::new("gh"); cmd.args([ "api", "graphql", "-f", &format!("query={QUERY}"), "-f", &format!("url={pr_url}"), ]) .current_dir(cwd) .stdin(std::process::Stdio::null()); xai_grok_tools::util::detach_command(&mut cmd); cmd.envs(xai_grok_tools::util::pager_env()); cmd.env("NO_COLOR", "1"); let output = match cmd.output().await { Ok(output) => output, Err(_) => return false, }; if !output.status.success() { let stderr_snippet: String = String::from_utf8_lossy(&output.stderr) .chars() .take(200) .collect(); tracing::warn!( status = %output.status, stderr = %stderr_snippet, "gh api graphql isInMergeQueue lookup failed" ); return false; } parse_is_in_merge_queue(&output.stdout).unwrap_or(false) } fn parse_is_in_merge_queue(stdout: &[u8]) -> Option { let stripped = strip_ansi_csi(stdout); let parsed = match serde_json::from_slice::(&stripped) { Ok(parsed) => parsed, Err(error) => { tracing::warn!(error = %error, "failed to parse gh api graphql isInMergeQueue response"); return None; } }; parsed.data?.resource?.is_in_merge_queue } /// `gh` can colorize stdout even when piped (e.g. `GH_FORCE_TTY`, `--color always` /// in config), which would break serde parsing of the JSON payload. fn strip_ansi_csi(bytes: &[u8]) -> Vec { let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') { i += 2; while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) { i += 1; } i += 1; } else { out.push(bytes[i]); i += 1; } } out } #[cfg(test)] mod tests { use super::*; #[test] fn parse_is_in_merge_queue_true() { let stdout = br#"{"data":{"resource":{"isInMergeQueue":true}}}"#; assert_eq!(parse_is_in_merge_queue(stdout), Some(true)); } #[test] fn parse_is_in_merge_queue_false() { let stdout = br#"{"data":{"resource":{"isInMergeQueue":false}}}"#; assert_eq!(parse_is_in_merge_queue(stdout), Some(false)); } #[test] fn parse_is_in_merge_queue_missing_resource() { let stdout = br#"{"data":{"resource":null}}"#; assert_eq!(parse_is_in_merge_queue(stdout), None); } #[test] fn parse_is_in_merge_queue_missing_data() { assert_eq!(parse_is_in_merge_queue(b"{}"), None); } #[test] fn parse_is_in_merge_queue_malformed_json() { assert_eq!(parse_is_in_merge_queue(b"not json"), None); } #[test] fn parse_is_in_merge_queue_ansi_wrapped_json() { let stdout = b"\x1b[1;32m{\"data\":{\"resource\":{\"isInMergeQueue\":\x1b[0;36mtrue\x1b[0m}}}\x1b[0m"; assert_eq!(parse_is_in_merge_queue(stdout), Some(true)); } #[test] fn parse_is_in_merge_queue_ansi_wrapped_false() { let stdout = b"\x1b[38;5;208m{\"data\":{\"resource\":{\"isInMergeQueue\":false}}}\x1b[0m\n"; assert_eq!(parse_is_in_merge_queue(stdout), Some(false)); } }