17.7 KiB · text History df92b0a
//! Pull requests: open, list, read, edit, close, label, comment, review
//! and merge.
//!
//! Opening, editing, closing, reopening and labelling go through the
//! same server-side paths as the web page, so they fire the same
//! `pull_request` workflows (`opened`, `edited`, `closed`, `reopened`,
//! `labeled`, `unlabeled`) and follow the same rules: anyone who can read
//! the repository may open one; its author or a repository admin may
//! change it.
//!
//! 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, anyhow};

use crate::api::models::{PullDetailResponse, Run};
use crate::api::{ApiFailure, NewPull, RepoRef, Res};
use crate::cli::PullCommand;
use crate::commands::{Ctx, current_branch, label, 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::Create {
            head,
            base,
            head_repo,
            title,
            body,
        } => {
            let head = match head {
                Some(head) => head.clone(),
                None => current_branch().ok_or_else(|| {
                    anyhow!("no branch checked out here to use as the head — pass --head BRANCH")
                })?,
            };
            let text = read_body(body.body.as_deref(), body.body_file.as_deref(), false)?;
            let new = NewPull {
                head: &head,
                base: base.as_deref(),
                head_repo: head_repo.as_deref(),
                title: title.trim(),
                body: text.as_deref().map(str::trim).filter(|t| !t.is_empty()),
            };
            let res = ctx
                .api
                .create_pull(&repo, &new)
                .await
                .map_err(|e| unpushed_hint(e, &head))?;
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }
            let pull = &res.pull;
            println!(
                "{} pull request #{}: {} ({})",
                paint("Opened", Tone::Good),
                pull.number,
                pull.title.as_deref().unwrap_or("(untitled)"),
                branches(pull.source_ref.as_deref(), pull.target_ref.as_deref())
            );
            println!(
                "{}",
                ctx.repo_web_url(&repo, &format!("/pulls/{}", pull.number))
            );
            Ok(())
        }

        PullCommand::Edit {
            number,
            title,
            body,
            body_file,
        } => {
            // Only an explicit --body or --body-file: unlike a comment,
            // piped stdin isn't taken as a new description, so a script
            // renaming a pull request can't wipe its description by
            // accident.
            let text = match (body, body_file) {
                (None, None) => None,
                _ => read_body(body.as_deref(), body_file.as_deref(), true)?,
            };
            let res = ctx
                .api
                .edit_pull(&repo, *number, title.as_deref(), text.as_deref())
                .await?;
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }
            println!(
                "Updated pull request #{number}: {}.",
                res.pull.title.as_deref().unwrap_or("(untitled)")
            );
            Ok(())
        }

        PullCommand::Close { number } => set_state(ctx, &repo, *number, "closed").await,
        PullCommand::Reopen { number } => set_state(ctx, &repo, *number, "open").await,

        PullCommand::Label {
            number,
            add,
            remove,
        } => {
            // One request per label; each answers with the whole pull
            // request, so the last one says where it ended up.
            let mut last: Option<Res<PullDetailResponse>> = None;
            for name in add {
                last = Some(ctx.api.add_pull_label(&repo, *number, name).await?);
            }
            for name in remove {
                last = Some(ctx.api.remove_pull_label(&repo, *number, name).await?);
            }
            let res = last.expect("clap requires --add or --remove");
            if ctx.emitted(&res.raw)? {
                return Ok(());
            }
            if res.pull.labels.is_empty() {
                println!("Pull request #{number} has no labels now.");
            } else {
                println!(
                    "Pull request #{number} is labelled {}.",
                    labels(&res.pull.labels)
                );
            }
            Ok(())
        }

        PullCommand::Labels => label::list(ctx, &repo).await,

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

async fn set_state(ctx: &Ctx, repo: &RepoRef, number: i64, state: &str) -> Result<()> {
    let res = ctx.api.set_pull_state(repo, number, state).await?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }
    println!(
        "Pull request #{number} is now {}.",
        state_cell(&res.pull.state)
    );
    Ok(())
}

/// The server can only see branches that have been pushed; say so when
/// the head it couldn't find is the one taken from the checkout.
fn unpushed_hint(error: anyhow::Error, head: &str) -> anyhow::Error {
    let missing_head = ApiFailure::of(&error).is_some_and(|f| {
        f.status.as_u16() == 422
            && f.message
                .as_deref()
                .is_some_and(|m| m.starts_with("head: no branch"))
    });
    if missing_head {
        error.context(format!(
            "is {head} pushed? Try `git push -u origin {head}` first"
        ))
    } else {
        error
    }
}

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