travelagent-forge-github 1.10.2

GitHub forge backend for travelagent
Documentation
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

use travelagent_core::forge::*;
use travelagent_core::model::FileStatus;
use travelagent_forge_github::GitHubForge;

fn load_fixture(name: &str) -> String {
    std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap()
}

fn ripgrep_pr_id() -> PrId {
    PrId {
        owner: "BurntSushi".into(),
        repo: "ripgrep".into(),
        number: 2900,
    }
}

fn ruff_pr_id() -> PrId {
    PrId {
        owner: "astral-sh".into(),
        repo: "ruff".into(),
        number: 16000,
    }
}

async fn setup() -> (MockServer, GitHubForge) {
    let server = MockServer::start().await;
    // Wiremock listens on http://127.0.0.1:PORT which the production
    // `with_token` rejects (non-https / loopback). Use the explicit
    // insecure builder for tests.
    let forge = GitHubForge::with_token_insecure(&server.uri(), "test-token".into()).unwrap();
    (server, forge)
}

#[tokio::test]
async fn ripgrep_pr_metadata_parsed_correctly() {
    let (server, forge) = setup().await;
    let body: serde_json::Value =
        serde_json::from_str(&load_fixture("ripgrep_2900_pr.json")).unwrap();

    Mock::given(method("GET"))
        .and(path("/repos/BurntSushi/ripgrep/pulls/2900"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&body))
        .mount(&server)
        .await;

    let pr = forge.get_pr(&ripgrep_pr_id()).await.unwrap();
    assert_eq!(pr.title, "globset: add matches_all method");
    assert_eq!(pr.author, "tmccombs");
    assert_eq!(pr.state, PrState::Closed);
    assert_eq!(pr.base_branch, "master");
    assert_eq!(pr.head_branch, "matches-all");
    assert!(!pr.is_draft);
}

#[tokio::test]
async fn ripgrep_commits_parsed_correctly() {
    let (server, forge) = setup().await;
    let body: serde_json::Value =
        serde_json::from_str(&load_fixture("ripgrep_2900_commits.json")).unwrap();

    Mock::given(method("GET"))
        .and(path("/repos/BurntSushi/ripgrep/pulls/2900/commits"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&body))
        .mount(&server)
        .await;

    let commits = forge.get_pr_commits(&ripgrep_pr_id()).await.unwrap();
    assert_eq!(commits.len(), 2);
    assert_eq!(commits[0].id, "3c17c22ef64e78064d8c621b118d7cdb3652fa76");
    assert_eq!(commits[0].short_id, "3c17c22");
    assert_eq!(commits[0].summary, "globset: add matches_all method");
    assert_eq!(commits[0].author, "tmccombs");
    assert_eq!(commits[1].summary, "fixup! globset: add matches_all method");
}

#[tokio::test]
async fn ripgrep_files_with_patches_parsed_correctly() {
    let (server, forge) = setup().await;
    let body: serde_json::Value =
        serde_json::from_str(&load_fixture("ripgrep_2900_files.json")).unwrap();

    Mock::given(method("GET"))
        .and(path("/repos/BurntSushi/ripgrep/pulls/2900/files"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&body))
        .mount(&server)
        .await;

    let files = forge.get_pr_files(&ripgrep_pr_id()).await.unwrap();
    assert_eq!(files.len(), 1);
    assert_eq!(files[0].status, FileStatus::Modified);
    assert_eq!(
        files[0].new_path,
        Some(std::path::PathBuf::from("crates/globset/src/lib.rs"))
    );
    // The patch has 4 hunks (4 @@ sections in the fixture)
    assert!(
        !files[0].hunks.is_empty(),
        "Expected hunks to be parsed from the patch"
    );
    // Verify line numbers from the first hunk (@@ -351,6 +351,43 @@)
    let first_hunk = &files[0].hunks[0];
    assert_eq!(first_hunk.old_start, 351);
    assert_eq!(first_hunk.new_start, 351);
}

#[tokio::test]
async fn ripgrep_review_comments_parsed_correctly() {
    let (server, forge) = setup().await;
    let comments_body: serde_json::Value =
        serde_json::from_str(&load_fixture("ripgrep_2900_comments.json")).unwrap();

    Mock::given(method("GET"))
        .and(path("/repos/BurntSushi/ripgrep/pulls/2900/comments"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&comments_body))
        .mount(&server)
        .await;

    // Also mock empty issue comments
    Mock::given(method("GET"))
        .and(path("/repos/BurntSushi/ripgrep/issues/2900/comments"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
        .mount(&server)
        .await;

    let comments = forge.get_comments(&ripgrep_pr_id()).await.unwrap();
    assert_eq!(comments.len(), 3);

    // First comment by BurntSushi
    assert_eq!(comments[0].author, "BurntSushi");
    assert_eq!(comments[0].path, Some("crates/globset/src/lib.rs".into()));
    assert_eq!(comments[0].in_reply_to, None);

    // Second comment also by BurntSushi, different thread
    assert_eq!(comments[1].author, "BurntSushi");
    assert_eq!(comments[1].in_reply_to, None);

    // Third comment is a reply to the second (in_reply_to_id = 1766859217)
    assert_eq!(comments[2].author, "BurntSushi");
    assert_eq!(comments[2].in_reply_to, Some(1766859217));
}

#[tokio::test]
async fn ruff_medium_pr_13_files_parsed_correctly() {
    let (server, forge) = setup().await;
    let body: serde_json::Value =
        serde_json::from_str(&load_fixture("ruff_16000_files.json")).unwrap();

    Mock::given(method("GET"))
        .and(path("/repos/astral-sh/ruff/pulls/16000/files"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&body))
        .mount(&server)
        .await;

    let files = forge.get_pr_files(&ruff_pr_id()).await.unwrap();
    assert_eq!(files.len(), 13);

    // Check statuses: 12 modified + 1 added
    let added_count = files
        .iter()
        .filter(|f| f.status == FileStatus::Added)
        .count();
    let modified_count = files
        .iter()
        .filter(|f| f.status == FileStatus::Modified)
        .count();
    assert_eq!(added_count, 1);
    assert_eq!(modified_count, 12);

    // Verify the added file
    let added = files
        .iter()
        .find(|f| f.status == FileStatus::Added)
        .unwrap();
    assert_eq!(
        added.new_path,
        Some(std::path::PathBuf::from(
            "crates/red_knot_project/src/metadata/settings.rs"
        ))
    );
    assert!(added.old_path.is_none());

    // Verify patches are parsed into hunks
    for file in &files {
        assert!(
            !file.hunks.is_empty(),
            "File {:?} should have hunks parsed from patch",
            file.new_path
        );
    }
}

#[tokio::test]
async fn ruff_3_review_comments_with_replies() {
    let (server, forge) = setup().await;
    let comments_body: serde_json::Value =
        serde_json::from_str(&load_fixture("ruff_16000_comments.json")).unwrap();

    Mock::given(method("GET"))
        .and(path("/repos/astral-sh/ruff/pulls/16000/comments"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&comments_body))
        .mount(&server)
        .await;

    Mock::given(method("GET"))
        .and(path("/repos/astral-sh/ruff/issues/16000/comments"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
        .mount(&server)
        .await;

    let comments = forge.get_comments(&ruff_pr_id()).await.unwrap();
    assert_eq!(comments.len(), 3);

    // First comment starts a thread (no in_reply_to)
    assert_eq!(comments[0].author, "dhruvmanila");
    assert_eq!(comments[0].id, 1946208119);
    assert_eq!(comments[0].in_reply_to, None);
    assert_eq!(
        comments[0].path,
        Some("crates/red_knot_project/src/metadata/settings.rs".into())
    );

    // Second comment replies to the first
    assert_eq!(comments[1].author, "MichaReiser");
    assert_eq!(comments[1].in_reply_to, Some(1946208119));

    // Third comment also replies to the first
    assert_eq!(comments[2].author, "dhruvmanila");
    assert_eq!(comments[2].in_reply_to, Some(1946208119));
}