forgejo-cli/src/prs.rs
Pi-Cla a979a34b78 Clippy Fixes
There are still some clippy warnings left, but this covers most of them
2024-08-11 01:33:06 -06:00

1564 lines
49 KiB
Rust

use std::str::FromStr;
use clap::{Args, Subcommand};
use eyre::{Context, OptionExt};
use forgejo_api::{
structs::{
CreatePullRequestOption, MergePullRequestOption, RepoGetPullRequestCommitsQuery,
RepoGetPullRequestFilesQuery, StateType,
},
Forgejo,
};
use crate::{
issues::IssueId,
repo::{RepoArg, RepoInfo, RepoName},
SpecialRender,
};
#[derive(Args, Clone, Debug)]
pub struct PrCommand {
/// The local git remote that points to the repo to operate on.
#[clap(long, short = 'R')]
remote: Option<String>,
#[clap(subcommand)]
command: PrSubcommand,
}
#[derive(Subcommand, Clone, Debug)]
pub enum PrSubcommand {
/// Search a repository's pull requests
Search {
query: Option<String>,
#[clap(long, short)]
labels: Option<String>,
#[clap(long, short)]
creator: Option<String>,
#[clap(long, short)]
assignee: Option<String>,
#[clap(long, short)]
state: Option<crate::issues::State>,
/// The repo to search in
#[clap(long, short)]
repo: Option<RepoArg>,
},
/// Create a new pull request
Create {
/// The branch to merge onto.
#[clap(long)]
base: Option<String>,
/// The branch to pull changes from.
#[clap(long)]
head: Option<String>,
/// What to name the new pull request.
///
/// Prefix with "WIP: " to mark this PR as a draft.
title: String,
/// The text body of the pull request.
///
/// Leaving this out will open your editor.
#[clap(long)]
body: Option<String>,
/// The repo to create this issue on
#[clap(long, short, id = "[HOST/]OWNER/REPO")]
repo: Option<RepoArg>,
},
/// View the contents of a pull request
View {
/// The pull request to view.
#[clap(id = "[REPO#]ID")]
id: Option<IssueId>,
#[clap(subcommand)]
command: Option<ViewCommand>,
},
/// View the mergability and CI status of a pull request
Status {
/// The pull request to view.
#[clap(id = "[REPO#]ID")]
id: Option<IssueId>,
},
/// Checkout a pull request in a new branch
Checkout {
/// The pull request to check out.
///
/// Prefix with ^ to get a pull request from the parent repo.
#[clap(id = "ID")]
pr: PrNumber,
/// The name to give the newly created branch.
///
/// Defaults to naming after the host url, repo owner, and PR number.
#[clap(long, id = "NAME")]
branch_name: Option<String>,
},
/// Add a comment on a pull request
Comment {
/// The pull request to comment on.
#[clap(id = "[REPO#]ID")]
pr: Option<IssueId>,
/// The text content of the comment.
///
/// Not including this in the command will open your editor.
body: Option<String>,
},
/// Edit the contents of a pull request
Edit {
/// The pull request to edit.
#[clap(id = "[REPO#]ID")]
pr: Option<IssueId>,
#[clap(subcommand)]
command: EditCommand,
},
/// Close a pull request, without merging.
Close {
/// The pull request to close.
#[clap(id = "[REPO#]ID")]
pr: Option<IssueId>,
/// A comment to add before closing.
///
/// Adding without an argument will open your editor
#[clap(long, short)]
with_msg: Option<Option<String>>,
},
/// Merge a pull request
Merge {
/// The pull request to merge.
#[clap(id = "[REPO#]ID")]
pr: Option<IssueId>,
/// The merge style to use.
#[clap(long, short = 'M')]
method: Option<MergeMethod>,
/// Option to delete the corresponding branch afterwards.
#[clap(long, short)]
delete: bool,
/// The title of the merge or squash commit to be created
#[clap(long, short)]
title: Option<String>,
/// The body of the merge or squash commit to be created
#[clap(long, short)]
message: Option<Option<String>>,
},
/// Open a pull request in your browser
Browse {
/// The pull request to open in your browser.
#[clap(id = "[REPO#]ID")]
id: Option<IssueId>,
},
}
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum MergeMethod {
Merge,
Rebase,
RebaseMerge,
Squash,
Manual,
}
#[derive(Clone, Copy, Debug)]
pub enum PrNumber {
This(u64),
Parent(u64),
}
impl PrNumber {
fn number(self) -> u64 {
match self {
PrNumber::This(x) => x,
PrNumber::Parent(x) => x,
}
}
}
impl FromStr for PrNumber {
type Err = std::num::ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(num) = s.strip_prefix("^") {
Ok(Self::Parent(num.parse()?))
} else {
Ok(Self::This(s.parse()?))
}
}
}
impl From<MergeMethod> for forgejo_api::structs::MergePullRequestOptionDo {
fn from(value: MergeMethod) -> Self {
use forgejo_api::structs::MergePullRequestOptionDo::*;
match value {
MergeMethod::Merge => Merge,
MergeMethod::Rebase => Rebase,
MergeMethod::RebaseMerge => RebaseMerge,
MergeMethod::Squash => Squash,
MergeMethod::Manual => ManuallyMerged,
}
}
}
#[derive(Subcommand, Clone, Debug)]
pub enum EditCommand {
/// Edit the title
Title {
/// New PR title.
///
/// Leaving this out will open the current title in your editor.
new_title: Option<String>,
},
/// Edit the text body
Body {
/// New PR body.
///
/// Leaving this out will open the current body in your editor.
new_body: Option<String>,
},
/// Edit a comment
Comment {
/// The index of the comment to edit, 0-indexed.
idx: usize,
/// New comment body.
///
/// Leaving this out will open the current body in your editor.
new_body: Option<String>,
},
Labels {
/// The labels to add.
#[clap(long, short)]
add: Vec<String>,
/// The labels to remove.
#[clap(long, short)]
rm: Vec<String>,
},
}
#[derive(Subcommand, Clone, Debug)]
pub enum ViewCommand {
/// View the title and body of a pull request.
Body,
/// View a comment on a pull request.
Comment {
/// The index of the comment to view, 0-indexed.
idx: usize,
},
/// View all comments on a pull request.
Comments,
/// View the labels applied to a pull request.
Labels,
/// View the diff between the base and head branches of a pull request.
Diff {
/// Get the diff in patch format
#[clap(long, short)]
patch: bool,
/// View the diff in your text editor
#[clap(long, short)]
editor: bool,
},
/// View the files changed in a pull request.
Files,
/// View the commits in a pull request.
Commits {
/// View one commit per line
#[clap(long, short)]
oneline: bool,
},
}
impl PrCommand {
pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
use PrSubcommand::*;
let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref())?;
let api = keys.get_api(repo.host_url()).await?;
let repo = repo.name().ok_or_else(|| self.no_repo_error())?;
match self.command {
Create {
title,
base,
head,
body,
repo: _,
} => create_pr(repo, &api, title, base, head, body).await?,
Merge {
pr,
method,
delete,
title,
message,
} => {
merge_pr(
repo,
&api,
pr.map(|id| id.number),
method,
delete,
title,
message,
)
.await?
}
View { id, command } => {
let id = id.map(|id| id.number);
match command.unwrap_or(ViewCommand::Body) {
ViewCommand::Body => view_pr(repo, &api, id).await?,
ViewCommand::Comment { idx } => {
let (repo, id) = try_get_pr_number(repo, &api, id).await?;
crate::issues::view_comment(&repo, &api, id, idx).await?
}
ViewCommand::Comments => {
let (repo, id) = try_get_pr_number(repo, &api, id).await?;
crate::issues::view_comments(&repo, &api, id).await?
}
ViewCommand::Labels => view_pr_labels(repo, &api, id).await?,
ViewCommand::Diff { patch, editor } => {
view_diff(repo, &api, id, patch, editor).await?
}
ViewCommand::Files => view_pr_files(repo, &api, id).await?,
ViewCommand::Commits { oneline } => {
view_pr_commits(repo, &api, id, oneline).await?
}
}
}
Status { id } => view_pr_status(repo, &api, id.map(|id| id.number)).await?,
Search {
query,
labels,
creator,
assignee,
state,
repo: _,
} => view_prs(repo, &api, query, labels, creator, assignee, state).await?,
Edit { pr, command } => {
let pr = pr.map(|pr| pr.number);
match command {
EditCommand::Title { new_title } => {
let (repo, id) = try_get_pr_number(repo, &api, pr).await?;
crate::issues::edit_title(&repo, &api, id, new_title).await?
}
EditCommand::Body { new_body } => {
let (repo, id) = try_get_pr_number(repo, &api, pr).await?;
crate::issues::edit_body(&repo, &api, id, new_body).await?
}
EditCommand::Comment { idx, new_body } => {
let (repo, id) = try_get_pr_number(repo, &api, pr).await?;
crate::issues::edit_comment(&repo, &api, id, idx, new_body).await?
}
EditCommand::Labels { add, rm } => {
edit_pr_labels(repo, &api, pr, add, rm).await?
}
}
}
Close { pr, with_msg } => {
let (repo, pr) = try_get_pr_number(repo, &api, pr.map(|pr| pr.number)).await?;
crate::issues::close_issue(&repo, &api, pr, with_msg).await?
}
Checkout { pr, branch_name } => checkout_pr(repo, &api, pr, branch_name).await?,
Browse { id } => {
let (repo, id) = try_get_pr_number(repo, &api, id.map(|pr| pr.number)).await?;
browse_pr(&repo, &api, id).await?
}
Comment { pr, body } => {
let (repo, pr) = try_get_pr_number(repo, &api, pr.map(|pr| pr.number)).await?;
crate::issues::add_comment(&repo, &api, pr, body).await?
}
}
Ok(())
}
fn repo(&self) -> Option<&RepoArg> {
use PrSubcommand::*;
match &self.command {
Search { repo, .. } | Create { repo, .. } => repo.as_ref(),
Checkout { .. } => None,
View { id: pr, .. }
| Status { id: pr, .. }
| Comment { pr, .. }
| Edit { pr, .. }
| Close { pr, .. }
| Merge { pr, .. }
| Browse { id: pr } => pr.as_ref().and_then(|x| x.repo.as_ref()),
}
}
fn no_repo_error(&self) -> eyre::Error {
use PrSubcommand::*;
match &self.command {
Search { .. } | Create { .. } => {
eyre::eyre!("can't figure what repo to access, try specifying with `--repo`")
}
Checkout { .. } => {
if git2::Repository::open(".").is_ok() {
eyre::eyre!("can't figure out what repo to access, try setting a remote tracking branch")
} else {
eyre::eyre!("pr checkout only works if the current directory is a git repo")
}
}
View { id: pr, .. }
| Status { id: pr, .. }
| Comment { pr, .. }
| Edit { pr, .. }
| Close { pr, .. }
| Merge { pr, .. }
| Browse { id: pr, .. } => match pr {
Some(pr) => eyre::eyre!(
"can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{}`",
pr.number
),
None => eyre::eyre!(
"can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{{pr}}`",
),
},
}
}
}
pub async fn view_pr(repo: &RepoName, api: &Forgejo, id: Option<u64>) -> eyre::Result<()> {
let crate::SpecialRender {
dash,
bright_red,
bright_green,
bright_magenta,
yellow,
dark_grey,
light_grey,
white,
reset,
..
} = crate::special_render();
let pr = try_get_pr(repo, api, id).await?;
let id = pr.number.ok_or_eyre("pr does not have number")? as u64;
let repo = repo_name_from_pr(&pr)?;
let mut additions = 0;
let mut deletions = 0;
let query = RepoGetPullRequestFilesQuery {
limit: Some(u32::MAX),
..Default::default()
};
let (_, files) = api
.repo_get_pull_request_files(repo.owner(), repo.name(), id, query)
.await?;
for file in files {
additions += file.additions.unwrap_or_default();
deletions += file.deletions.unwrap_or_default();
}
let title = pr
.title
.as_deref()
.ok_or_else(|| eyre::eyre!("pr does not have title"))?;
let title_no_wip = title
.strip_prefix("WIP: ")
.or_else(|| title.strip_prefix("WIP:"));
let (title, is_draft) = match title_no_wip {
Some(title) => (title, true),
None => (title, false),
};
let state = pr
.state
.ok_or_else(|| eyre::eyre!("pr does not have state"))?;
let is_merged = pr.merged.unwrap_or_default();
let state = match state {
StateType::Open if is_draft => format!("{light_grey}Draft{reset}"),
StateType::Open => format!("{bright_green}Open{reset}"),
StateType::Closed if is_merged => format!("{bright_magenta}Merged{reset}"),
StateType::Closed => format!("{bright_red}Closed{reset}"),
};
let base = pr.base.as_ref().ok_or_eyre("pr does not have base")?;
let base_repo = base
.repo
.as_ref()
.ok_or_eyre("base does not have repo")?
.full_name
.as_deref()
.ok_or_eyre("base repo does not have name")?;
let base_name = base
.label
.as_deref()
.ok_or_eyre("base does not have label")?;
let head = pr.head.as_ref().ok_or_eyre("pr does not have head")?;
let head_repo = head
.repo
.as_ref()
.ok_or_eyre("head does not have repo")?
.full_name
.as_deref()
.ok_or_eyre("head repo does not have name")?;
let head_name = head
.label
.as_deref()
.ok_or_eyre("head does not have label")?;
let head_name = if base_repo != head_repo {
format!("{head_repo}:{head_name}")
} else {
head_name.to_owned()
};
let user = pr
.user
.as_ref()
.ok_or_else(|| eyre::eyre!("pr does not have creator"))?;
let username = user
.login
.as_ref()
.ok_or_else(|| eyre::eyre!("user does not have login"))?;
let comments = pr.comments.unwrap_or_default();
println!("{yellow}{title}{reset} {dark_grey}#{id}{reset}");
println!(
"By {white}{username}{reset} {dash} {state} {dash} {bright_green}+{additions} {bright_red}-{deletions}{reset}"
);
println!("From `{head_name}` into `{base_name}`");
if let Some(body) = &pr.body {
if !body.trim().is_empty() {
println!();
println!("{}", crate::markdown(body));
}
}
println!();
if comments == 1 {
println!("1 comment");
} else {
println!("{comments} comments");
}
Ok(())
}
async fn view_pr_labels(repo: &RepoName, api: &Forgejo, pr: Option<u64>) -> eyre::Result<()> {
let pr = try_get_pr(repo, api, pr).await?;
let labels = pr.labels.as_deref().unwrap_or_default();
let SpecialRender {
fancy,
black,
white,
reset,
..
} = *crate::special_render();
if fancy {
let mut total_width = 0;
for label in labels {
let name = label.name.as_deref().unwrap_or("???").trim();
if total_width + name.len() > 40 {
println!();
total_width = 0;
}
let color_s = label.color.as_deref().unwrap_or("FFFFFF");
let (r, g, b) = parse_color(color_s)?;
let text_color = if luma(r, g, b) > 0.5 { black } else { white };
let rgb_bg = format!("\x1b[48;2;{r};{g};{b}m");
if label.exclusive.unwrap_or_default() {
let (r2, g2, b2) = darken(r, g, b);
let (category, name) = name
.split_once("/")
.ok_or_eyre("label is exclusive but does not have slash")?;
let rgb_bg_dark = format!("\x1b[48;2;{r2};{g2};{b2}m");
print!("{rgb_bg_dark}{text_color} {category} {rgb_bg} {name} {reset} ");
} else {
print!("{rgb_bg}{text_color} {name} {reset} ");
}
total_width += name.len();
}
println!();
} else {
for label in labels {
let name = label.name.as_deref().unwrap_or("???");
println!("{name}");
}
}
Ok(())
}
fn parse_color(color: &str) -> eyre::Result<(u8, u8, u8)> {
eyre::ensure!(color.len() == 6, "color string wrong length");
let mut iter = color.chars();
let mut next_digit = || {
iter.next()
.unwrap()
.to_digit(16)
.ok_or_eyre("invalid digit")
};
let r1 = next_digit()?;
let r2 = next_digit()?;
let g1 = next_digit()?;
let g2 = next_digit()?;
let b1 = next_digit()?;
let b2 = next_digit()?;
let r = ((r1 << 4) | (r2)) as u8;
let g = ((g1 << 4) | (g2)) as u8;
let b = ((b1 << 4) | (b2)) as u8;
Ok((r, g, b))
}
// Thanks, wikipedia.
fn luma(r: u8, g: u8, b: u8) -> f32 {
((0.299 * (r as f32)) + (0.578 * (g as f32)) + (0.114 * (b as f32))) / 255.0
}
fn darken(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
(
((r as f32) * 0.85) as u8,
((g as f32) * 0.85) as u8,
((b as f32) * 0.85) as u8,
)
}
async fn view_pr_status(repo: &RepoName, api: &Forgejo, id: Option<u64>) -> eyre::Result<()> {
let pr = try_get_pr(repo, api, id).await?;
let repo = repo_name_from_pr(&pr)?;
let SpecialRender {
bright_magenta,
bright_red,
bright_green,
yellow,
light_grey,
dash,
bullet,
reset,
..
} = *crate::special_render();
if pr.merged.ok_or_eyre("pr merge status unknown")? {
let merged_by = pr.merged_by.ok_or_eyre("pr not merged by anyone")?;
let merged_by = merged_by
.login
.as_deref()
.ok_or_eyre("pr merger does not have login")?;
let merged_at = pr.merged_at.ok_or_eyre("pr does not have merge date")?;
let date_format = time::macros::format_description!(
"on [month repr:long] [day], [year], at [hour repr:12]:[minute] [period]"
);
let tz_format = time::macros::format_description!(
"[offset_hour padding:zero sign:mandatory]:[offset_minute]"
);
let (merged_at, show_tz) = if let Ok(local_offset) = time::UtcOffset::current_local_offset()
{
let merged_at = merged_at.to_offset(local_offset);
(merged_at, false)
} else {
(merged_at, true)
};
print!(
"{bright_magenta}Merged{reset} by {merged_by} {}",
merged_at.format(date_format)?
);
if show_tz {
print!("{}", merged_at.format(tz_format)?);
}
println!();
} else {
let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
let query = forgejo_api::structs::RepoGetPullRequestCommitsQuery {
page: None,
limit: Some(u32::MAX),
verification: Some(false),
files: Some(false),
};
let (_commit_headers, commits) = api
.repo_get_pull_request_commits(repo.owner(), repo.name(), pr_number, query)
.await?;
let latest_commit = commits
.iter()
.max_by_key(|x| x.created)
.ok_or_eyre("no commits in pr")?;
let sha = latest_commit
.sha
.as_deref()
.ok_or_eyre("commit does not have sha")?;
let query = forgejo_api::structs::RepoGetCombinedStatusByRefQuery {
page: None,
limit: Some(u32::MAX),
};
let combined_status = api
.repo_get_combined_status_by_ref(repo.owner(), repo.name(), sha, query)
.await?;
let state = pr.state.ok_or_eyre("pr does not have state")?;
let is_draft = pr.title.as_deref().is_some_and(|s| s.starts_with("WIP:"));
match state {
StateType::Open => {
if is_draft {
println!("{light_grey}Draft{reset} {dash} Can't merge draft PR")
} else {
print!("{bright_green}Open{reset} {dash} ");
let mergable = pr.mergeable.ok_or_eyre("pr does not have mergable")?;
if mergable {
println!("Can be merged");
} else {
println!("{bright_red}Merge conflicts{reset}");
}
}
}
StateType::Closed => println!("{bright_red}Closed{reset} {dash} Reopen to merge"),
}
let commit_statuses = combined_status
.statuses
.ok_or_eyre("combined status does not have status list")?;
for status in commit_statuses {
let state = status
.status
.as_deref()
.ok_or_eyre("status does not have status")?;
let context = status
.context
.as_deref()
.ok_or_eyre("status does not have context")?;
print!("{bullet} ");
match state {
"success" => print!("{bright_green}Success{reset}"),
"pending" => print!("{yellow}Pending{reset}"),
"failure" => print!("{bright_red}Failure{reset}"),
_ => eyre::bail!("invalid status"),
};
println!(" {dash} {context}");
}
}
Ok(())
}
async fn edit_pr_labels(
repo: &RepoName,
api: &Forgejo,
pr: Option<u64>,
add: Vec<String>,
rm: Vec<String>,
) -> eyre::Result<()> {
let pr = try_get_pr(repo, api, pr).await?;
let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
let repo = repo_name_from_pr(&pr)?;
let query = forgejo_api::structs::IssueListLabelsQuery {
limit: Some(u32::MAX),
..Default::default()
};
let mut labels = api
.issue_list_labels(repo.owner(), repo.name(), query)
.await?;
let query = forgejo_api::structs::OrgListLabelsQuery {
limit: Some(u32::MAX),
..Default::default()
};
let org_labels = api
.org_list_labels(repo.owner(), query)
.await
.unwrap_or_default();
labels.extend(org_labels);
let mut unknown_labels = Vec::new();
let mut add_ids = Vec::with_capacity(add.len());
for label_name in &add {
let maybe_label = labels
.iter()
.find(|label| label.name.as_ref() == Some(label_name));
if let Some(label) = maybe_label {
add_ids.push(serde_json::Value::Number(
label.id.ok_or_eyre("label does not have id")?.into(),
));
} else {
unknown_labels.push(label_name);
}
}
let mut rm_ids = Vec::with_capacity(add.len());
for label_name in &rm {
let maybe_label = labels
.iter()
.find(|label| label.name.as_ref() == Some(label_name));
if let Some(label) = maybe_label {
rm_ids.push(label.id.ok_or_eyre("label does not have id")?);
} else {
unknown_labels.push(label_name);
}
}
let opts = forgejo_api::structs::IssueLabelsOption {
labels: Some(add_ids),
updated_at: None,
};
api.issue_add_label(repo.owner(), repo.name(), pr_number, opts)
.await?;
let opts = forgejo_api::structs::DeleteLabelsOption { updated_at: None };
for id in rm_ids {
api.issue_remove_label(
repo.owner(),
repo.name(),
pr_number,
id as u64,
opts.clone(),
)
.await?;
}
if !unknown_labels.is_empty() {
if unknown_labels.len() == 1 {
println!("'{}' doesn't exist", &unknown_labels[0]);
} else {
let SpecialRender { bullet, .. } = *crate::special_render();
println!("The following labels don't exist:");
for unknown_label in unknown_labels {
println!("{bullet} {unknown_label}");
}
}
}
Ok(())
}
async fn create_pr(
repo: &RepoName,
api: &Forgejo,
title: String,
base: Option<String>,
head: Option<String>,
body: Option<String>,
) -> eyre::Result<()> {
let mut repo_data = api.repo_get(repo.owner(), repo.name()).await?;
let head = match head {
Some(head) => head,
None => {
let local_repo = git2::Repository::open(".")?;
let head = local_repo.head()?;
eyre::ensure!(
head.is_branch(),
"HEAD is not on branch, can't guess head branch"
);
let branch_ref = head
.name()
.ok_or_eyre("current branch does not have utf8 name")?;
let upstream_remote = local_repo.branch_upstream_remote(branch_ref)?;
let remote_name = upstream_remote
.as_str()
.ok_or_eyre("remote does not have utf8 name")?;
let remote = local_repo.find_remote(remote_name)?;
let remote_url_s = remote.url().ok_or_eyre("remote does not have utf8 url")?;
let remote_url = url::Url::parse(remote_url_s)?;
let clone_url = repo_data
.clone_url
.as_ref()
.ok_or_eyre("repo does not have git url")?;
let html_url = repo_data
.html_url
.as_ref()
.ok_or_eyre("repo does not have html url")?;
let ssh_url = repo_data
.ssh_url
.as_ref()
.ok_or_eyre("repo does not have ssh url")?;
eyre::ensure!(
&remote_url == clone_url || &remote_url == html_url || &remote_url == ssh_url,
"branch does not track that repo"
);
let upstream_branch = local_repo.branch_upstream_name(branch_ref)?;
let upstream_branch = upstream_branch
.as_str()
.ok_or_eyre("remote branch does not have utf8 name")?;
upstream_branch
.rsplit_once("/")
.map(|(_, b)| b)
.unwrap_or(upstream_branch)
.to_owned()
}
};
let (base, base_is_parent) = match base {
Some(base) => match base.strip_prefix("^") {
Some("") => (None, true),
Some(stripped) => (Some(stripped.to_owned()), true),
None => (Some(base), false),
},
None => (None, false),
};
let (repo_owner, repo_name, base_repo, head) = if base_is_parent {
let parent_repo = *repo_data
.parent
.take()
.ok_or_eyre("cannot create pull request upstream, there is no upstream")?;
let parent_owner = parent_repo
.owner
.as_ref()
.ok_or_eyre("parent has no owner")?
.login
.as_deref()
.ok_or_eyre("parent owner has no login")?
.to_owned();
let parent_name = parent_repo
.name
.as_deref()
.ok_or_eyre("parent has no name")?
.to_owned();
(
parent_owner,
parent_name,
parent_repo,
format!("{}:{}", repo.owner(), head),
)
} else {
(
repo.owner().to_owned(),
repo.name().to_owned(),
repo_data,
head,
)
};
let base = match base {
Some(base) => base,
None => base_repo
.default_branch
.as_deref()
.ok_or_eyre("repo does not have default branch")?
.to_owned(),
};
let body = match body {
Some(body) => body,
None => {
let mut body = String::new();
crate::editor(&mut body, Some("md")).await?;
body
}
};
let pr = api
.repo_create_pull_request(
&repo_owner,
&repo_name,
CreatePullRequestOption {
assignee: None,
assignees: None,
base: Some(base.to_owned()),
body: Some(body),
due_date: None,
head: Some(head),
labels: None,
milestone: None,
title: Some(title),
},
)
.await?;
let number = pr
.number
.ok_or_else(|| eyre::eyre!("pr does not have number"))?;
let title = pr
.title
.as_ref()
.ok_or_else(|| eyre::eyre!("pr does not have title"))?;
println!("created pull request #{}: {}", number, title);
Ok(())
}
async fn merge_pr(
repo: &RepoName,
api: &Forgejo,
pr: Option<u64>,
method: Option<MergeMethod>,
delete: bool,
title: Option<String>,
message: Option<Option<String>>,
) -> eyre::Result<()> {
let repo_info = api.repo_get(repo.owner(), repo.name()).await?;
let pr_info = try_get_pr(repo, api, pr).await?;
let repo = repo_name_from_pr(&pr_info)?;
let pr_html_url = pr_info
.html_url
.as_ref()
.ok_or_eyre("pr does not have url")?;
let default_merge = repo_info
.default_merge_style
.map(|x| x.into())
.unwrap_or(forgejo_api::structs::MergePullRequestOptionDo::Merge);
let merge_style = method.map(|x| x.into()).unwrap_or(default_merge);
use forgejo_api::structs::MergePullRequestOptionDo::*;
if title.is_some() {
match merge_style {
Rebase => eyre::bail!("rebase does not support commit title"),
FastForwardOnly => eyre::bail!("ff-only does not support commit title"),
ManuallyMerged => eyre::bail!("manually merged does not support commit title"),
_ => (),
}
}
let default_message = || format!("Reviewed-on: {pr_html_url}");
let message = match message {
Some(Some(s)) => s,
Some(None) => {
let mut body = default_message();
crate::editor(&mut body, Some("md")).await?;
body
}
None => default_message(),
};
let request = MergePullRequestOption {
r#do: merge_style,
merge_commit_id: None,
merge_message_field: Some(message),
merge_title_field: title,
delete_branch_after_merge: Some(delete),
force_merge: None,
head_commit_id: None,
merge_when_checks_succeed: None,
};
let pr_number = pr_info.number.ok_or_eyre("pr does not have number")? as u64;
api.repo_merge_pull_request(repo.owner(), repo.name(), pr_number, request)
.await?;
let pr_title = pr_info
.title
.as_deref()
.ok_or_eyre("pr does not have title")?;
let pr_base = pr_info.base.as_ref().ok_or_eyre("pr does not have base")?;
let base_label = pr_base
.label
.as_ref()
.ok_or_eyre("base does not have label")?;
println!("Merged PR #{pr_number} \"{pr_title}\" into `{base_label}`");
Ok(())
}
async fn checkout_pr(
repo: &RepoName,
api: &Forgejo,
pr: PrNumber,
branch_name: Option<String>,
) -> eyre::Result<()> {
let local_repo = git2::Repository::open(".").unwrap();
let mut options = git2::StatusOptions::new();
options.include_ignored(false);
let has_no_uncommited = local_repo.statuses(Some(&mut options)).unwrap().is_empty();
eyre::ensure!(
has_no_uncommited,
"Cannot checkout PR, working directory has uncommited changes"
);
let remote_repo = match pr {
PrNumber::Parent(_) => {
let mut this_repo = api.repo_get(repo.owner(), repo.name()).await?;
let name = this_repo.full_name.as_deref().unwrap_or("???/???");
*this_repo
.parent
.take()
.ok_or_else(|| eyre::eyre!("cannot get parent repo, {name} is not a fork"))?
}
PrNumber::This(_) => api.repo_get(repo.owner(), repo.name()).await?,
};
let (repo_owner, repo_name) = repo_name_from_repo(&remote_repo)?;
let pull_data = api
.repo_get_pull_request(repo_owner, repo_name, pr.number())
.await?;
let url = remote_repo
.clone_url
.as_ref()
.ok_or_eyre("repo has no clone url")?;
let mut remote = local_repo.remote_anonymous(url.as_str())?;
let branch_name = branch_name.unwrap_or_else(|| {
format!(
"pr-{}-{}-{}",
url.host_str().unwrap_or("unknown"),
repo_owner,
pr.number(),
)
});
auth_git2::GitAuthenticator::new().fetch(
&local_repo,
&mut remote,
&[&format!("pull/{}/head", pr.number())],
None,
)?;
let reference = local_repo.find_reference("FETCH_HEAD")?.resolve()?;
let commit = reference.peel_to_commit()?;
let mut branch_is_new = true;
let branch =
if let Ok(mut branch) = local_repo.find_branch(&branch_name, git2::BranchType::Local) {
branch_is_new = false;
branch
.get_mut()
.set_target(commit.id(), "update pr branch")?;
branch
} else {
local_repo.branch(&branch_name, &commit, false)?
};
let branch_ref = branch
.get()
.name()
.ok_or_eyre("branch does not have name")?;
local_repo.set_head(branch_ref)?;
local_repo
// for some reason, `.force()` is required to make it actually update
// file contents. thank you git2 examples for noticing this too, I would
// have pulled out so much hair figuring this out myself.
.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
.unwrap();
let pr_title = pull_data.title.as_deref().ok_or_eyre("pr has no title")?;
println!("Checked out PR #{}: {pr_title}", pr.number());
if branch_is_new {
println!("On new branch {branch_name}");
} else {
println!("Updated branch to latest commit");
}
Ok(())
}
async fn view_prs(
repo: &RepoName,
api: &Forgejo,
query_str: Option<String>,
labels: Option<String>,
creator: Option<String>,
assignee: Option<String>,
state: Option<crate::issues::State>,
) -> eyre::Result<()> {
let labels = labels
.map(|s| s.split(',').map(|s| s.to_string()).collect::<Vec<_>>())
.unwrap_or_default();
let query = forgejo_api::structs::IssueListIssuesQuery {
q: query_str,
labels: Some(labels.join(",")),
created_by: creator,
assigned_by: assignee,
state: state.map(|s| s.into()),
r#type: Some(forgejo_api::structs::IssueListIssuesQueryType::Pulls),
milestones: None,
since: None,
before: None,
mentioned_by: None,
page: None,
limit: None,
};
let prs = api
.issue_list_issues(repo.owner(), repo.name(), query)
.await?;
if prs.len() == 1 {
println!("1 pull request");
} else {
println!("{} pull requests", prs.len());
}
for pr in prs {
let number = pr
.number
.ok_or_else(|| eyre::eyre!("pr does not have number"))?;
let title = pr
.title
.as_ref()
.ok_or_else(|| eyre::eyre!("pr does not have title"))?;
let user = pr
.user
.as_ref()
.ok_or_else(|| eyre::eyre!("pr does not have creator"))?;
let username = user
.login
.as_ref()
.ok_or_else(|| eyre::eyre!("user does not have login"))?;
println!("#{}: {} (by {})", number, title, username);
}
Ok(())
}
async fn view_diff(
repo: &RepoName,
api: &Forgejo,
pr: Option<u64>,
patch: bool,
editor: bool,
) -> eyre::Result<()> {
let pr = try_get_pr(repo, api, pr).await?;
let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
let repo = repo_name_from_pr(&pr)?;
let diff_type = if patch { "patch" } else { "diff" };
let diff = api
.repo_download_pull_diff_or_patch(
repo.owner(),
repo.name(),
pr_number,
diff_type,
forgejo_api::structs::RepoDownloadPullDiffOrPatchQuery::default(),
)
.await?;
if editor {
let mut view = diff.clone();
crate::editor(&mut view, Some(diff_type)).await?;
if view != diff {
println!("changes made to the diff will not persist");
}
} else {
println!("{diff}");
}
Ok(())
}
async fn view_pr_files(repo: &RepoName, api: &Forgejo, pr: Option<u64>) -> eyre::Result<()> {
let pr = try_get_pr(repo, api, pr).await?;
let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
let repo = repo_name_from_pr(&pr)?;
let crate::SpecialRender {
bright_red,
bright_green,
reset,
..
} = crate::special_render();
let query = RepoGetPullRequestFilesQuery {
limit: Some(u32::MAX),
..Default::default()
};
let (_, files) = api
.repo_get_pull_request_files(repo.owner(), repo.name(), pr_number, query)
.await?;
let max_additions = files
.iter()
.map(|x| x.additions.unwrap_or_default())
.max()
.unwrap_or_default();
let max_deletions = files
.iter()
.map(|x| x.deletions.unwrap_or_default())
.max()
.unwrap_or_default();
let additions_width = max_additions.checked_ilog10().unwrap_or_default() as usize + 1;
let deletions_width = max_deletions.checked_ilog10().unwrap_or_default() as usize + 1;
for file in files {
let name = file.filename.as_deref().unwrap_or("???");
let additions = file.additions.unwrap_or_default();
let deletions = file.deletions.unwrap_or_default();
println!("{bright_green}+{additions:<additions_width$} {bright_red}-{deletions:<deletions_width$}{reset} {name}");
}
Ok(())
}
async fn view_pr_commits(
repo: &RepoName,
api: &Forgejo,
pr: Option<u64>,
oneline: bool,
) -> eyre::Result<()> {
let pr = try_get_pr(repo, api, pr).await?;
let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
let repo = repo_name_from_pr(&pr)?;
let query = RepoGetPullRequestCommitsQuery {
limit: Some(u32::MAX),
files: Some(false),
..Default::default()
};
let (_headers, commits) = api
.repo_get_pull_request_commits(repo.owner(), repo.name(), pr_number, query)
.await?;
let max_additions = commits
.iter()
.filter_map(|x| x.stats.as_ref())
.map(|x| x.additions.unwrap_or_default())
.max()
.unwrap_or_default();
let max_deletions = commits
.iter()
.filter_map(|x| x.stats.as_ref())
.map(|x| x.deletions.unwrap_or_default())
.max()
.unwrap_or_default();
let additions_width = max_additions.checked_ilog10().unwrap_or_default() as usize + 1;
let deletions_width = max_deletions.checked_ilog10().unwrap_or_default() as usize + 1;
let crate::SpecialRender {
bright_red,
bright_green,
yellow,
reset,
..
} = crate::special_render();
for commit in commits {
let repo_commit = commit
.commit
.as_ref()
.ok_or_eyre("commit does not have commit?")?;
let message = repo_commit.message.as_deref().unwrap_or("[no msg]");
let name = message.lines().next().unwrap_or(message);
let sha = commit
.sha
.as_deref()
.ok_or_eyre("commit does not have sha")?;
let short_sha = &sha[..7];
let stats = commit
.stats
.as_ref()
.ok_or_eyre("commit does not have stats")?;
let additions = stats.additions.unwrap_or_default();
let deletions = stats.deletions.unwrap_or_default();
if oneline {
println!("{yellow}{short_sha} {bright_green}+{additions:<additions_width$} {bright_red}-{deletions:<deletions_width$}{reset} {name}");
} else {
let author = repo_commit
.author
.as_ref()
.ok_or_eyre("commit has no author")?;
let author_name = author.name.as_deref().ok_or_eyre("author has no name")?;
let author_email = author.email.as_deref().ok_or_eyre("author has no email")?;
let date = commit
.created
.as_ref()
.ok_or_eyre("commit as no creation date")?;
println!("{yellow}commit {sha}{reset} ({bright_green}+{additions}{reset}, {bright_red}-{deletions}{reset})");
println!("Author: {author_name} <{author_email}>");
print!("Date: ");
let format = time::macros::format_description!("[weekday repr:short] [month repr:short] [day] [hour repr:24]:[minute]:[second] [year] [offset_hour sign:mandatory][offset_minute]");
date.format_into(&mut std::io::stdout().lock(), format)?;
println!();
println!();
for line in message.lines() {
println!(" {line}");
}
println!();
}
}
Ok(())
}
pub async fn browse_pr(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
let pr = api
.repo_get_pull_request(repo.owner(), repo.name(), id)
.await?;
let html_url = pr
.html_url
.as_ref()
.ok_or_else(|| eyre::eyre!("pr does not have html_url"))?;
open::that(html_url.as_str())?;
Ok(())
}
async fn try_get_pr_number(
repo: &RepoName,
api: &Forgejo,
number: Option<u64>,
) -> eyre::Result<(RepoName, u64)> {
let pr = match number {
Some(number) => (repo.clone(), number),
None => {
let pr = guess_pr(repo, api)
.await
.wrap_err("could not guess pull request number, please specify")?;
let number = pr.number.ok_or_eyre("pr does not have number")? as u64;
let repo = repo_name_from_pr(&pr)?;
(repo, number)
}
};
Ok(pr)
}
async fn try_get_pr(
repo: &RepoName,
api: &Forgejo,
number: Option<u64>,
) -> eyre::Result<forgejo_api::structs::PullRequest> {
let pr = match number {
Some(number) => {
api.repo_get_pull_request(repo.owner(), repo.name(), number)
.await?
}
None => guess_pr(repo, api)
.await
.wrap_err("could not guess pull request number, please specify")?,
};
Ok(pr)
}
async fn guess_pr(
repo: &RepoName,
api: &Forgejo,
) -> eyre::Result<forgejo_api::structs::PullRequest> {
let local_repo = git2::Repository::open(".")?;
let head = local_repo.head()?;
eyre::ensure!(head.is_branch(), "head is not on branch");
let local_branch = git2::Branch::wrap(head);
let remote_branch = local_branch.upstream()?;
let remote_head_name = remote_branch
.get()
.name()
.ok_or_eyre("remote branch does not have valid name")?;
let remote_head_short = remote_head_name
.rsplit_once("/")
.map(|(_, b)| b)
.unwrap_or(remote_head_name);
let this_repo = api.repo_get(repo.owner(), repo.name()).await?;
// check for PRs on the main branch first
let base = this_repo
.default_branch
.as_deref()
.ok_or_eyre("repo does not have default branch")?;
if let Ok(pr) = api
.repo_get_pull_request_by_base_head(repo.owner(), repo.name(), base, remote_head_short)
.await
{
return Ok(pr);
}
let this_full_name = this_repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
let parent_remote_head_name = format!("{this_full_name}:{remote_head_short}");
if let Some(parent) = this_repo.parent.as_deref() {
let (parent_owner, parent_name) = repo_name_from_repo(parent)?;
let parent_base = this_repo
.default_branch
.as_deref()
.ok_or_eyre("repo does not have default branch")?;
if let Ok(pr) = api
.repo_get_pull_request_by_base_head(
parent_owner,
parent_name,
parent_base,
&parent_remote_head_name,
)
.await
{
return Ok(pr);
}
}
// then iterate all branches
if let Some(pr) = find_pr_from_branch(repo.owner(), repo.name(), api, remote_head_short).await?
{
return Ok(pr);
}
if let Some(parent) = this_repo.parent.as_deref() {
let (parent_owner, parent_name) = repo_name_from_repo(parent)?;
if let Some(pr) =
find_pr_from_branch(parent_owner, parent_name, api, &parent_remote_head_name).await?
{
return Ok(pr);
}
}
eyre::bail!("could not find PR");
}
async fn find_pr_from_branch(
repo_owner: &str,
repo_name: &str,
api: &Forgejo,
head: &str,
) -> eyre::Result<Option<forgejo_api::structs::PullRequest>> {
for page in 1.. {
let branch_query = forgejo_api::structs::RepoListBranchesQuery {
page: Some(page),
limit: Some(30),
};
let remote_branches = match api
.repo_list_branches(repo_owner, repo_name, branch_query)
.await
{
Ok(x) if !x.is_empty() => x,
_ => break,
};
let prs = futures::future::try_join_all(
remote_branches
.into_iter()
.map(|branch| check_branch_pair(repo_owner, repo_name, api, branch, head)),
)
.await?;
for pr in prs {
if pr.is_some() {
return Ok(pr);
}
}
}
Ok(None)
}
async fn check_branch_pair(
repo_owner: &str,
repo_name: &str,
api: &Forgejo,
base: forgejo_api::structs::Branch,
head: &str,
) -> eyre::Result<Option<forgejo_api::structs::PullRequest>> {
let base_name = base
.name
.as_deref()
.ok_or_eyre("remote branch does not have name")?;
match api
.repo_get_pull_request_by_base_head(repo_owner, repo_name, base_name, head)
.await
{
Ok(pr) => Ok(Some(pr)),
Err(_) => Ok(None),
}
}
fn repo_name_from_repo(repo: &forgejo_api::structs::Repository) -> eyre::Result<(&str, &str)> {
let owner = repo
.owner
.as_ref()
.ok_or_eyre("repo does not have owner")?
.login
.as_deref()
.ok_or_eyre("repo owner does not have name")?;
let name = repo.name.as_deref().ok_or_eyre("repo does not have name")?;
Ok((owner, name))
}
fn repo_name_from_pr(pr: &forgejo_api::structs::PullRequest) -> eyre::Result<RepoName> {
let base_branch = pr.base.as_ref().ok_or_eyre("pr does not have base")?;
let repo = base_branch
.repo
.as_ref()
.ok_or_eyre("branch does not have repo")?;
let (owner, name) = repo_name_from_repo(repo)?;
let repo_name = RepoName::new(owner.to_owned(), name.to_owned());
Ok(repo_name)
}
//async fn guess_pr(
// repo: &RepoName,
// api: &Forgejo,
//) -> eyre::Result<forgejo_api::structs::PullRequest> {
// let local_repo = git2::Repository::open(".")?;
// let head_id = local_repo.head()?.peel_to_commit()?.id();
// let sha = oid_to_string(head_id);
// let pr = api
// .repo_get_commit_pull_request(repo.owner(), repo.name(), &sha)
// .await?;
// Ok(pr)
//}
//
//fn oid_to_string(oid: git2::Oid) -> String {
// let mut s = String::with_capacity(40);
// for byte in oid.as_bytes() {
// s.push(
// char::from_digit((byte & 0xF) as u32, 16).expect("every nibble is a valid hex digit"),
// );
// s.push(
// char::from_digit(((byte >> 4) & 0xF) as u32, 16)
// .expect("every nibble is a valid hex digit"),
// );
// }
// s
//}