//! 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))
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
17.7 KiB · text
History
df92b0a