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, #[clap(subcommand)] command: PrSubcommand, } #[derive(Subcommand, Clone, Debug)] pub enum PrSubcommand { /// Search a repository's pull requests Search { query: Option, #[clap(long, short)] labels: Option, #[clap(long, short)] creator: Option, #[clap(long, short)] assignee: Option, #[clap(long, short)] state: Option, /// The repo to search in #[clap(long, short)] repo: Option, }, /// Create a new pull request Create { /// The branch to merge onto. #[clap(long)] base: Option, /// The branch to pull changes from. #[clap(long)] head: Option, /// 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, /// The repo to create this issue on #[clap(long, short, id = "[HOST/]OWNER/REPO")] repo: Option, }, /// View the contents of a pull request View { /// The pull request to view. #[clap(id = "[REPO#]ID")] id: Option, #[clap(subcommand)] command: Option, }, /// View the mergability and CI status of a pull request Status { /// The pull request to view. #[clap(id = "[REPO#]ID")] id: Option, }, /// 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, }, /// Add a comment on a pull request Comment { /// The pull request to comment on. #[clap(id = "[REPO#]ID")] pr: Option, /// The text content of the comment. /// /// Not including this in the command will open your editor. body: Option, }, /// Edit the contents of a pull request Edit { /// The pull request to edit. #[clap(id = "[REPO#]ID")] pr: Option, #[clap(subcommand)] command: EditCommand, }, /// Close a pull request, without merging. Close { /// The pull request to close. #[clap(id = "[REPO#]ID")] pr: Option, /// A comment to add before closing. /// /// Adding without an argument will open your editor #[clap(long, short)] with_msg: Option>, }, /// Merge a pull request Merge { /// The pull request to merge. #[clap(id = "[REPO#]ID")] pr: Option, /// The merge style to use. #[clap(long, short = 'M')] method: Option, /// 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, /// The body of the merge or squash commit to be created #[clap(long, short)] message: Option>, }, /// Open a pull request in your browser Browse { /// The pull request to open in your browser. #[clap(id = "[REPO#]ID")] id: Option, }, } #[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 { if let Some(num) = s.strip_prefix("^") { Ok(Self::Parent(num.parse()?)) } else { Ok(Self::This(s.parse()?)) } } } impl From 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, }, /// Edit the text body Body { /// New PR body. /// /// Leaving this out will open the current body in your editor. new_body: Option, }, /// 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, }, Labels { /// The labels to add. #[clap(long, short)] add: Vec, /// The labels to remove. #[clap(long, short)] rm: Vec, }, } #[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) -> 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) -> 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) -> 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, add: Vec, rm: Vec, ) -> 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, head: Option, body: Option, ) -> 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(stripped) if stripped.is_empty() => (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, method: Option, delete: bool, title: Option, message: Option>, ) -> 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, ) -> 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, labels: Option, creator: Option, assignee: Option, state: Option, ) -> eyre::Result<()> { let labels = labels .map(|s| s.split(',').map(|s| s.to_string()).collect::>()) .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, 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) -> 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:, 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:"); 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, ) -> 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, ) -> eyre::Result { 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 { 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> { 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> { 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 { 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 { // 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 //}