agentic-outer-dag-bin 0.1.1

External outer-DAG driver for worktree→PR→CodeRabbit loops
use anyhow::Result;
use pr_comments::PrComments;
use pr_comments::models::CheckSuiteSummary;
use pr_comments::models::IssueCommentSummary;
use pr_comments::models::PullRequestReviewSummary;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodeRabbitPoll {
    Waiting,
    Completed,
    Skipped { reason: String },
}

pub struct CodeRabbitClient {
    pr_comments: PrComments,
}

impl CodeRabbitClient {
    pub fn new() -> Result<Self> {
        Ok(Self {
            pr_comments: PrComments::new()?,
        })
    }

    pub async fn poll_once(&self, pr_number: u64, head_sha: &str) -> Result<CodeRabbitPoll> {
        let suites = self.pr_comments.list_check_suites_for_ref(head_sha).await?;
        let suites_outcome = interpret_check_suites(&suites);
        if matches!(suites_outcome, Some(CodeRabbitPoll::Completed)) {
            return Ok(CodeRabbitPoll::Completed);
        }

        let reviews = self
            .pr_comments
            .list_pull_request_reviews(pr_number)
            .await?;
        if has_coderabbit_review_for_head(&reviews, head_sha) {
            return Ok(CodeRabbitPoll::Completed);
        }

        let comments = self.pr_comments.list_issue_comments(pr_number).await?;
        if let Some(reason) = interpret_issue_comments_for_skip(&comments) {
            return Ok(CodeRabbitPoll::Skipped { reason });
        }

        Ok(suites_outcome.unwrap_or(CodeRabbitPoll::Waiting))
    }
}

fn interpret_check_suites(suites: &[CheckSuiteSummary]) -> Option<CodeRabbitPoll> {
    let coderabbit: Vec<_> = suites
        .iter()
        .filter(|suite| suite.app_slug.as_deref() == Some("coderabbitai"))
        .collect();
    if coderabbit.is_empty() {
        return None;
    }
    if coderabbit.iter().any(|suite| suite.status == "completed") {
        Some(CodeRabbitPoll::Completed)
    } else {
        Some(CodeRabbitPoll::Waiting)
    }
}

fn has_coderabbit_review_for_head(reviews: &[PullRequestReviewSummary], head_sha: &str) -> bool {
    reviews.iter().any(|review| {
        is_coderabbit_login(&review.user_login) && review.commit_id.as_deref() == Some(head_sha)
    })
}

fn is_coderabbit_login(login: &str) -> bool {
    login.to_ascii_lowercase().contains("coderabbit")
}

fn interpret_issue_comments_for_skip(comments: &[IssueCommentSummary]) -> Option<String> {
    for comment in comments {
        let body = comment.body.to_ascii_lowercase();
        let is_coderabbit = is_coderabbit_login(&comment.user_login);
        if is_coderabbit && body.contains("review skipped") {
            return Some(comment.body.clone());
        }
    }

    None
}

pub fn skip_reason_indicates_draft(reason: &str) -> bool {
    let reason = reason.to_ascii_lowercase();
    reason.contains("draft detected")
        || reason.contains("draft pull request")
        || reason.contains("pr is draft")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn issue_comment(user_login: &str, body: &str) -> IssueCommentSummary {
        IssueCommentSummary {
            id: 1,
            user_login: user_login.to_string(),
            user_type: Some("Bot".to_string()),
            body: body.to_string(),
            created_at: "2026-01-01T00:00:00Z".to_string(),
        }
    }

    fn review(user_login: &str, commit_id: Option<&str>) -> PullRequestReviewSummary {
        PullRequestReviewSummary {
            id: 1,
            user_login: user_login.to_string(),
            user_type: Some("Bot".to_string()),
            state: "COMMENTED".to_string(),
            submitted_at: Some("2026-01-01T00:00:00Z".to_string()),
            commit_id: commit_id.map(str::to_string),
        }
    }

    fn check_suite(
        status: &str,
        conclusion: Option<&str>,
        app_slug: Option<&str>,
    ) -> CheckSuiteSummary {
        CheckSuiteSummary {
            id: 1,
            status: status.to_string(),
            conclusion: conclusion.map(str::to_string),
            app_slug: app_slug.map(str::to_string),
            updated_at: "2026-01-01T00:00:00Z".to_string(),
        }
    }

    #[test]
    fn matches_coderabbit_logins_case_insensitively() {
        for login in [
            "coderabbitai",
            "coderabbitai[bot]",
            "CodeRabbitAI",
            "my-coderabbit-helper",
        ] {
            assert!(is_coderabbit_login(login), "expected match for {login}");
        }
    }

    #[test]
    fn rejects_unrelated_bot_logins() {
        for login in ["dependabot[bot]", "renovate[bot]"] {
            assert!(!is_coderabbit_login(login), "unexpected match for {login}");
        }
    }

    #[test]
    fn interpret_check_suites_returns_none_without_coderabbit_suite() {
        assert_eq!(
            interpret_check_suites(&[check_suite("queued", None, Some("github-actions"))]),
            None
        );
    }

    #[test]
    fn interpret_check_suites_treats_queued_and_in_progress_as_waiting() {
        for status in ["queued", "in_progress"] {
            assert_eq!(
                interpret_check_suites(&[check_suite(status, None, Some("coderabbitai"))]),
                Some(CodeRabbitPoll::Waiting)
            );
        }
    }

    #[test]
    fn interpret_check_suites_treats_completed_success_as_completed() {
        assert_eq!(
            interpret_check_suites(&[check_suite(
                "completed",
                Some("success"),
                Some("coderabbitai")
            )]),
            Some(CodeRabbitPoll::Completed)
        );
    }

    #[test]
    fn interpret_check_suites_keeps_completed_non_success_as_completed_for_current_policy() {
        assert_eq!(
            interpret_check_suites(&[check_suite(
                "completed",
                Some("failure"),
                Some("coderabbitai")
            )]),
            Some(CodeRabbitPoll::Completed)
        );
    }

    #[test]
    fn coderabbit_review_completion_requires_matching_head_sha() {
        assert!(has_coderabbit_review_for_head(
            &[review("coderabbitai[bot]", Some("abc123"))],
            "abc123"
        ));
        assert!(!has_coderabbit_review_for_head(
            &[review("coderabbitai[bot]", Some("deadbeef"))],
            "abc123"
        ));
        assert!(!has_coderabbit_review_for_head(
            &[review("dependabot[bot]", Some("abc123"))],
            "abc123"
        ));
        assert!(!has_coderabbit_review_for_head(
            &[review("coderabbitai[bot]", None)],
            "abc123"
        ));
    }

    #[test]
    fn detects_skipped_comment() {
        let result = interpret_issue_comments_for_skip(&[issue_comment(
            "coderabbitai[bot]",
            "Review skipped because no actionable changes were found",
        )]);
        assert_eq!(
            result.as_deref(),
            Some("Review skipped because no actionable changes were found")
        );
    }

    #[test]
    fn unrelated_bot_comment_does_not_complete_poll() {
        let result = interpret_issue_comments_for_skip(&[issue_comment(
            "dependabot[bot]",
            "Review skipped because no actionable changes were found",
        )]);
        assert!(result.is_none());
    }

    #[test]
    fn coderabbit_issue_comment_does_not_complete_without_skip_phrase() {
        let result =
            interpret_issue_comments_for_skip(&[issue_comment("CodeRabbitAI", "Looks good to me")]);
        assert!(result.is_none());
    }

    #[test]
    fn detects_draft_skip_reason_variants() {
        assert!(skip_reason_indicates_draft(
            "Review skipped. Draft detected."
        ));
        assert!(skip_reason_indicates_draft(
            "review skipped because PR is draft"
        ));
        assert!(!skip_reason_indicates_draft(
            "Review skipped because no actionable changes were found"
        ));
    }
}