use chrono::{DateTime, Utc};
use comfy_table::{Cell, ColumnConstraint, ContentArrangement, Table, Width, presets::UTF8_FULL_CONDENSED};
use crate::models::{CiStatus, PullRequest, ReviewStatus};
pub fn print_pr_table_unified(prs: &[PullRequest]) {
if prs.is_empty() {
println!("No pull requests found.");
return;
}
let mut table = Table::new();
table.load_preset(UTF8_FULL_CONDENSED);
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec!["Src", "#", "Title", "Author", "Repo", "Status", "CI", "Rev.", "Age"]);
if let Some(col) = table.column_mut(2) {
col.set_constraint(ColumnConstraint::UpperBoundary(Width::Fixed(50)));
}
for pr in prs {
let provider_badge = match pr.provider.as_str() {
"github" => "\u{E709}", "bitbucket" => "\u{E703}", other => other,
};
let title = if pr.draft {
format!("\u{F444} {}", pr.title)
} else {
pr.title.clone()
};
table.add_row(vec![
Cell::new(provider_badge),
Cell::new(pr.number.to_string()),
Cell::new(&title),
Cell::new(&pr.author.login),
Cell::new(&pr.repo_full_name),
Cell::new(format_review_status(&pr.review_status)),
Cell::new(format_ci_status(&pr.ci_status)),
Cell::new(pr.reviewers.len().to_string()),
Cell::new(format_age(&pr.updated_at)),
]);
}
println!("{}", table);
}
pub fn print_pr_table(provider_name: &str, prs: &[PullRequest]) {
if prs.is_empty() {
println!("[{}] No pull requests found.", provider_name);
return;
}
let mut table = Table::new();
table.load_preset(UTF8_FULL_CONDENSED);
table.set_header(vec!["Provider", "#", "Title", "Author", "Repo", "Status", "Updated"]);
for pr in prs {
let title = if pr.title.chars().count() > 50 {
format!("{}...", &pr.title.chars().take(47).collect::<String>())
} else {
pr.title.clone()
};
table.add_row(vec![
provider_name.to_string(),
pr.number.to_string(),
title,
pr.author.login.clone(),
pr.repo_full_name.clone(),
format_review_status(&pr.review_status).to_string(),
format!("{}", pr.updated_at.format("%Y-%m-%d %H:%M")),
]);
}
println!("{}", table);
}
fn format_age(dt: &DateTime<Utc>) -> String {
let elapsed = Utc::now().signed_duration_since(*dt);
let secs = elapsed.num_seconds();
if secs < 0 {
return "future".to_string();
}
if secs < 60 {
return "now".to_string();
}
let mins = elapsed.num_minutes();
if mins < 60 {
return format!("{}m", mins);
}
let hours = elapsed.num_hours();
if hours < 24 {
return format!("{}h", hours);
}
let days = elapsed.num_days();
if days < 7 {
return format!("{}d", days);
}
let weeks = days / 7;
if weeks < 8 {
return format!("{}w", weeks);
}
let months = days / 30;
if months < 12 {
return format!("{}mo", months);
}
format!("{}y", days / 365)
}
fn format_review_status(status: &ReviewStatus) -> &'static str {
match status {
ReviewStatus::NeedsReview => "needs review",
ReviewStatus::Approved => "approved",
ReviewStatus::ChangesRequested => "changes requested",
ReviewStatus::Mixed => "mixed",
ReviewStatus::InReview => "in review",
}
}
fn format_ci_status(ci: &Option<CiStatus>) -> &'static str {
match ci {
None => "\u{F068}", Some(CiStatus::Success) => "\u{F058}", Some(CiStatus::Failed) => "\u{F057}", Some(CiStatus::Pending) | Some(CiStatus::Running) => "\u{F110}", Some(CiStatus::Cancelled) => "\u{F068}", }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{CiStatus, PrIdentifier, PrState, PullRequest, ReviewStatus, User};
use chrono::Duration;
fn make_pr(draft: bool, updated_at: DateTime<Utc>) -> PullRequest {
PullRequest {
id: PrIdentifier {
provider: "github".to_string(),
owner: "acme".to_string(),
repo: "widget".to_string(),
number: 1,
},
number: 1,
title: "feat: test PR".to_string(),
url: "https://github.com/acme/widget/pull/1".to_string(),
author: User {
login: "alice".to_string(),
display_name: None,
avatar_url: None,
},
reviewers: vec![],
repo_full_name: "acme/widget".to_string(),
provider: "github".to_string(),
head_branch: "feat/test".to_string(),
base_branch: "main".to_string(),
state: PrState::Open,
review_status: ReviewStatus::NeedsReview,
ci_status: None,
draft,
created_at: Utc::now(),
updated_at,
labels: vec![],
comment_count: 0,
additions: None,
deletions: None,
}
}
#[test]
fn format_age_minutes() {
let dt = Utc::now() - Duration::minutes(45);
assert_eq!(format_age(&dt), "45m");
}
#[test]
fn format_age_days() {
let dt = Utc::now() - Duration::days(3);
assert_eq!(format_age(&dt), "3d");
}
#[test]
fn format_age_now() {
let dt = Utc::now() - Duration::seconds(30);
assert_eq!(format_age(&dt), "now");
}
#[test]
fn format_age_hours() {
let dt = Utc::now() - Duration::hours(5);
assert_eq!(format_age(&dt), "5h");
}
#[test]
fn format_age_weeks() {
let dt = Utc::now() - Duration::days(14);
assert_eq!(format_age(&dt), "2w");
}
#[test]
fn format_age_months() {
let dt = Utc::now() - Duration::days(90);
assert_eq!(format_age(&dt), "3mo");
}
#[test]
fn format_age_future_clock_skew() {
let dt = Utc::now() + Duration::seconds(60);
assert_eq!(format_age(&dt), "future");
}
#[test]
fn format_ci_status_none_returns_dash() {
assert_eq!(format_ci_status(&None), "\u{F068}");
}
#[test]
fn format_ci_status_success() {
assert_eq!(format_ci_status(&Some(CiStatus::Success)), "\u{F058}");
}
#[test]
fn format_ci_status_failed() {
assert_eq!(format_ci_status(&Some(CiStatus::Failed)), "\u{F057}");
}
#[test]
fn format_ci_status_pending() {
assert_eq!(format_ci_status(&Some(CiStatus::Pending)), "\u{F110}");
}
#[test]
fn format_ci_status_running() {
assert_eq!(format_ci_status(&Some(CiStatus::Running)), "\u{F110}");
}
#[test]
fn format_ci_status_cancelled() {
assert_eq!(format_ci_status(&Some(CiStatus::Cancelled)), "\u{F068}");
}
#[test]
fn draft_pr_has_d_prefix_in_title() {
let pr = make_pr(true, Utc::now() - Duration::hours(1));
let title = if pr.draft {
format!("\u{F444} {}", pr.title)
} else {
pr.title.clone()
};
assert!(title.starts_with("\u{F444} "), "Draft PR title must start with draft icon, got: {}", title);
assert_eq!(title, "\u{F444} feat: test PR");
}
#[test]
fn non_draft_pr_has_no_d_prefix() {
let pr = make_pr(false, Utc::now() - Duration::hours(1));
let title = if pr.draft {
format!("\u{F444} {}", pr.title)
} else {
pr.title.clone()
};
assert!(!title.starts_with("\u{F444} "), "Non-draft PR title must not start with draft icon");
assert_eq!(title, "feat: test PR");
}
#[test]
fn print_pr_table_unified_empty_prints_no_prs_message() {
print_pr_table_unified(&[]);
}
#[test]
fn print_pr_table_unified_single_pr_does_not_panic() {
let pr = make_pr(false, Utc::now() - Duration::hours(2));
print_pr_table_unified(&[pr]);
}
}