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<()> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let config = config::load_config()?;
let cli = Cli::parse();
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())));
}
match cli.command {
Some(Commands::List(args)) => {
run_list(args, ®istry, &config).await?;
}
Some(Commands::Open(args)) => {
run_open(args, ®istry).await;
}
None => {
let default_args = ListArgs {
repo: None,
org: None,
status: None,
sort: SortKey::Age,
profile: None,
};
run_list(default_args, ®istry, &config).await?;
}
}
Ok(())
}
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();
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)),
}
}
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()
});
}
if let Some(ref org_filter) = args.org {
all_prs.retain(|pr| {
pr.repo_full_name.split('/').next().unwrap_or("") == org_filter.as_str()
});
}
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
),
});
}
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(())
}
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();
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);
}
}
}
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};
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);
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()];
let repo_filter = "backend-api";
prs.retain(|pr| {
pr.repo_full_name == repo_filter
|| pr.repo_full_name.split('/').last().unwrap_or("") == repo_filter
});
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");
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"
);
}
}