prlens 0.1.1

One queue for all your PRs — aggregates GitHub and Bitbucket review requests into a single interactive view
Documentation
mod cache;
mod cli;
mod config;
mod display;
mod models;
mod provider;
mod tui;

use clap::Parser;
use std::sync::Arc;
use cli::{Cli, Commands, ListArgs, OpenArgs, SortKey, StatusFilter};
use models::{PullRequest, ReviewStatus};
use provider::{registry::ProviderRegistry, github::GitHubProvider, bitbucket::BitbucketProvider};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Install the aws-lc-rs provider before any TLS connections are made.
    // reqwest and octocrab both pull in rustls; without an explicit install_default()
    // the process-level provider is ambiguous and TLS connections panic at runtime.
    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();

    // 1. Initialize tracing to stderr — stdout is reserved for PR table output only
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .with_writer(std::io::stderr)
        .init();

    // 2. Load config (sync std::fs — before async runtime handles provider calls)
    let config = config::load_config()?;

    // 3. Parse CLI args
    let cli = Cli::parse();

    // 4. Build registry — register GitHubProvider when github.enabled (D-07)
    let mut registry = ProviderRegistry::new();
    if config.github.enabled {
        registry.register(Arc::new(GitHubProvider::new(config.github.clone())));
    }
    if config.bitbucket.enabled {
        registry.register(Arc::new(BitbucketProvider::new(config.bitbucket.clone())));
    }

    // 5. Dispatch commands — None (no subcommand) behaves identically to List with defaults
    match cli.command {
        Some(Commands::List(args)) => {
            run_list(args, &registry, &config).await?;
        }
        Some(Commands::Open(args)) => {
            run_open(args, &registry).await;
        }
        None => {
            let default_args = ListArgs {
                repo: None,
                org: None,
                status: None,
                sort: SortKey::Age,
                profile: None,
            };
            run_list(default_args, &registry, &config).await?;
        }
    }

    Ok(())
}

/// Fetch PRs from all providers, apply filters and sort, then launch the interactive TUI.
async fn run_list(args: ListArgs, registry: &ProviderRegistry, config: &config::Config) -> anyhow::Result<()> {
    let (tx, rx) = std::sync::mpsc::sync_channel::<(Vec<PullRequest>, Vec<String>)>(1);
    let registry_clone = registry.clone();

    // Fetch, filter, and sort in a background task so the TUI can show a loading screen.
    tokio::spawn(async move {
        let results = registry_clone.list_all_prs().await;

        let mut all_prs: Vec<PullRequest> = Vec::new();
        let mut provider_errors: Vec<String> = Vec::new();
        for (provider_name, result) in results {
            match result {
                Ok(prs) => all_prs.extend(prs),
                Err(e) => provider_errors.push(format!("Error from provider '{}': {}", provider_name, e)),
            }
        }

        // --- Filter: --repo ---
        if let Some(ref repo_filter) = args.repo {
            all_prs.retain(|pr| {
                pr.repo_full_name == *repo_filter
                    || pr.repo_full_name.split('/').last().unwrap_or("") == repo_filter.as_str()
            });
        }

        // --- Filter: --org ---
        if let Some(ref org_filter) = args.org {
            all_prs.retain(|pr| {
                pr.repo_full_name.split('/').next().unwrap_or("") == org_filter.as_str()
            });
        }

        // --- Filter: --status ---
        if let Some(ref status_filter) = args.status {
            all_prs.retain(|pr| match status_filter {
                StatusFilter::Pending => {
                    matches!(pr.review_status, ReviewStatus::NeedsReview | ReviewStatus::InReview)
                }
                StatusFilter::Approved => matches!(pr.review_status, ReviewStatus::Approved),
                StatusFilter::ChangesRequested => matches!(
                    pr.review_status,
                    ReviewStatus::ChangesRequested | ReviewStatus::Mixed
                ),
            });
        }

        // --- Sort ---
        match args.sort {
            SortKey::Age => all_prs.sort_by_key(|pr| pr.updated_at),
            SortKey::Author => all_prs.sort_by(|a, b| a.author.login.cmp(&b.author.login)),
            SortKey::Status => all_prs.sort_by_key(|pr| status_sort_key(&pr.review_status)),
        }

        let _ = tx.send((all_prs, provider_errors));
    });

    let initial_profile = args.profile.as_ref().and_then(|name| {
        config.profiles.iter().position(|p| p.name.eq_ignore_ascii_case(name))
    });

    tui::run_interactive(rx, &config, initial_profile)?;
    Ok(())
}

/// Fetch all PRs from the registry and open the one matching args.number in the system browser.
async fn run_open(args: OpenArgs, registry: &ProviderRegistry) {
    let results = registry.list_all_prs().await;
    let all_prs: Vec<PullRequest> = results
        .into_iter()
        .filter_map(|(_, r)| r.ok())
        .flatten()
        .collect();

    // Phase 3: single-provider only; PR number disambiguation deferred to Phase 4
    match all_prs.iter().find(|pr| pr.number == args.number) {
        Some(pr) => {
            if let Err(e) = open::that(&pr.url) {
                eprintln!("Failed to open PR #{} in browser: {}", args.number, e);
                std::process::exit(1);
            }
        }
        None => {
            eprintln!(
                "PR #{} not found in queue. Run `prlens list` to see available PRs.",
                args.number
            );
            std::process::exit(1);
        }
    }
}

/// Map ReviewStatus to a sort priority (lower = shown first, higher urgency).
fn status_sort_key(status: &ReviewStatus) -> u8 {
    match status {
        ReviewStatus::ChangesRequested => 0,
        ReviewStatus::NeedsReview => 1,
        ReviewStatus::InReview => 2,
        ReviewStatus::Mixed => 3,
        ReviewStatus::Approved => 4,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{PrIdentifier, PrState, ReviewStatus, User};
    use chrono::{DateTime, Duration, Utc};

    /// Construct a PullRequest fixture with configurable fields.
    fn make_pr(
        repo_full_name: &str,
        review_status: ReviewStatus,
        updated_at: DateTime<Utc>,
        login: &str,
    ) -> PullRequest {
        PullRequest {
            id: PrIdentifier {
                provider: "github".to_string(),
                owner: repo_full_name.split('/').next().unwrap_or("acme").to_string(),
                repo: repo_full_name
                    .split('/')
                    .last()
                    .unwrap_or("widget")
                    .to_string(),
                number: 1,
            },
            number: 1,
            title: "Test PR".to_string(),
            url: format!("https://github.com/{}/pull/1", repo_full_name),
            author: User {
                login: login.to_string(),
                display_name: None,
                avatar_url: None,
            },
            reviewers: vec![],
            repo_full_name: repo_full_name.to_string(),
            provider: "github".to_string(),
            head_branch: "feat/test".to_string(),
            base_branch: "main".to_string(),
            state: PrState::Open,
            review_status,
            ci_status: None,
            draft: false,
            created_at: Utc::now(),
            updated_at,
            labels: vec![],
            comment_count: 0,
            additions: None,
            deletions: None,
        }
    }

    #[test]
    fn repo_filter_by_short_name_matches() {
        let pr = make_pr("acme/backend-api", ReviewStatus::NeedsReview, Utc::now(), "alice");
        let filter = "backend-api";
        let matches = pr.repo_full_name == filter
            || pr.repo_full_name.split('/').last().unwrap_or("") == filter;
        assert!(matches, "short repo name 'backend-api' should match 'acme/backend-api'");
    }

    #[test]
    fn repo_filter_by_full_name_matches() {
        let pr = make_pr("acme/backend-api", ReviewStatus::NeedsReview, Utc::now(), "alice");
        let filter = "acme/backend-api";
        let matches = pr.repo_full_name == filter
            || pr.repo_full_name.split('/').last().unwrap_or("") == filter;
        assert!(matches, "full name 'acme/backend-api' should match 'acme/backend-api'");
    }

    #[test]
    fn status_filter_pending_matches_needs_review_and_in_review() {
        let pr_needs = make_pr("acme/widget", ReviewStatus::NeedsReview, Utc::now(), "alice");
        let pr_in = make_pr("acme/widget", ReviewStatus::InReview, Utc::now(), "bob");
        let pr_approved = make_pr("acme/widget", ReviewStatus::Approved, Utc::now(), "carol");

        let pending_needs = matches!(
            pr_needs.review_status,
            ReviewStatus::NeedsReview | ReviewStatus::InReview
        );
        let pending_in = matches!(
            pr_in.review_status,
            ReviewStatus::NeedsReview | ReviewStatus::InReview
        );
        let pending_approved = matches!(
            pr_approved.review_status,
            ReviewStatus::NeedsReview | ReviewStatus::InReview
        );

        assert!(pending_needs, "NeedsReview should match Pending filter");
        assert!(pending_in, "InReview should match Pending filter");
        assert!(!pending_approved, "Approved should NOT match Pending filter");
    }

    #[test]
    fn sort_age_orders_oldest_first() {
        let now = Utc::now();
        let old = make_pr("acme/w", ReviewStatus::NeedsReview, now - Duration::days(5), "alice");
        let new = make_pr("acme/w", ReviewStatus::NeedsReview, now - Duration::hours(1), "bob");
        let mut prs = vec![new.clone(), old.clone()];
        prs.sort_by_key(|pr| pr.updated_at);
        // Oldest updated_at should be first
        assert_eq!(prs[0].author.login, "alice", "oldest PR (alice, 5d ago) should be first");
        assert_eq!(prs[1].author.login, "bob", "newest PR (bob, 1h ago) should be second");
    }

    #[test]
    fn and_semantics_repo_and_status_filter() {
        let now = Utc::now();
        let matching = make_pr("acme/backend-api", ReviewStatus::NeedsReview, now, "alice");
        let wrong_repo = make_pr("acme/frontend", ReviewStatus::NeedsReview, now, "bob");
        let wrong_status = make_pr("acme/backend-api", ReviewStatus::Approved, now, "carol");

        let mut prs = vec![matching.clone(), wrong_repo.clone(), wrong_status.clone()];

        // Apply repo filter
        let repo_filter = "backend-api";
        prs.retain(|pr| {
            pr.repo_full_name == repo_filter
                || pr.repo_full_name.split('/').last().unwrap_or("") == repo_filter
        });

        // Apply status filter (Pending)
        prs.retain(|pr| {
            matches!(pr.review_status, ReviewStatus::NeedsReview | ReviewStatus::InReview)
        });

        assert_eq!(prs.len(), 1, "only one PR should survive both filters");
        assert_eq!(prs[0].author.login, "alice");
    }

    #[test]
    fn open_find_returns_none_when_prs_empty() {
        let all_prs: Vec<PullRequest> = Vec::new();
        let result = all_prs.iter().find(|pr| pr.number == 999);
        assert!(result.is_none(), "find on empty vec with number 999 should return None");
    }

    #[test]
    fn open_find_returns_pr_when_number_matches() {
        let now = Utc::now();
        let pr = make_pr("acme/widget", ReviewStatus::NeedsReview, now, "alice");
        // make_pr sets number=1; override to use a specific number for the find test
        let mut pr_custom = make_pr("acme/widget", ReviewStatus::NeedsReview, now, "alice");
        pr_custom.number = 42;
        let all_prs = vec![pr, pr_custom];
        let found = all_prs.iter().find(|p| p.number == 42);
        assert!(found.is_some(), "find should locate PR with number 42");
        assert_eq!(found.unwrap().number, 42);
    }

    #[test]
    fn status_sort_key_ordering() {
        assert!(
            status_sort_key(&ReviewStatus::ChangesRequested)
                < status_sort_key(&ReviewStatus::NeedsReview),
            "ChangesRequested should sort before NeedsReview"
        );
        assert!(
            status_sort_key(&ReviewStatus::NeedsReview)
                < status_sort_key(&ReviewStatus::InReview),
        );
        assert!(
            status_sort_key(&ReviewStatus::InReview) < status_sort_key(&ReviewStatus::Mixed),
        );
        assert!(
            status_sort_key(&ReviewStatus::Mixed) < status_sort_key(&ReviewStatus::Approved),
            "Approved should sort last"
        );
    }
}