quinjet 0.0.50

A fast, live, keyboard-first Git source-control interface for the terminal
#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;

pub(crate) fn repositories(repositories: &[GitHubRepository], warnings: &[String]) -> String {
    let mut out = Report::default();
    for repository in repositories {
        let remotes = if repository.remotes.is_empty() {
            "inferred".to_owned()
        } else {
            format!("remote {}", repository.remotes.join(", "))
        };
        out.line(&format!(
            "{:<40} {:<10} {}",
            repository.display_name(),
            remotes,
            repository.url
        ));
    }
    for warning in warnings {
        out.line(&format!("warning: {warning}"));
    }
    out.finish()
}

pub(crate) fn pull_request(pull_request: &PullRequest) -> String {
    let mut out = Report::default();
    let state = if pull_request.is_draft {
        "DRAFT".to_owned()
    } else {
        pull_request.state.clone()
    };
    out.line(&format!("#{}  {}", pull_request.number, pull_request.title));
    out.line(&format!(
        "{state} · @{} · opened {} · updated {}",
        pull_request.author,
        format_local_timestamp(&pull_request.created_at),
        format_local_timestamp(&pull_request.updated_at)
    ));
    out.line(&format!("Source       {}", pull_request.head_label()));
    out.line(&format!("Destination  {}", pull_request.base_label()));
    out.line(&format!(
        "Changes      {} files, +{} -{}",
        pull_request.changed_files, pull_request.additions, pull_request.deletions
    ));
    let action = &pull_request.action_state;
    if !action.merge_queue_entry_id.is_empty() {
        out.line(&format!(
            "Merge        queue position {} ({})",
            action.merge_queue_position,
            action.merge_queue_state.to_ascii_lowercase()
        ));
    } else if !action.auto_merge_method.is_empty() {
        out.line(&format!(
            "Merge        auto {}",
            action.auto_merge_method.to_ascii_lowercase()
        ));
    } else if pull_request.state == "OPEN"
        && !pull_request.is_draft
        && !action.merge_state.is_empty()
    {
        out.line(&format!(
            "Merge        {} ({})",
            action.merge_state.to_ascii_lowercase(),
            action.mergeable.to_ascii_lowercase()
        ));
    }
    if action.is_locked {
        out.line("Conversation locked");
    }
    out.line(&format!("URL          {}", pull_request.url));
    if !pull_request.description.trim().is_empty() {
        out.line(&format!("\n{}", pull_request.description.trim_end()));
    }
    out.finish()
}

pub(crate) fn pull_request_review(review: &PullRequestReviewSnapshot) -> String {
    let mut out = Report::default();
    let decision = review.review_decision.as_deref().unwrap_or("NONE");
    out.line(&format!(
        "Review: {decision}  · {} unresolved  · {} pending",
        review.unresolved_count(),
        review.pending_comment_count()
    ));
    if let Some(pending) = &review.pending_review {
        out.line(&format!("Pending review: {}", pending.id));
    }
    for thread in &review.threads {
        let location = match thread.subject {
            PullRequestReviewThreadSubject::File => thread.path.display().to_string(),
            PullRequestReviewThreadSubject::Line | PullRequestReviewThreadSubject::Unknown => {
                let side = match thread.side {
                    PullRequestReviewSide::Left => "left",
                    PullRequestReviewSide::Right => "right",
                    PullRequestReviewSide::Unknown => "unknown",
                };
                format!(
                    "{}:{}:{side}",
                    thread.path.display(),
                    thread.line.or(thread.original_line).unwrap_or_default()
                )
            }
        };
        let state = if thread.is_resolved {
            "resolved"
        } else if thread.is_outdated {
            "outdated"
        } else {
            "unresolved"
        };
        out.line(&format!("\n[{state}] {location}  {}", thread.id));
        for comment in &thread.comments {
            out.line(&format!(
                "  @{}  {}  ({})",
                comment.author,
                comment.state.to_lowercase(),
                format_local_timestamp(&comment.created_at)
            ));
            for line in comment.body.trim_end().lines() {
                out.line(&format!("    {line}"));
            }
        }
        if thread.comments_truncated {
            out.line("  [older replies were truncated]");
        }
    }
    if review.truncated {
        out.line("\n[the review reached Quinjet's size cap and was truncated]");
    }
    out.finish()
}

pub(crate) fn pull_request_files(index: &PullRequestDiffIndex) -> String {
    let mut out = Report::default();
    for file in &index.files {
        let counts = file
            .counts
            .map(|counts| {
                if counts.binary {
                    "  binary".to_owned()
                } else {
                    format!("  +{} -{}", counts.additions, counts.deletions)
                }
            })
            .unwrap_or_default();
        let rename = file
            .old_path
            .as_ref()
            .map(|from| format!(" (from {})", from.display()))
            .unwrap_or_default();
        out.line(&format!(
            "{} {}{rename}{counts}",
            file_status_code(file.status),
            file.path.display()
        ));
    }
    if index.truncated {
        out.line(&format!(
            "\n[the changed-file list reached Quinjet's size cap; {} of {} shown]",
            index.files.len(),
            index.total_files
        ));
    }
    out.finish()
}

pub(crate) fn checks(checks: &[PullRequestCheck]) -> String {
    if checks.is_empty() {
        return "No checks reported\n".to_owned();
    }
    let mut out = Report::default();
    for check in checks {
        let duration = check.duration_label();
        let duration = if duration.is_empty() {
            String::new()
        } else {
            format!("  {duration}")
        };
        out.line(&format!(
            "{}  {:<9} {:<44} {}{duration}",
            check_glyph(check.status),
            status_word(check.status),
            truncate(&check.name, 44),
            check.workflow
        ));
    }
    out.line(&format!("\n{}", check_summary(checks)));
    out.finish()
}

pub(crate) fn check_summary(checks: &[PullRequestCheck]) -> String {
    let mut passed = 0;
    let mut pending = 0;
    let mut failed = 0;
    for check in checks {
        match check.status {
            PullRequestCheckStatus::Passed => passed += 1,
            PullRequestCheckStatus::Pending => pending += 1,
            PullRequestCheckStatus::Failed => failed += 1,
            _ => {}
        }
    }
    format!("{passed} passed, {pending} pending, {failed} failed")
}

pub(crate) fn check_log(check: &PullRequestCheck, log: &CheckRunLog) -> String {
    let mut out = Report::default();
    out.line(&format!(
        "{}  {}  ({} · {})",
        check_glyph(check.status),
        check.name,
        check.workflow,
        check.state
    ));
    if !check.link.is_empty() {
        out.line(&check.link);
    }
    if let Some(reason) = &log.unavailable {
        out.line(&format!("\n{reason}"));
        return out.finish();
    }
    if log.log_pending {
        out.line("\nWaiting for the runner to write its first output");
    }
    let now = unix_now();
    for step in &log.steps {
        out.line(&format!("\n{}", step_heading(step, now)));
        for line in &step.lines {
            out.line(&format!("  {}", line.text));
        }
    }
    if !log.loose_lines.is_empty() {
        out.line("\nRunner output");
        for line in &log.loose_lines {
            out.line(&format!("  {}", line.text));
        }
    }
    if log.truncated {
        out.line("\n[the log reached Quinjet's size cap and was truncated]");
    }
    out.finish()
}

pub(crate) fn conversation(conversation: &PullRequestConversation) -> String {
    let mut out = Report::default();
    for entry in &conversation.entries {
        let detail = if entry.detail.is_empty() {
            String::new()
        } else {
            format!(" {}", entry.detail)
        };
        out.line(&format!(
            "\n@{} {}{detail}  ({})",
            entry.actor,
            conversation_action(entry.kind),
            format_local_timestamp(&entry.timestamp)
        ));
        if !entry.context.is_empty() {
            for line in entry.context.lines() {
                out.line(&format!("  | {line}"));
            }
        }
        if entry.kind.has_body() && !entry.body.trim().is_empty() {
            for line in entry.body.trim_end().lines() {
                out.line(&format!("  {line}"));
            }
        }
    }
    if conversation.truncated {
        out.line("\n[the conversation reached Quinjet's entry cap and older entries were dropped]");
    }
    out.finish()
}

fn step_heading(step: &CheckStep, now: i64) -> String {
    let duration = step.duration_label(now);
    let duration = if duration.is_empty() {
        String::new()
    } else {
        format!("  {duration}")
    };
    format!(
        "{}  {}. {}{duration}",
        check_glyph(step.status),
        step.number,
        step.name
    )
}

pub(crate) const fn check_glyph(status: PullRequestCheckStatus) -> &'static str {
    match status {
        PullRequestCheckStatus::Passed => "+",
        PullRequestCheckStatus::Failed => "x",
        PullRequestCheckStatus::Pending => "o",
        PullRequestCheckStatus::Skipped => "-",
        PullRequestCheckStatus::Cancelled => "/",
        PullRequestCheckStatus::Unknown => "?",
    }
}

pub(crate) const fn status_word(status: PullRequestCheckStatus) -> &'static str {
    match status {
        PullRequestCheckStatus::Passed => "passed",
        PullRequestCheckStatus::Failed => "failed",
        PullRequestCheckStatus::Pending => "pending",
        PullRequestCheckStatus::Skipped => "skipped",
        PullRequestCheckStatus::Cancelled => "cancelled",
        PullRequestCheckStatus::Unknown => "unknown",
    }
}

pub(crate) const fn pull_request_file_label(status: PullRequestFileStatus) -> &'static str {
    match status {
        PullRequestFileStatus::Added => "added",
        PullRequestFileStatus::Modified => "modified",
        PullRequestFileStatus::Deleted => "deleted",
        PullRequestFileStatus::Renamed => "renamed",
        PullRequestFileStatus::Copied => "copied",
        PullRequestFileStatus::TypeChanged => "type changed",
        PullRequestFileStatus::Unmerged => "unmerged",
        PullRequestFileStatus::Unknown => "",
    }
}

pub(crate) const fn file_status_code(status: PullRequestFileStatus) -> &'static str {
    match status {
        PullRequestFileStatus::Added => "A",
        PullRequestFileStatus::Modified => "M",
        PullRequestFileStatus::Deleted => "D",
        PullRequestFileStatus::Renamed => "R",
        PullRequestFileStatus::Copied => "C",
        PullRequestFileStatus::TypeChanged => "T",
        PullRequestFileStatus::Unmerged => "U",
        PullRequestFileStatus::Unknown => "?",
    }
}

pub(crate) const fn conversation_action(kind: ConversationKind) -> &'static str {
    match kind {
        ConversationKind::Opened => "opened this",
        ConversationKind::Comment => "commented",
        ConversationKind::Review => "reviewed",
        ConversationKind::ReviewComment => "commented on",
        ConversationKind::Commit => "pushed",
        ConversationKind::ForcePush => "force-pushed",
        ConversationKind::Merged => "merged this",
        ConversationKind::Closed => "closed this",
        ConversationKind::Reopened => "reopened this",
        ConversationKind::Labeled => "added the label",
        ConversationKind::Unlabeled => "removed the label",
        ConversationKind::Renamed => "renamed this from",
        ConversationKind::ReadyForReview => "marked this ready for review",
        ConversationKind::ConvertedToDraft => "converted this to a draft",
        ConversationKind::ReviewRequested => "requested a review from",
        ConversationKind::ReviewRequestRemoved => "cancelled the review request for",
        ConversationKind::Assigned => "assigned",
        ConversationKind::Unassigned => "unassigned",
        ConversationKind::CrossReferenced => "referenced this in",
        ConversationKind::HeadRefDeleted => "deleted the head branch",
        ConversationKind::HeadRefRestored => "restored the head branch",
        ConversationKind::BaseRefChanged => "changed the base branch",
        ConversationKind::Other => "acted on this",
    }
}