5.5 KiB · text History b868907
//! The repository list and one repository's summary.

use anyhow::Result;

use crate::api::models::Repository;
use crate::cli::{RepoArgs, RepoListArgs, RepoSort};
use crate::commands::Ctx;
use crate::output::{
    Table, Tone, ago, ci_rank, heading, note, paint, status_badge, status_cell, truncate,
};

pub async fn list(ctx: &Ctx, args: &RepoListArgs) -> Result<()> {
    let res = ctx.api.repos().await?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }

    let needle = args.search.as_deref().map(str::to_lowercase);
    let mut repos: Vec<&Repository> = res
        .repositories
        .iter()
        .filter(|r| args.archived || !r.archived)
        .filter(|r| match &needle {
            None => true,
            Some(needle) => {
                r.slug().to_lowercase().contains(needle)
                    || r.description
                        .as_deref()
                        .is_some_and(|d| d.to_lowercase().contains(needle))
            }
        })
        .collect();

    // Sorting happens here because `/repos` returns everything the token
    // can reach in one response — there's no pagination to respect, so
    // re-ordering costs no round-trip.
    sort(&mut repos, args.sort);

    if repos.is_empty() {
        note("No repositories.");
        return Ok(());
    }

    let mut table = Table::new(["", "REPOSITORY", "CI", "PUSHED", "DESCRIPTION"]);
    for repo in &repos {
        let visibility = match repo.visibility.as_deref() {
            Some("private") => paint("•", Tone::Busy),
            _ => " ".to_string(),
        };
        let ci = match repo.ci_status.as_deref() {
            Some(status) => status_cell(status, None),
            None => paint("—", Tone::Muted),
        };
        table.row([
            visibility,
            repo.slug(),
            ci,
            ago(repo.pushed_at.as_deref()),
            truncate(repo.description.as_deref().unwrap_or(""), 48),
        ]);
    }
    table.print();

    let orgs = res.namespaces.iter().filter(|n| n.kind == "org").count();
    if orgs > 0 {
        note(&format!(
            "\n{} repositories across {} namespace(s), {orgs} of them organizations.",
            repos.len(),
            res.namespaces.len()
        ));
    }
    Ok(())
}

fn sort(repos: &mut [&Repository], order: RepoSort) {
    match order {
        RepoSort::Name => repos.sort_by_key(|r| r.slug().to_lowercase()),
        // Newest push first, never-pushed repos last: an empty repo has no
        // work on it to be recent about. Timestamps compare as strings —
        // they're ISO-8601 UTC from one server with one formatter, so
        // lexicographic order *is* chronological order and no parse can
        // fail halfway through a sort.
        RepoSort::Pushed => repos.sort_by(|a, b| {
            by_pushed(a, b).then_with(|| a.slug().to_lowercase().cmp(&b.slug().to_lowercase()))
        }),
        RepoSort::Ci => repos.sort_by(|a, b| {
            ci_rank(a.ci_status.as_deref())
                .cmp(&ci_rank(b.ci_status.as_deref()))
                .then_with(|| by_pushed(a, b))
                .then_with(|| a.slug().to_lowercase().cmp(&b.slug().to_lowercase()))
        }),
    }
}

fn by_pushed(a: &Repository, b: &Repository) -> std::cmp::Ordering {
    match (a.pushed_at.as_deref(), b.pushed_at.as_deref()) {
        (Some(x), Some(y)) => y.cmp(x),
        (None, Some(_)) => std::cmp::Ordering::Greater,
        (Some(_), None) => std::cmp::Ordering::Less,
        (None, None) => std::cmp::Ordering::Equal,
    }
}

pub async fn show(ctx: &Ctx, args: &RepoArgs) -> Result<()> {
    let repo = match &args.target {
        Some(raw) => crate::api::RepoRef::parse(raw)?,
        None => ctx.repo()?,
    };

    let res = ctx.api.repo(&repo).await?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }

    let r = &res.repository;
    heading(&r.slug());
    if let Some(description) = r.description.as_deref().filter(|d| !d.is_empty()) {
        println!("{description}");
    }
    println!();

    let visibility = r.visibility.as_deref().unwrap_or("unknown");
    let archived = if r.archived { ", archived" } else { "" };
    println!("visibility   {visibility}{archived}");
    println!(
        "branch       {}",
        r.default_branch.as_deref().unwrap_or("—")
    );
    println!("your access  {}", r.permission.as_deref().unwrap_or("read"));
    println!("pushed       {}", ago(r.pushed_at.as_deref()));
    println!(
        "open         {} issues, {} pulls, {} wiki pages",
        res.counts.open_issues, res.counts.open_pulls, res.counts.wiki_pages
    );

    match &res.latest_run {
        Some(run) => println!(
            "latest run   #{} {} {} on {}",
            run.number,
            status_badge(run.display_status()),
            run.workflow_label(),
            run.short_ref().unwrap_or("—")
        ),
        None => println!("latest run   {}", paint("none yet", Tone::Muted)),
    }

    let p = &res.permissions;
    let mut may: Vec<&str> = Vec::new();
    if p.ci {
        may.push("manage CI");
    }
    if p.issues {
        may.push("write issues");
    }
    if p.pulls {
        may.push("write pulls");
    }
    if p.merge {
        may.push("merge");
    }
    println!(
        "you may      {}",
        if may.is_empty() {
            "read".to_string()
        } else {
            may.join(", ")
        }
    );

    if let Some(url) = &r.html_url {
        note(&format!("\n{url}"));
    } else {
        note(&format!("\n{}", ctx.repo_web_url(&repo, "")));
    }
    Ok(())
}