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