codeberg_cli/actions/pull_request/
create.rsuse crate::actions::text_manipulation::{edit_prompt_for, input_prompt_for, select_prompt_for};
use crate::actions::GeneralArgs;
use crate::render::option::option_display;
use crate::render::ui::{
fuzzy_select_with_key, fuzzy_select_with_key_with_default, multi_fuzzy_select_with_key,
};
use crate::types::context::BergContext;
use crate::types::git::OwnerRepo;
use anyhow::Context;
use clap::Parser;
use forgejo_api::structs::{
CreatePullRequestOption, IssueGetMilestonesListQuery, IssueListLabelsQuery,
RepoListBranchesQuery,
};
use strum::{Display, VariantArray};
#[derive(Parser, Debug)]
pub struct CreatePullRequestArgs {
#[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
pub assignees: Option<Vec<String>>,
#[arg(short, long)]
pub target_branch: Option<String>,
#[arg(id = "description", short, long)]
pub body: Option<String>,
#[arg(short, long)]
pub source_branch: Option<String>,
#[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
pub labels: Option<Vec<String>>,
#[arg(long)]
pub title: Option<String>,
#[arg(short, long)]
pub milestone: Option<String>,
#[arg(short, long)]
pub interactive: bool,
}
#[derive(Display, PartialEq, Eq, VariantArray)]
enum CreatableFields {
Assignees,
Labels,
Milestone,
}
impl CreatePullRequestArgs {
pub async fn run(self, general_args: GeneralArgs) -> anyhow::Result<()> {
let _ = general_args;
let ctx = BergContext::new(self).await?;
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
let options = create_options(&ctx).await?;
let pull_request = ctx
.client
.repo_create_pull_request(owner.as_str(), repo.as_str(), options)
.await?;
tracing::debug!("{pull_request:?}");
Ok(())
}
}
async fn create_options(
ctx: &BergContext<CreatePullRequestArgs>,
) -> anyhow::Result<CreatePullRequestOption> {
let title = match ctx.args.title.as_ref() {
Some(title) => title.clone(),
None => inquire::Text::new(input_prompt_for("Pull Request Title").as_str()).prompt()?,
};
let target_branch = match ctx.args.target_branch.as_ref() {
Some(branch) => branch.clone(),
None => {
select_branch(
ctx,
"target branch into which changes are merged",
vec!["main", "master"],
None,
)
.await?
}
};
let source_branch = match ctx.args.source_branch.as_ref() {
Some(branch) => branch.clone(),
None => {
let current_checkout = get_current_checkout()?;
select_branch(
ctx,
"source branch containing the changes",
vec![current_checkout.as_str()],
Some(target_branch.as_str()),
)
.await?
}
};
let mut options = CreatePullRequestOption {
title: Some(title),
base: Some(target_branch),
head: Some(source_branch),
assignee: None,
assignees: None,
body: None,
due_date: None,
labels: None,
milestone: None,
};
let optional_data = {
use CreatableFields::*;
[
(Labels, ctx.args.labels.is_none()),
(Assignees, ctx.args.assignees.is_none()),
(Milestone, ctx.args.milestone.is_none()),
]
.into_iter()
.filter_map(|(name, missing)| missing.then_some(name))
.collect::<Vec<_>>()
};
let chosen_optionals = multi_fuzzy_select_with_key(
&optional_data,
"Choose optional properties",
|_| false,
|o| o.to_string(),
)?;
options.body.replace(pr_body(ctx)?);
{
use CreatableFields::*;
options.labels = pr_labels(ctx, chosen_optionals.contains(&&Labels)).await?;
options.assignees = pr_assignees(ctx, chosen_optionals.contains(&&Assignees)).await?;
options.milestone = pr_milestone(ctx, chosen_optionals.contains(&&Milestone)).await?;
}
Ok(options)
}
fn pr_body(ctx: &BergContext<CreatePullRequestArgs>) -> anyhow::Result<String> {
let body = match ctx.args.body.as_ref() {
Some(body) => body.clone(),
None => inquire::Editor::new(edit_prompt_for("a description").as_str())
.with_predefined_text("Enter a pull request description")
.prompt()?,
};
Ok(body)
}
async fn pr_labels(
ctx: &BergContext<CreatePullRequestArgs>,
interactive: bool,
) -> anyhow::Result<Option<Vec<i64>>> {
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
let all_labels = ctx
.client
.issue_list_labels(
owner.as_str(),
repo.as_str(),
IssueListLabelsQuery::default(),
)
.await?;
let labels = match &ctx.args.labels {
Some(label_names) => {
let label_ids = all_labels
.iter()
.filter(|l| {
l.name
.as_ref()
.is_some_and(|name| label_names.contains(name))
})
.filter_map(|l| l.id)
.collect::<Vec<_>>();
Some(label_ids)
}
None => {
if !interactive {
return Ok(None);
}
let selected_labels = multi_fuzzy_select_with_key(
&all_labels,
select_prompt_for("labels"),
|_| false,
|l| {
l.name
.as_ref()
.cloned()
.unwrap_or_else(|| String::from("???"))
},
)?;
let label_ids = selected_labels
.iter()
.filter_map(|l| l.id)
.collect::<Vec<_>>();
Some(label_ids)
}
};
Ok(labels)
}
async fn pr_assignees(
ctx: &BergContext<CreatePullRequestArgs>,
interactive: bool,
) -> anyhow::Result<Option<Vec<String>>> {
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
let all_assignees = ctx
.client
.repo_get_assignees(owner.as_str(), repo.as_str())
.await?;
let assignees = match &ctx.args.assignees {
Some(assignees_names) => Some(assignees_names.clone()),
None => {
if !interactive {
return Ok(None);
}
let selected_assignees = multi_fuzzy_select_with_key(
&all_assignees,
select_prompt_for("assignees"),
|_| false,
|u| {
u.login
.as_ref()
.cloned()
.unwrap_or_else(|| String::from("???"))
},
)?;
Some(
selected_assignees
.into_iter()
.filter_map(|u| u.login.as_ref().cloned())
.collect::<Vec<_>>(),
)
}
};
Ok(assignees)
}
async fn pr_milestone(
ctx: &BergContext<CreatePullRequestArgs>,
interactive: bool,
) -> anyhow::Result<Option<i64>> {
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
let all_milestones = ctx
.client
.issue_get_milestones_list(
owner.as_str(),
repo.as_str(),
IssueGetMilestonesListQuery::default(),
)
.await?;
let milestone = match &ctx.args.milestone {
Some(milestone_name) => Some(
all_milestones
.iter()
.find(|m| m.title.as_ref().is_some_and(|name| name == milestone_name))
.and_then(|milestone| milestone.id)
.context(format!(
"Milestone with name {milestone_name} wasn't found. Check the spelling"
))?,
),
None => {
if !interactive {
return Ok(None);
}
let selected_milestone =
fuzzy_select_with_key(&all_milestones, select_prompt_for("milestones"), |m| {
m.title
.as_ref()
.cloned()
.unwrap_or_else(|| String::from("???"))
})?;
Some(
selected_milestone
.id
.context("Selected milestone's id is missing")?,
)
}
};
Ok(milestone)
}
async fn select_branch(
ctx: &BergContext<CreatePullRequestArgs>,
prompt_text: &str,
default_branch_names: Vec<&str>,
filter_branch: Option<&str>,
) -> anyhow::Result<String> {
let OwnerRepo { repo, owner } = ctx.owner_repo()?;
let branches = ctx
.client
.repo_list_branches(
owner.as_str(),
repo.as_str(),
RepoListBranchesQuery::default(),
)
.await?
.into_iter()
.filter(|branch| {
!branch
.name
.as_ref()
.is_some_and(|name| filter_branch.is_some_and(|filter| name == filter))
})
.collect::<Vec<_>>();
if branches.is_empty() {
if filter_branch.is_some() {
anyhow::bail!("No remote branches except {filter_branch:?} found. Maybe the branch you want to merge doesn't exist on remote yet?");
} else {
anyhow::bail!("No remote branches found.");
}
}
let default_index = default_branch_names.iter().find_map(|&default_name| {
branches.iter().position(|branch| {
branch
.name
.as_ref()
.is_some_and(|name| name.as_str() == default_name)
})
});
fuzzy_select_with_key_with_default(
&branches,
select_prompt_for(prompt_text),
|b| option_display(&b.name),
default_index,
)
.map(|branch| option_display(&branch.name))
}
fn get_current_checkout() -> anyhow::Result<String> {
let output = std::process::Command::new("git")
.arg("branch")
.arg("--show-current")
.output()?;
String::from_utf8(output.stdout)
.map(|base| base.trim().to_owned())
.map_err(anyhow::Error::from)
}