12.5 KiB · text History d1406da
//! 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.
//!
//! So is approving (or rejecting) a fork's workflow runs: they wait,
//! without secrets, until someone with write access says the code may
//! run.

use anyhow::Result;

use crate::api::models::Run;
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,
    status_badge, 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(", "));
            }

            // A fork's runs are held until someone with write access lets
            // them run the fork's code — worth saying before the blockers,
            // since a required check waiting on them is often one.
            if !res.awaiting_approval_runs.is_empty() {
                println!(
                    "\n{}",
                    bold("Workflows from this fork are waiting for approval")
                );
                for run in &res.awaiting_approval_runs {
                    println!(
                        "  {} #{} {}",
                        status_badge(run.display_status()),
                        run.number,
                        run.workflow_label()
                    );
                }
                if res.can_approve_runs {
                    note(&format!(
                        "  They run the fork's code, without this repository's secrets. \
                         Approve with `gg pull approve-runs {n}`, or `gg pull reject-runs {n}`.",
                        n = pull.number
                    ));
                } else {
                    note("  Someone with write access to this repository has to approve them.");
                }
            }

            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(())
        }

        PullCommand::ApproveRuns { number } => {
            let res = ctx.api.approve_pull_runs(&repo, *number).await?;
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }
            report_runs(&res.runs, "Approved", Tone::Good, *number);
            Ok(())
        }

        PullCommand::RejectRuns { number } => {
            let res = ctx.api.reject_pull_runs(&repo, *number).await?;
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }
            report_runs(&res.runs, "Cancelled", Tone::Busy, *number);
            Ok(())
        }
    }
}

/// What approving or rejecting touched — which may be nothing, when no
/// run was waiting.
fn report_runs(runs: &[Run], verb: &str, tone: Tone, number: i64) {
    if runs.is_empty() {
        note(&format!(
            "No workflow runs were waiting for approval on pull request #{number}."
        ));
        return;
    }
    println!(
        "{} {} workflow run{} on pull request #{number}:",
        paint(verb, tone),
        runs.len(),
        if runs.len() == 1 { "" } else { "s" }
    );
    for run in runs {
        println!(
            "  #{} {} — {}",
            run.number,
            run.workflow_label(),
            status_badge(run.display_status())
        );
    }
}

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))
}