travelagent 1.11.1

Agent-first TUI code review tool
use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Mutex};

use tokio::runtime::Handle;

use travelagent_core::cache::{CACHE_VERSION, PrCache, read_cache, write_cache};
use travelagent_core::config::ConfigLoadOutcome;
use travelagent_core::error::TrvError;
use travelagent_core::forge::{ForgeBackend, ForgeType, ForgeWarnHandler, PrId};

use crate::app::App;
use crate::cli::Cli;
use crate::forge_detect::{self, ForgeTarget};
use crate::theme::Theme;

/// Run the TUI in remote PR review mode.
///
/// Fetches PR metadata and diff files from the appropriate forge, then
/// creates an `App` in remote mode and hands off to the normal event loop
/// in `main.rs` via the returned `App`.
pub fn create_remote_app(
    cli_args: &Cli,
    pr_arg: &str,
    theme: Theme,
    config_outcome: &ConfigLoadOutcome,
    runtime_handle: Handle,
) -> anyhow::Result<App> {
    // Borrow the process-wide runtime handle for the sync/async bridge.
    // `block_on` re-enters this runtime rather than spinning one up here.
    let rt = &runtime_handle;

    // Extract forge_hosts config for host detection
    let forge_hosts = config_outcome
        .config
        .as_ref()
        .and_then(|cfg| cfg.forge_hosts.as_ref());

    // Parse PR target
    let target = forge_detect::parse_pr_arg(pr_arg, forge_hosts)?;

    // Shared warning queue the forge client writes into via its
    // `warn_handler` callback. The App drains this each tick and pushes
    // into its status bar + error-log ring, so pagination truncation
    // surfaces to the user instead of garbling stdout/stderr under the
    // TUI's alternate screen.
    let warn_queue: Arc<Mutex<VecDeque<String>>> = Arc::new(Mutex::new(VecDeque::new()));
    let warn_handler = make_warn_handler(Arc::clone(&warn_queue));

    // Resolve forge and PR ID
    let (forge, pr_id, forge_host) = match target {
        ForgeTarget::ByNumber(number) => {
            let (forge_type, owner, repo, custom_host) =
                forge_detect::detect_forge_from_remote(forge_hosts)?;
            let pr_id = PrId {
                owner,
                repo,
                number,
            };
            let forge = create_forge(forge_type, custom_host.as_deref(), warn_handler.clone())?;
            (forge, pr_id, custom_host)
        }
        ForgeTarget::ByUrl {
            forge_type,
            pr_id,
            host,
        } => {
            let forge = create_forge(forge_type, host.as_deref(), warn_handler.clone())?;
            (forge, pr_id, host)
        }
    };

    // Determine cache host scope
    let cache_host = forge_host.as_deref().unwrap_or(match forge.forge_type() {
        travelagent_core::forge::ForgeType::GitHub => "github.com",
        travelagent_core::forge::ForgeType::GitLab => "gitlab.com",
    });

    // Fetch PR metadata first (single lightweight API call) for cache key
    let metadata = rt.block_on(forge.get_pr(&pr_id))?;
    let cache_key = &metadata.head_sha;

    // Check cache for diff files and commits (expensive to fetch).
    // Comments are always refreshed since they can change without new commits.
    let (diff_files, commits) = if let Some(cached) = read_cache(&pr_id, cache_host, cache_key) {
        (cached.diff_files, cached.commits)
    } else {
        let (files, commits_data) = rt.block_on(async {
            let files = forge.get_pr_files(&pr_id).await?;
            let commits = forge.get_pr_commits(&pr_id).await?;
            Ok::<_, TrvError>((files, commits))
        })?;

        // Write cache for next time
        if let Err(e) = write_cache(
            &pr_id,
            cache_host,
            &PrCache {
                version: CACHE_VERSION,
                head_sha: cache_key.clone(),
                metadata: metadata.clone(),
                diff_files: files.clone(),
                commits: commits_data.clone(),
                comments: vec![], // not cached — always fresh
            },
        ) {
            eprintln!("Warning: failed to write cache: {e}");
        }

        (files, commits_data)
    };

    // Always fetch fresh comments (can change without new commits)
    let comments = rt.block_on(forge.get_comments(&pr_id)).unwrap_or_default();
    let review_threads = rt
        .block_on(forge.get_review_threads(&pr_id))
        .unwrap_or_default();

    // Print PR header to stderr (visible before TUI takes over)
    let state_display = metadata.state.display();
    let draft_marker = if metadata.is_draft { " (draft)" } else { "" };
    eprintln!(
        "PR #{}: {} [{state_display}{draft_marker}]",
        pr_id.number, metadata.title
    );
    eprintln!("  {} -> {}", metadata.head_branch, metadata.base_branch);
    eprintln!("  {} files changed", diff_files.len());
    // Flush stderr so the summary is visible before TUI setup
    let _ = io::stderr().flush();

    // Create App with remote diff files. `new_remote` wires `forge` +
    // `pr_id` into `RemoteSessionState` so the app is in remote mode
    // before we populate the rest of the PR data below.
    let pr_number = pr_id.number;
    let pr_owner = pr_id.owner.clone();
    let pr_repo = pr_id.repo.clone();
    let mut app = App::new_remote(
        theme,
        config_outcome
            .config
            .as_ref()
            .and_then(|cfg| cfg.comment_types.clone()),
        cli_args.output_to_stdout,
        diff_files,
        metadata.title.clone(),
        pr_number,
        &pr_owner,
        &pr_repo,
        runtime_handle,
        Some(forge),
        pr_id,
    )?;

    {
        let r = app
            .remote_mut()
            .expect("new_remote produces remote-mode App");
        r.pr_metadata = Some(metadata);
        r.pr_commits = commits;
        r.remote_comments = comments;
        r.review_threads = review_threads;
        r.forge_host = forge_host;
    }

    // Hand the warn queue to the App so its per-tick drain can pick up
    // anything the forge client enqueued during the initial fetches.
    app.attach_forge_warn_queue(warn_queue);

    Ok(app)
}

/// Build a `ForgeWarnHandler` that appends into `queue`. The callback is
/// cheap and lock-free from the caller's perspective — worst case a
/// `Mutex::lock` contention with the main thread's drain, which happens at
/// most once per tick.
fn make_warn_handler(queue: Arc<Mutex<VecDeque<String>>>) -> ForgeWarnHandler {
    Arc::new(move |msg| {
        if let Ok(mut q) = queue.lock() {
            q.push_back(msg);
        }
    })
}

fn create_forge(
    forge_type: ForgeType,
    custom_host: Option<&str>,
    warn_handler: ForgeWarnHandler,
) -> anyhow::Result<std::sync::Arc<dyn ForgeBackend>> {
    match forge_type {
        ForgeType::GitHub => {
            let forge = if let Some(host) = custom_host {
                let base_url = format!("https://{host}/api/v3");
                travelagent_forge_github::GitHubForge::with_base_url(&base_url)?
            } else {
                travelagent_forge_github::GitHubForge::new()?
            };
            Ok(std::sync::Arc::new(forge.with_warn_handler(warn_handler)))
        }
        ForgeType::GitLab => {
            let forge = if let Some(host) = custom_host {
                let base_url = format!("https://{host}");
                travelagent_forge_gitlab::GitLabForge::with_base_url(&base_url)?
            } else {
                travelagent_forge_gitlab::GitLabForge::new()?
            };
            Ok(std::sync::Arc::new(forge.with_warn_handler(warn_handler)))
        }
    }
}