use crate::services::git;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
use super::zellij_events;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct WaitForCopilotReviewInput {
pub pr_number: u64,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_poll_interval")]
pub poll_interval_secs: u64,
}
fn default_timeout() -> u64 {
300 }
fn default_poll_interval() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CopilotReviewOutput {
pub status: String,
pub comments: Vec<CopilotComment>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CopilotComment {
pub path: String,
pub line: Option<u64>,
pub body: String,
pub diff_hunk: Option<String>,
}
fn get_repo_info() -> Result<(String, String)> {
let output = Command::new("gh")
.args(["repo", "view", "--json", "owner,name"])
.output()
.context("Failed to execute gh repo view")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to get repo info: {}", stderr.trim());
}
#[derive(Deserialize)]
struct RepoInfo {
owner: RepoOwner,
name: String,
}
#[derive(Deserialize)]
struct RepoOwner {
login: String,
}
let stdout = String::from_utf8_lossy(&output.stdout);
let info: RepoInfo = serde_json::from_str(&stdout).context("Failed to parse repo info JSON")?;
Ok((info.owner.login, info.name))
}
fn fetch_pr_comments(owner: &str, repo: &str, pr_number: u64) -> Result<Vec<CopilotComment>> {
let endpoint = format!("/repos/{}/{}/pulls/{}/comments", owner, repo, pr_number);
debug!("[CopilotReview] Fetching comments from: {}", endpoint);
let output = Command::new("gh")
.args(["api", &endpoint])
.output()
.context("Failed to execute gh api for PR comments")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to fetch PR comments: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
#[derive(Deserialize)]
struct PRComment {
path: String,
#[serde(default)]
line: Option<u64>,
body: String,
#[serde(default)]
diff_hunk: Option<String>,
user: CommentUser,
}
#[derive(Deserialize)]
struct CommentUser {
login: String,
#[serde(rename = "type")]
user_type: Option<String>,
}
let comments: Vec<PRComment> =
serde_json::from_str(&stdout).context("Failed to parse PR comments JSON")?;
let copilot_comments: Vec<CopilotComment> = comments
.into_iter()
.filter(|c| is_copilot_comment(&c.user.login, c.user.user_type.as_deref()))
.map(|c| CopilotComment {
path: c.path,
line: c.line,
body: c.body,
diff_hunk: c.diff_hunk,
})
.collect();
debug!(
"[CopilotReview] Found {} Copilot comments",
copilot_comments.len()
);
Ok(copilot_comments)
}
fn is_copilot_comment(login: &str, user_type: Option<&str>) -> bool {
let login_lower = login.to_lowercase();
if login_lower.contains("copilot") {
return true;
}
if user_type == Some("Bot") && login_lower.contains("copilot") {
return true;
}
if login_lower == "github-advanced-security[bot]" {
return true;
}
false
}
fn fetch_pr_reviews(owner: &str, repo: &str, pr_number: u64) -> Result<bool> {
let endpoint = format!("/repos/{}/{}/pulls/{}/reviews", owner, repo, pr_number);
debug!("[CopilotReview] Fetching reviews from: {}", endpoint);
let output = Command::new("gh")
.args(["api", &endpoint])
.output()
.context("Failed to execute gh api for PR reviews")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to fetch PR reviews: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
#[derive(Deserialize)]
struct PRReview {
user: ReviewUser,
}
#[derive(Deserialize)]
struct ReviewUser {
login: String,
#[serde(rename = "type")]
user_type: Option<String>,
}
let reviews: Vec<PRReview> =
serde_json::from_str(&stdout).context("Failed to parse PR reviews JSON")?;
let has_copilot_review = reviews
.iter()
.any(|r| is_copilot_comment(&r.user.login, r.user.user_type.as_deref()));
if has_copilot_review {
debug!("[CopilotReview] Found Copilot review");
}
Ok(has_copilot_review)
}
pub fn wait_for_copilot_review(input: &WaitForCopilotReviewInput) -> Result<CopilotReviewOutput> {
info!(
"[CopilotReview] Waiting for Copilot review on PR #{} (timeout: {}s, poll: {}s)",
input.pr_number, input.timeout_secs, input.poll_interval_secs
);
let (owner, repo) = get_repo_info()?;
info!("[CopilotReview] Repository: {}/{}", owner, repo);
let deadline = Instant::now() + Duration::from_secs(input.timeout_secs);
let poll_interval = Duration::from_secs(input.poll_interval_secs);
loop {
let comments = fetch_pr_comments(&owner, &repo, input.pr_number)?;
if !comments.is_empty() {
info!("[CopilotReview] Found {} Copilot comments", comments.len());
if let Ok(session) = std::env::var("ZELLIJ_SESSION_NAME") {
if let Ok(branch) = git::get_current_branch() {
if let Some(agent_id_str) = git::extract_agent_id(&branch) {
match crate::ui_protocol::AgentId::try_from(agent_id_str) {
Ok(agent_id) => {
let event = crate::ui_protocol::AgentEvent::CopilotReviewed {
agent_id,
comment_count: comments.len() as u32,
timestamp: zellij_events::now_iso8601(),
};
if let Err(e) = zellij_events::emit_event(&session, &event) {
warn!("Failed to emit copilot:reviewed event: {}", e);
}
}
Err(e) => {
warn!(
"Invalid agent_id in branch '{}', skipping event: {}",
branch, e
);
}
}
}
}
}
return Ok(CopilotReviewOutput {
status: "reviewed".to_string(),
comments,
});
}
if fetch_pr_reviews(&owner, &repo, input.pr_number)? {
info!("[CopilotReview] Found Copilot review (no inline comments)");
if let Ok(session) = std::env::var("ZELLIJ_SESSION_NAME") {
if let Ok(branch) = git::get_current_branch() {
if let Some(agent_id_str) = git::extract_agent_id(&branch) {
match crate::ui_protocol::AgentId::try_from(agent_id_str) {
Ok(agent_id) => {
let event = crate::ui_protocol::AgentEvent::CopilotReviewed {
agent_id,
comment_count: 0,
timestamp: zellij_events::now_iso8601(),
};
if let Err(e) = zellij_events::emit_event(&session, &event) {
warn!("Failed to emit copilot:reviewed event: {}", e);
}
}
Err(e) => {
warn!(
"Invalid agent_id in branch '{}', skipping event: {}",
branch, e
);
}
}
}
}
}
return Ok(CopilotReviewOutput {
status: "reviewed".to_string(),
comments: vec![],
});
}
if Instant::now() > deadline {
warn!("[CopilotReview] Timeout reached waiting for Copilot review");
return Ok(CopilotReviewOutput {
status: "timeout".to_string(),
comments: vec![],
});
}
debug!(
"[CopilotReview] No Copilot review yet, sleeping for {}s",
input.poll_interval_secs
);
thread::sleep(poll_interval);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_copilot_comment() {
assert!(is_copilot_comment("copilot", None));
assert!(is_copilot_comment("copilot[bot]", Some("Bot")));
assert!(is_copilot_comment("Copilot", None));
assert!(is_copilot_comment(
"github-advanced-security[bot]",
Some("Bot")
));
assert!(!is_copilot_comment("octocat", None));
assert!(!is_copilot_comment("github-actions[bot]", Some("Bot")));
}
#[test]
fn test_default_values() {
assert_eq!(default_timeout(), 300);
assert_eq!(default_poll_interval(), 30);
}
}