mirror of
https://codeberg.org/Cyborus/forgejo-cli.git
synced 2024-11-10 12:09:33 +01:00
a979a34b78
There are still some clippy warnings left, but this covers most of them
1564 lines
49 KiB
Rust
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
|
|
//}
|