summaryrefslogtreecommitdiffstats
path: root/src/prs.rs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-11-04 11:30:10 +0100
committerDaniel Baumann <daniel@debian.org>2024-11-20 07:38:21 +0100
commit34f503aa3bfba930fd7978a0071786884d73749f (patch)
tree7e3c8e2506fdd93e29958d9f8cb36fbed4a5af7d /src/prs.rs
parentInitial commit. (diff)
downloadforgejo-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.rs1564
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
+//}