diff options
author | Daniel Baumann <daniel@debian.org> | 2024-11-04 11:30:10 +0100 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-11-20 07:38:21 +0100 |
commit | 34f503aa3bfba930fd7978a0071786884d73749f (patch) | |
tree | 7e3c8e2506fdd93e29958d9f8cb36fbed4a5af7d /src/prs.rs | |
parent | Initial commit. (diff) | |
download | forgejo-cli-34f503aa3bfba930fd7978a0071786884d73749f.tar.xz forgejo-cli-34f503aa3bfba930fd7978a0071786884d73749f.zip |
Adding upstream version 0.1.1.upstream/0.1.1upstream
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'src/prs.rs')
-rw-r--r-- | src/prs.rs | 1564 |
1 files changed, 1564 insertions, 0 deletions
diff --git a/src/prs.rs b/src/prs.rs new file mode 100644 index 0000000..840ae12 --- /dev/null +++ b/src/prs.rs @@ -0,0 +1,1564 @@ +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(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<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 +//} |