use std::cmp::Ordering;
use clap::{Args, ValueEnum};
use enum_dispatch::enum_dispatch;
use futures::{
StreamExt,
stream::{FuturesOrdered, FuturesUnordered},
try_join,
};
use itertools::Itertools;
use jiff::SpanRound;
use owo_colors::OwoColorize;
use pluralizer::pluralize;
use tracing::info;
use crate::{
bookmark::Bookmark,
cli::CliConfig,
commands::{GetBookmarksOptions, StrVisualWidth},
config::Config,
description::FormatMergeRequest,
error::{ConfigSnafu, Error, InvalidComponentSnafu, Result},
forge::{
ApprovalSatisfaction,
CheckStatus,
Forge,
ForgeImpl,
ForgeMergeRequest,
MergeRequestStatus,
},
jj::Jujutsu,
output::Output,
};
#[derive(Args)]
pub struct StatusCommandConfig {
#[arg(short = 'o', long = "output", default_value = "two-line-compact")]
pub output_mode: DisplayStatusMode,
#[command(flatten)]
pub revset_options: StatusCommandRevsetOptions,
}
impl StatusCommandConfig {
pub fn help_long() -> String {
format!(
r#"
Show the status of tracked bookmarks and their {}
{}
Show the status of all my bookmarks:
{}
Show the status of all tracked bookmarks:
{}
Show the status of a specific revset:
{}
"#,
match ForgeImpl::from_cwd() {
Ok(forge) => format!("{}s", forge.mr_name()),
Err(_) => "MRs/PRs".to_string(),
},
"Examples:".yellow().bold(),
"jj vine status".green().bold(),
"jj vine status --tracked".green().bold(),
"jj vine status -r <revset>".green().bold(),
)
}
}
#[derive(Args, Default)]
#[group(required = false, multiple = false)]
pub struct StatusCommandRevsetOptions {
#[arg(short = 'r', long)]
pub revset: Option<String>,
#[arg(long)]
pub tracked: bool,
}
impl StatusCommandRevsetOptions {
fn to_get_bookmarks_options(&self) -> GetBookmarksOptions {
match (self.revset.as_deref(), self.tracked) {
(Some(revset), false) => GetBookmarksOptions::Revset(revset.to_string()),
(None, true) => GetBookmarksOptions::Tracked,
(None, false) => GetBookmarksOptions::Mine,
_ => unreachable!(),
}
}
}
enum BookmarkStatus {
HasMergeRequest {
bookmark: String,
merge_request: Box<ForgeMergeRequest>,
status: MergeRequestStatus,
},
NoMergeRequest {
bookmark: String,
},
}
struct BookmarkStatusError {
bookmark: String,
error: Error,
}
pub async fn status(config: &StatusCommandConfig, cli_config: &CliConfig<'_>) -> Result<()> {
let jj = Jujutsu::new(&cli_config.repository)?;
let repo_config = Config::load(&cli_config.repository)?;
let forge = ForgeImpl::new(&repo_config)?;
let output = cli_config.output;
let revset = config.revset_options.to_get_bookmarks_options().to_revset();
let changes = jj.log(revset)?;
let bookmarks: Vec<_> = Bookmark::from_changes(&changes).into_iter().collect();
if bookmarks.is_empty() {
output.finish();
info!("No tracked bookmarks found.");
return Ok(());
}
output.log_current("Checking status of tracked bookmarks");
let futures = FuturesUnordered::new();
for bookmark in &bookmarks {
futures.push(async {
let _substep = output.start_substep(bookmark.name().to_string());
let mr_option = forge
.find_merge_request_by_source_branch(bookmark.name())
.await
.map_err(|e| BookmarkStatusError {
bookmark: bookmark.name().to_string(),
error: e,
})?;
let status = match mr_option {
Some(mr) => {
let mr_status = forge
.get_merge_request_status(mr.iid().as_ref())
.await
.map_err(|e| BookmarkStatusError {
bookmark: bookmark.name().to_string(),
error: e,
})?;
BookmarkStatus::HasMergeRequest {
bookmark: bookmark.name().to_string(),
merge_request: Box::new(mr),
status: mr_status,
}
}
None => BookmarkStatus::NoMergeRequest {
bookmark: bookmark.name().to_string(),
},
};
Ok(status)
});
}
let statuses = futures.collect::<Vec<_>>().await;
let rendered = config.output_mode.render(statuses, &forge, output).await;
output.finish();
info!("{}", rendered);
Ok(())
}
#[derive(ValueEnum, Clone, Copy)]
pub enum DisplayStatusMode {
#[value(id = "two-line-compact")]
TwoLineCompact,
}
impl std::str::FromStr for DisplayStatusMode {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"flat" => Ok(DisplayStatusMode::TwoLineCompact),
_ => Err(ConfigSnafu {
message: format!("Invalid output mode: {}. Valid modes are: flat", s),
}
.build()),
}
}
}
impl DisplayStatusMode {
async fn render(
&self,
statuses: Vec<Result<BookmarkStatus, BookmarkStatusError>>,
forge: &ForgeImpl,
output: &dyn Output,
) -> String {
match self {
DisplayStatusMode::TwoLineCompact => {
print_two_line_compact(statuses, forge, output).await
}
}
}
}
fn sorted_statuses(
statuses: &[Result<BookmarkStatus, BookmarkStatusError>],
) -> impl Iterator<Item = &Result<BookmarkStatus, BookmarkStatusError>> + '_ {
statuses.iter().sorted_by(|a, b| match (a, b) {
(
Ok(BookmarkStatus::HasMergeRequest {
merge_request: a, ..
}),
Ok(BookmarkStatus::HasMergeRequest {
merge_request: b, ..
}),
) => a.iid().cmp(&b.iid()),
(Ok(BookmarkStatus::HasMergeRequest { .. }), _) => Ordering::Less,
(_, Ok(BookmarkStatus::HasMergeRequest { .. })) => Ordering::Greater,
(
Ok(BookmarkStatus::NoMergeRequest { bookmark: a }),
Ok(BookmarkStatus::NoMergeRequest { bookmark: b }),
) => a.cmp(b),
(Ok(BookmarkStatus::NoMergeRequest { .. }), _) => Ordering::Less,
(_, Ok(BookmarkStatus::NoMergeRequest { .. })) => Ordering::Greater,
(Err(_), Err(_)) => Ordering::Equal,
})
}
async fn print_two_line_compact(
statuses: Vec<Result<BookmarkStatus, BookmarkStatusError>>,
forge: &ForgeImpl,
output: &dyn Output,
) -> String {
let futures: FuturesOrdered<_> = sorted_statuses(&statuses)
.map(|status| two_line_compact_status(status, forge, output))
.collect();
futures
.collect::<Vec<_>>()
.await
.into_iter()
.flatten()
.join("\n\n")
}
async fn two_line_compact_status(
status: &Result<BookmarkStatus, BookmarkStatusError>,
forge: &ForgeImpl,
output: &dyn Output,
) -> Option<String> {
match status {
Ok(BookmarkStatus::HasMergeRequest {
bookmark,
merge_request,
status,
}) => {
let _substep =
output.start_substep(forge.format_merge_request_id(&merge_request.iid()));
let data = StatusData::new(
forge,
bookmark.clone(),
merge_request.clone(),
status.clone(),
);
let first_line = match parse_components("iid title") {
Ok(line) => line,
Err(e) => return Some(e.to_string()),
};
let second_line = match parse_components(
"bookmark ready checks approval num_discussions created url",
) {
Ok(line) => line,
Err(e) => return Some(e.to_string()),
};
let (first_line, second_line) = match try_join!(
render_components(first_line, &data),
render_components(second_line, &data)
) {
Ok(lines) => lines,
Err(err) => return Some(err.to_string()),
};
let second_line_padding =
" ".repeat(first_line.first().unwrap_or(&"".to_string()).visual_width() + 1);
Some(format!(
"{}\n{}{}",
first_line.join(" "),
second_line_padding,
second_line.join(&" • ".dimmed().to_string()),
))
}
Ok(BookmarkStatus::NoMergeRequest { bookmark }) => Some(format!(
"{} {}",
bookmark.magenta(),
"No merge request".dimmed()
)),
Err(BookmarkStatusError { bookmark, error }) => Some(format!(
"Failed to get status for bookmark {}: {}",
bookmark.magenta(),
error
)),
}
}
fn parse_components(line: &str) -> Result<Vec<StatusComponentImpl>> {
line.split_whitespace()
.map(get_component)
.collect::<Result<Vec<_>>>()
}
async fn render_components(
components: Vec<StatusComponentImpl>,
data: &StatusData<'_>,
) -> Result<Vec<String>> {
let futures: FuturesOrdered<_> = components
.iter()
.map(|component| component.render(data))
.collect();
Ok(futures
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect())
}
fn get_component(name: &str) -> Result<StatusComponentImpl> {
match name {
"bookmark" => Ok(BookmarkNameComponent {}.into()),
"iid" => Ok(MergeRequestIIDComponent {}.into()),
"title" => Ok(MergeRequestTitleComponent {}.into()),
"ready" => Ok(ReadyToMergeComponent {}.into()),
"checks" => Ok(ChecksStatusComponent {}.into()),
"approval" => Ok(ApprovalStatusComponent {}.into()),
"created" => Ok(CreatedAtComponent {}.into()),
"url" => Ok(MergeRequestURLComponent {}.into()),
"num_discussions" => Ok(NumOpenDiscussionsComponent {}.into()),
_ => Err(InvalidComponentSnafu {
component: name.to_string(),
}
.build()),
}
}
#[enum_dispatch]
trait StatusComponent: Send + Sync {
async fn render(&self, status: &StatusData<'_>) -> Result<Option<String>>;
}
#[enum_dispatch(StatusComponent)]
enum StatusComponentImpl {
BookmarkName(BookmarkNameComponent),
MergeRequestIID(MergeRequestIIDComponent),
MergeRequestTitle(MergeRequestTitleComponent),
ReadyToMerge(ReadyToMergeComponent),
ChecksStatus(ChecksStatusComponent),
ApprovalStatus(ApprovalStatusComponent),
CreatedAt(CreatedAtComponent),
MergeRequestURL(MergeRequestURLComponent),
NumOpenDiscussions(NumOpenDiscussionsComponent),
}
macro_rules! component {
($struct_name:ident, $body:expr) => {
struct $struct_name {}
impl StatusComponent for $struct_name {
async fn render(&self, status: &StatusData<'_>) -> Result<Option<String>> {
($body)(status).await
}
}
};
}
struct StatusData<'a> {
forge: &'a ForgeImpl,
bookmark: String,
merge_request: Box<ForgeMergeRequest>,
status: MergeRequestStatus,
}
impl<'a> StatusData<'a> {
fn new(
forge: &'a ForgeImpl,
bookmark: String,
merge_request: Box<ForgeMergeRequest>,
status: MergeRequestStatus,
) -> Self {
Self {
forge,
bookmark,
merge_request,
status,
}
}
}
component!(BookmarkNameComponent, async |data: &StatusData<'_>| {
Ok(Some(data.bookmark.magenta().to_string()))
});
component!(MergeRequestIIDComponent, async |data: &StatusData<'_>| {
Ok(Some(
data.forge
.format_merge_request_id(&data.merge_request.iid())
.cyan()
.to_string(),
))
});
component!(MergeRequestTitleComponent, async |data: &StatusData<'_>| {
Ok(Some(format!("\"{}\"", data.merge_request.title().white())))
});
component!(ReadyToMergeComponent, async |data: &StatusData<'_>| {
if data.status.ready_to_merge() {
Ok(Some("[READY]".green().bold().to_string()))
} else {
Ok(None)
}
});
component!(ChecksStatusComponent, async |data: &StatusData<'_>| {
match data.status.check_status {
CheckStatus::Success => Ok(Some("✓ Checks OK".green().to_string())),
CheckStatus::Failed => Ok(Some("✗ Checks failing".red().to_string())),
CheckStatus::Pending => Ok(Some("⋯ Checks pending".yellow().to_string())),
CheckStatus::None => Ok(None),
}
});
component!(ApprovalStatusComponent, async |data: &StatusData<'_>| {
let approval_status = &data.status.approval_status;
if approval_status.required_count == 0
&& approval_status.satisfaction == ApprovalSatisfaction::Satisfied
{
Ok(Some(
pluralize("approval", approval_status.approved_count as isize, true)
.white()
.to_string(),
))
} else if approval_status.satisfaction == ApprovalSatisfaction::Satisfied {
Ok(Some(
format!(
"Approved ({}/{})",
approval_status.approved_count, approval_status.required_count
)
.green()
.to_string(),
))
} else if approval_status.satisfaction != ApprovalSatisfaction::Unknown {
Ok(Some(
format!(
"Needs {} ({}/{})",
pluralize("approval", approval_status.required_count as isize, false),
approval_status.approved_count,
approval_status.required_count
)
.red()
.to_string(),
))
} else {
Ok(None)
}
});
component!(CreatedAtComponent, async |data: &StatusData<'_>| {
let now = jiff::Zoned::now();
let duration = (data.merge_request.created_at() - now.timestamp()).abs();
let mut round = SpanRound::new().largest(jiff::Unit::Year).relative(&now);
if duration.total((jiff::Unit::Hour, &now)).unwrap() > 24.0 {
round = round.smallest(jiff::Unit::Hour);
} else if duration.total((jiff::Unit::Minute, &now)).unwrap() > 0.0 {
round = round.smallest(jiff::Unit::Minute);
} else {
round = round.smallest(jiff::Unit::Second);
}
Ok(Some(
format!(
"{:#} old",
duration.round(round).expect("Failed to round duration")
)
.dimmed()
.to_string(),
))
});
component!(MergeRequestURLComponent, async |data: &StatusData<'_>| {
Ok(Some(
data.merge_request
.url(data.forge)
.truecolor(100, 100, 100)
.to_string(),
))
});
component!(NumOpenDiscussionsComponent, async |data: &StatusData<
'_,
>|
-> Result<
Option<String>,
> {
let num_open_discussions = data
.forge
.num_open_discussions(&data.merge_request.iid())
.await?;
match num_open_discussions.unresolved {
0 => Ok(None),
unresolved => Ok(Some(
format!(
"{} open {}",
unresolved,
pluralize("discussion", unresolved as isize, false)
)
.yellow()
.to_string(),
)),
}
});