9.9 KiB · text History 1294137
//! Pull requests: list, read, comment, review and merge.
//!
//! Merging is included for the same reason the phone has it — it's the PR
//! action you most often want without opening a browser. It enforces the
//! repository's review rules; `--override-rules` is accepted from repo
//! admins and recorded as an override, exactly as the web UI does it.

use anyhow::Result;

use crate::cli::PullCommand;
use crate::commands::{Ctx, read_body};
use crate::output::{
    Table, Tone, ago, author_of, bold, heading, indented, labels, note, paint, state_cell, truncate,
};

pub async fn run(ctx: &Ctx, command: &PullCommand) -> Result<()> {
    let repo = ctx.repo()?;

    match command {
        PullCommand::List {
            state,
            search,
            limit,
        } => {
            let res = ctx
                .api
                .pulls(&repo, Some(state.as_param()), search.as_deref(), *limit)
                .await?;
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }
            if res.pulls.is_empty() {
                let qualifier = if state.as_param() == "all" {
                    String::new()
                } else {
                    format!("{} ", state.as_param())
                };
                note(&format!("No {qualifier}pull requests in {repo}."));
                return Ok(());
            }

            let mut table = Table::new(["#", "STATE", "TITLE", "BRANCH", "AUTHOR", "UPDATED"]);
            for pull in &res.pulls {
                table.row([
                    format!("#{}", pull.number),
                    state_cell(&pull.state),
                    truncate(pull.title.as_deref().unwrap_or("(untitled)"), 48),
                    truncate(
                        &branches(pull.source_ref.as_deref(), pull.target_ref.as_deref()),
                        30,
                    ),
                    author_of(pull.author.as_ref()),
                    ago(pull.updated_at.as_deref().or(pull.inserted_at.as_deref())),
                ]);
            }
            table.print();
            Ok(())
        }

        PullCommand::Show {
            number,
            no_comments,
        } => {
            let res = ctx.api.pull(&repo, *number).await?;
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }

            let pull = &res.pull;
            heading(&format!(
                "#{} {}",
                pull.number,
                pull.title.as_deref().unwrap_or("(untitled)")
            ));
            println!(
                "{} · {} · by {} {}",
                state_cell(&pull.state),
                branches(pull.source_ref.as_deref(), pull.target_ref.as_deref()),
                author_of(pull.author.as_ref()),
                ago(pull.inserted_at.as_deref())
            );
            if !pull.labels.is_empty() {
                println!("labels: {}", labels(&pull.labels));
            }
            if let Some(merged_at) = &pull.merged_at {
                println!(
                    "merged by {} {}",
                    author_of(pull.merged_by.as_ref()),
                    ago(Some(merged_at))
                );
            }

            if let Some(body) = pull.body.as_deref().filter(|b| !b.trim().is_empty()) {
                println!();
                indented(body, "  ");
            }

            if !res.reviews.is_empty() {
                println!("\n{}", bold("Reviews"));
                for review in &res.reviews {
                    let state = review.state.as_deref().unwrap_or("commented");
                    let tone = match state {
                        "approved" => Tone::Good,
                        "changes_requested" => Tone::Bad,
                        _ => Tone::Muted,
                    };
                    println!(
                        "  {} {} {}",
                        paint(state, tone),
                        author_of(review.reviewer.as_ref()),
                        ago(review.inserted_at.as_deref())
                    );
                    if let Some(body) = review.body.as_deref().filter(|b| !b.trim().is_empty()) {
                        indented(body, "      ");
                    }
                }
            }

            if !res.review_requests.is_empty() {
                let waiting: Vec<String> = res
                    .review_requests
                    .iter()
                    .map(|r| author_of(r.reviewer.as_ref()))
                    .collect();
                println!("\nawaiting review from {}", waiting.join(", "));
            }

            if !res.merge_blockers.is_empty() {
                println!("\n{}", bold("Merge blockers"));
                for blocker in &res.merge_blockers {
                    println!("  {} {blocker}", paint("✘", Tone::Bad));
                }
            } else if pull.state == "open" {
                let mergeable = match pull.mergeable {
                    Some(true) => paint("no conflicts", Tone::Good),
                    Some(false) => paint("conflicts with the target branch", Tone::Bad),
                    None => paint("mergeability not computed yet", Tone::Muted),
                };
                println!("\n{mergeable}");
            }

            if !no_comments {
                if !res.comments.is_empty() {
                    println!("\n{}", bold(&format!("{} comment(s)", res.comments.len())));
                    for comment in &res.comments {
                        println!();
                        println!(
                            "{} {}",
                            bold(&author_of(comment.author.as_ref())),
                            ago(comment.inserted_at.as_deref())
                        );
                        let body = if comment.deleted {
                            "(deleted)"
                        } else {
                            comment.body.as_deref().unwrap_or("")
                        };
                        indented(body, "  ");
                    }
                }

                // Line comments only make sense next to the file and line
                // they're pinned to, so they're listed apart from the
                // discussion rather than interleaved into it.
                if !res.line_comments.is_empty() {
                    println!(
                        "\n{}",
                        bold(&format!("{} line comment(s)", res.line_comments.len()))
                    );
                    for comment in &res.line_comments {
                        println!();
                        println!(
                            "{} {}:{} — {}",
                            bold(&author_of(comment.author.as_ref())),
                            comment.file_path.as_deref().unwrap_or("?"),
                            comment
                                .line
                                .map(|l| l.to_string())
                                .unwrap_or_else(|| "?".into()),
                            ago(comment.inserted_at.as_deref())
                        );
                        indented(comment.body.as_deref().unwrap_or(""), "  ");
                    }
                }
            }

            note(&format!(
                "\n{}",
                ctx.repo_web_url(&repo, &format!("/pulls/{}", pull.number))
            ));
            let mut cant: Vec<&str> = Vec::new();
            if !res.can_comment {
                cant.push("comment");
            }
            if !res.can_review {
                cant.push("review");
            }
            if !res.can_merge && pull.state == "open" {
                cant.push("merge");
            }
            if !cant.is_empty() {
                note(&format!("(you can't {} this one)", cant.join(" or ")));
            }
            Ok(())
        }

        PullCommand::Comment { number, body } => {
            let text = read_body(body.body.as_deref(), body.body_file.as_deref(), true)?
                .expect("required body");
            let res = ctx.api.comment_on_pull(&repo, *number, text.trim()).await?;
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }
            println!("Commented on pull request #{number}.");
            Ok(())
        }

        PullCommand::Review {
            number,
            state,
            body,
        } => {
            let text = read_body(body.body.as_deref(), body.body_file.as_deref(), false)?;
            let res = ctx
                .api
                .review(
                    &repo,
                    *number,
                    state.as_param(),
                    text.as_deref().map(str::trim).filter(|t| !t.is_empty()),
                )
                .await?;
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }
            println!(
                "Review recorded on #{number}: {}.",
                state.as_param().replace('_', " ")
            );
            Ok(())
        }

        PullCommand::Merge {
            number,
            message,
            override_rules,
        } => {
            let res = ctx
                .api
                .merge(&repo, *number, message.as_deref(), *override_rules)
                .await?;
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }
            println!(
                "{} pull request #{number}{}.",
                paint("Merged", Tone::Good),
                if *override_rules {
                    " (review rules overridden)"
                } else {
                    ""
                }
            );
            Ok(())
        }
    }
}

fn branches(source: Option<&str>, target: Option<&str>) -> String {
    let short = |r: Option<&str>| {
        r.map(|r| {
            r.strip_prefix("refs/heads/")
                .or_else(|| r.strip_prefix("refs/tags/"))
                .unwrap_or(r)
                .to_string()
        })
        .unwrap_or_else(|| "?".into())
    };
    format!("{} → {}", short(source), short(target))
}