codeberg_cli/actions/pull_request/
create.rs1use crate::actions::text_manipulation::{input_prompt_for, select_prompt_for};
2use crate::actions::GeneralArgs;
3use crate::render::option::option_display;
4use crate::render::ui::{
5 fuzzy_select_with_key, fuzzy_select_with_key_with_default, multi_fuzzy_select_with_key,
6};
7use crate::types::context::BergContext;
8use crate::types::git::OwnerRepo;
9use anyhow::Context;
10use clap::Parser;
11use forgejo_api::structs::{
12 CreatePullRequestOption, IssueGetMilestonesListQuery, IssueListLabelsQuery,
13 RepoListBranchesQuery,
14};
15use strum::{Display, VariantArray};
16
17#[derive(Parser, Debug)]
19pub struct CreatePullRequestArgs {
20 #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
22 pub assignees: Option<Vec<String>>,
23
24 #[arg(short, long)]
26 pub target_branch: Option<String>,
27
28 #[arg(id = "description", short, long)]
30 pub body: Option<String>,
31
32 #[arg(short, long)]
34 pub source_branch: Option<String>,
35
36 #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
38 pub labels: Option<Vec<String>>,
39
40 #[arg(long)]
42 pub title: Option<String>,
43
44 #[arg(short, long)]
46 pub milestone: Option<String>,
47
48 #[arg(short, long)]
50 pub interactive: bool,
51}
52
53#[derive(Display, PartialEq, Eq, VariantArray)]
54enum CreatableFields {
55 Assignees,
56 Labels,
57 Milestone,
58}
59
60impl CreatePullRequestArgs {
61 pub async fn run(self, general_args: GeneralArgs) -> anyhow::Result<()> {
62 let _ = general_args;
63 let ctx = BergContext::new(self, general_args).await?;
64
65 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
66 let options = create_options(&ctx).await?;
67 let pull_request = ctx
68 .client
69 .repo_create_pull_request(owner.as_str(), repo.as_str(), options)
70 .await?;
71 tracing::debug!("{pull_request:?}");
72 Ok(())
73 }
74}
75
76async fn create_options(
77 ctx: &BergContext<CreatePullRequestArgs>,
78) -> anyhow::Result<CreatePullRequestOption> {
79 let title = match ctx.args.title.as_ref() {
80 Some(title) => title.clone(),
81 None => inquire::Text::new(input_prompt_for("Pull Request Title").as_str()).prompt()?,
82 };
83
84 let target_branch = match ctx.args.target_branch.as_ref() {
85 Some(branch) => branch.clone(),
86 None => {
87 select_branch(
88 ctx,
89 "target branch into which changes are merged",
90 vec!["main", "master"],
91 None,
92 )
93 .await?
94 }
95 };
96
97 let source_branch = match ctx.args.source_branch.as_ref() {
98 Some(branch) => branch.clone(),
99 None => {
100 let current_checkout = get_current_checkout()?;
101 select_branch(
102 ctx,
103 "source branch containing the changes",
104 vec![current_checkout.as_str()],
105 Some(target_branch.as_str()),
106 )
107 .await?
108 }
109 };
110
111 let mut options = CreatePullRequestOption {
112 title: Some(title),
113 base: Some(target_branch),
114 head: Some(source_branch),
115 assignee: None,
116 assignees: None,
117 body: None,
118 due_date: None,
119 labels: None,
120 milestone: None,
121 };
122
123 let optional_data = {
124 use CreatableFields::*;
125 [
126 (Labels, ctx.args.labels.is_none()),
127 (Assignees, ctx.args.assignees.is_none()),
128 (Milestone, ctx.args.milestone.is_none()),
129 ]
130 .into_iter()
131 .filter_map(|(name, missing)| missing.then_some(name))
132 .collect::<Vec<_>>()
133 };
134
135 let chosen_optionals = multi_fuzzy_select_with_key(
136 &optional_data,
137 "Choose optional properties",
138 |_| false,
139 |o| o.to_string(),
140 )?;
141
142 options.body.replace(pr_body(ctx)?);
143 {
144 use CreatableFields::*;
145 options.labels = pr_labels(ctx, chosen_optionals.contains(&&Labels)).await?;
146 options.assignees = pr_assignees(ctx, chosen_optionals.contains(&&Assignees)).await?;
147 options.milestone = pr_milestone(ctx, chosen_optionals.contains(&&Milestone)).await?;
148 }
149
150 Ok(options)
151}
152
153fn pr_body(ctx: &BergContext<CreatePullRequestArgs>) -> anyhow::Result<String> {
154 let body = match ctx.args.body.as_ref() {
155 Some(body) => body.clone(),
156 None => ctx.editor_for("a description", "Enter a pull request description")?,
157 };
158 Ok(body)
159}
160
161async fn pr_labels(
162 ctx: &BergContext<CreatePullRequestArgs>,
163 interactive: bool,
164) -> anyhow::Result<Option<Vec<i64>>> {
165 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
166
167 let all_labels = ctx
168 .client
169 .issue_list_labels(
170 owner.as_str(),
171 repo.as_str(),
172 IssueListLabelsQuery::default(),
173 )
174 .await?;
175
176 let labels = match &ctx.args.labels {
177 Some(label_names) => {
178 let label_ids = all_labels
179 .iter()
180 .filter(|l| {
181 l.name
182 .as_ref()
183 .is_some_and(|name| label_names.contains(name))
184 })
185 .filter_map(|l| l.id)
186 .collect::<Vec<_>>();
187 Some(label_ids)
188 }
189 None => {
190 if !interactive {
191 return Ok(None);
192 }
193
194 let selected_labels = multi_fuzzy_select_with_key(
195 &all_labels,
196 select_prompt_for("labels"),
197 |_| false,
198 |l| {
199 l.name
200 .as_ref()
201 .cloned()
202 .unwrap_or_else(|| String::from("???"))
203 },
204 )?;
205
206 let label_ids = selected_labels
207 .iter()
208 .filter_map(|l| l.id)
209 .collect::<Vec<_>>();
210
211 Some(label_ids)
212 }
213 };
214 Ok(labels)
215}
216
217async fn pr_assignees(
218 ctx: &BergContext<CreatePullRequestArgs>,
219 interactive: bool,
220) -> anyhow::Result<Option<Vec<String>>> {
221 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
222
223 let all_assignees = ctx
224 .client
225 .repo_get_assignees(owner.as_str(), repo.as_str())
226 .await?;
227 let assignees = match &ctx.args.assignees {
228 Some(assignees_names) => Some(assignees_names.clone()),
229 None => {
230 if !interactive {
231 return Ok(None);
232 }
233
234 let selected_assignees = multi_fuzzy_select_with_key(
235 &all_assignees,
236 select_prompt_for("assignees"),
237 |_| false,
238 |u| {
239 u.login
240 .as_ref()
241 .cloned()
242 .unwrap_or_else(|| String::from("???"))
243 },
244 )?;
245
246 Some(
247 selected_assignees
248 .into_iter()
249 .filter_map(|u| u.login.as_ref().cloned())
250 .collect::<Vec<_>>(),
251 )
252 }
253 };
254
255 Ok(assignees)
256}
257
258async fn pr_milestone(
259 ctx: &BergContext<CreatePullRequestArgs>,
260 interactive: bool,
261) -> anyhow::Result<Option<i64>> {
262 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
263
264 let all_milestones = ctx
265 .client
266 .issue_get_milestones_list(
267 owner.as_str(),
268 repo.as_str(),
269 IssueGetMilestonesListQuery::default(),
270 )
271 .await?;
272
273 let milestone = match &ctx.args.milestone {
274 Some(milestone_name) => Some(
275 all_milestones
276 .iter()
277 .find(|m| m.title.as_ref().is_some_and(|name| name == milestone_name))
278 .and_then(|milestone| milestone.id)
279 .context(format!(
280 "Milestone with name {milestone_name} wasn't found. Check the spelling"
281 ))?,
282 ),
283 None => {
284 if !interactive {
285 return Ok(None);
286 }
287 let selected_milestone =
288 fuzzy_select_with_key(&all_milestones, select_prompt_for("milestones"), |m| {
289 m.title
290 .as_ref()
291 .cloned()
292 .unwrap_or_else(|| String::from("???"))
293 })?;
294
295 Some(
296 selected_milestone
297 .id
298 .context("Selected milestone's id is missing")?,
299 )
300 }
301 };
302
303 Ok(milestone)
304}
305
306async fn select_branch(
307 ctx: &BergContext<CreatePullRequestArgs>,
308 prompt_text: &str,
309 default_branch_names: Vec<&str>,
310 filter_branch: Option<&str>,
311) -> anyhow::Result<String> {
312 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
313 let branches = ctx
314 .client
315 .repo_list_branches(
316 owner.as_str(),
317 repo.as_str(),
318 RepoListBranchesQuery::default(),
319 )
320 .await?
321 .into_iter()
322 .filter(|branch| {
323 branch
324 .name
325 .as_ref()
326 .is_some_and(|name| filter_branch.is_none_or(|filter| name != filter))
327 })
328 .collect::<Vec<_>>();
329
330 if branches.is_empty() {
331 if filter_branch.is_some() {
332 anyhow::bail!("No remote branches except {filter_branch:?} found. Maybe the branch you want to merge doesn't exist on remote yet?");
333 } else {
334 anyhow::bail!("No remote branches found.");
335 }
336 }
337
338 let default_index = default_branch_names.iter().find_map(|&default_name| {
339 branches.iter().position(|branch| {
340 branch
341 .name
342 .as_ref()
343 .is_some_and(|name| name.as_str() == default_name)
344 })
345 });
346
347 fuzzy_select_with_key_with_default(
348 &branches,
349 select_prompt_for(prompt_text),
350 |b| option_display(&b.name),
351 default_index,
352 )
353 .map(|branch| option_display(&branch.name))
354}
355
356fn get_current_checkout() -> anyhow::Result<String> {
357 let output = std::process::Command::new("git")
358 .arg("branch")
359 .arg("--show-current")
360 .output()?;
361 String::from_utf8(output.stdout)
362 .map(|base| base.trim().to_owned())
363 .map_err(anyhow::Error::from)
364}