1use rand::Rng;
2
3use crate::api_traits::{CommentMergeRequest, MergeRequest, RemoteProject, Timestamp};
4use crate::cli::merge_request::MergeRequestOptions;
5use crate::config::ConfigProperties;
6use crate::display::{Column, DisplayBody};
7use crate::error::{AddContext, GRError};
8use crate::git::Repo;
9use crate::io::{CmdInfo, ShellResponse, TaskRunner};
10use crate::remote::{CacheCliArgs, CacheType, GetRemoteCliArgs, ListBodyArgs, ListRemoteCliArgs};
11use crate::shell::BlockingCommand;
12use crate::{dialog, display, exec, git, remote, Cmd, Result};
13use std::fmt::{self, Display, Formatter};
14use std::{
15 fs::File,
16 io::{BufRead, BufReader, Cursor, Write},
17 sync::Arc,
18};
19
20use super::common::{self, get_user};
21use super::project::{Member, Project};
22
23const GPT_PROMPT: &str = r#"
26Act like a professional software engineer. You have finished a new feature and
27want to create a new pull request for someone else to review your changes. You
28will be provided by a list of commit messages with the following format:
29
30- Newest commit is at the very bottom.
31- The subject of the commit message is followed by a dash `-` and its short SHA.
32- If there is a body, it will be immediately below the subject `-` short SHA line.
33
34You will provide a description of these changes. The description must not
35mention commit SHAs and it must not mention the number of commits included. Be
36concise and provide at most two paragraphs describing the changes. Use
37imperative mode, be short and to the point. The description for the pull
38request will begin with the sentence `This merge request`.
39
40The formatted output that you will provide is as follows:
41
42- First line will be the title. Keep it within 80 characters width
43- Next line is blank
44- The following lines will be the description of the pull request
45
46Below are the changes:"#;
47
48#[derive(Builder, Clone, Debug, Default)]
49#[builder(default)]
50pub struct MergeRequestResponse {
51 pub id: i64,
52 pub web_url: String,
53 pub author: String,
54 pub updated_at: String,
55 pub source_branch: String,
56 pub sha: String,
57 pub created_at: String,
58 pub title: String,
59 pub pull_request: String,
61 pub description: String,
63 pub merged_at: String,
64 pub pipeline_id: Option<i64>,
65 pub pipeline_url: Option<String>,
66}
67
68impl MergeRequestResponse {
69 pub fn builder() -> MergeRequestResponseBuilder {
70 MergeRequestResponseBuilder::default()
71 }
72}
73
74impl From<MergeRequestResponse> for DisplayBody {
75 fn from(mr: MergeRequestResponse) -> DisplayBody {
76 DisplayBody {
77 columns: vec![
78 Column::new("ID", mr.id.to_string()),
79 Column::new("Title", mr.title),
80 Column::new("Source Branch", mr.source_branch),
81 Column::builder()
82 .name("SHA".to_string())
83 .value(mr.sha)
84 .optional(true)
85 .build()
86 .unwrap(),
87 Column::builder()
88 .name("Description".to_string())
89 .value(mr.description)
90 .optional(true)
91 .build()
92 .unwrap(),
93 Column::new("Author", mr.author),
94 Column::new("URL", mr.web_url),
95 Column::new("Updated at", mr.updated_at),
96 Column::builder()
97 .name("Merged at".to_string())
98 .value(mr.merged_at)
99 .optional(true)
100 .build()
101 .unwrap(),
102 Column::builder()
103 .name("Pipeline ID".to_string())
104 .value(mr.pipeline_id.map_or("".to_string(), |id| id.to_string()))
105 .optional(true)
106 .build()
107 .unwrap(),
108 Column::builder()
109 .name("Pipeline URL".to_string())
110 .value(mr.pipeline_url.unwrap_or("".to_string()))
111 .optional(true)
112 .build()
113 .unwrap(),
114 ],
115 }
116 }
117}
118
119impl Timestamp for MergeRequestResponse {
120 fn created_at(&self) -> String {
121 self.created_at.clone()
122 }
123}
124
125#[derive(Clone, Copy, PartialEq, Debug)]
126pub enum MergeRequestState {
127 Opened,
128 Closed,
129 Merged,
130}
131
132impl TryFrom<&str> for MergeRequestState {
133 type Error = String;
134
135 fn try_from(s: &str) -> std::result::Result<Self, Self::Error> {
136 match s {
137 "opened" => Ok(MergeRequestState::Opened),
138 "closed" => Ok(MergeRequestState::Closed),
139 "merged" => Ok(MergeRequestState::Merged),
140 _ => Err(format!("Invalid merge request state: {s}")),
141 }
142 }
143}
144
145impl Display for MergeRequestState {
146 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
147 match self {
148 MergeRequestState::Opened => write!(f, "opened"),
149 MergeRequestState::Closed => write!(f, "closed"),
150 MergeRequestState::Merged => write!(f, "merged"),
151 }
152 }
153}
154
155#[derive(Builder, Debug)]
156pub struct MergeRequestBodyArgs {
157 #[builder(default)]
158 pub title: String,
159 #[builder(default)]
160 pub description: String,
161 #[builder(default)]
162 pub source_branch: String,
163 #[builder(default)]
164 pub target_repo: String,
165 #[builder(default)]
166 pub target_branch: String,
167 #[builder(default)]
168 pub assignee: Member,
169 #[builder(default)]
170 pub reviewer: Member,
171 #[builder(default = "String::from(\"true\")")]
172 pub remove_source_branch: String,
173 #[builder(default)]
174 pub draft: bool,
175 #[builder(default)]
176 pub amend: bool,
177}
178
179impl MergeRequestBodyArgs {
180 pub fn builder() -> MergeRequestBodyArgsBuilder {
181 MergeRequestBodyArgsBuilder::default()
182 }
183}
184
185#[derive(Builder, Clone)]
186pub struct MergeRequestListBodyArgs {
187 pub state: MergeRequestState,
188 pub list_args: Option<ListBodyArgs>,
189 #[builder(default)]
190 pub assignee: Option<Member>,
191 #[builder(default)]
192 pub author: Option<Member>,
193 #[builder(default)]
194 pub reviewer: Option<Member>,
195}
196
197impl MergeRequestListBodyArgs {
198 pub fn builder() -> MergeRequestListBodyArgsBuilder {
199 MergeRequestListBodyArgsBuilder::default()
200 }
201}
202
203#[derive(Builder, Clone)]
204pub struct MergeRequestCliArgs {
205 pub title: Option<String>,
206 pub body_from_commit: Option<String>,
207 #[builder(default)]
208 pub body_from_file: Option<String>,
209 pub description: Option<String>,
210 pub description_from_file: Option<String>,
211 #[builder(default)]
212 pub assignee: Option<String>,
213 #[builder(default)]
214 pub reviewer: Option<String>,
215 #[builder(default)]
216 pub rand_reviewer: bool,
217 pub target_branch: Option<String>,
218 #[builder(default)]
219 pub target_repo: Option<String>,
220 #[builder(default)]
221 pub fetch: Option<String>,
222 #[builder(default)]
223 pub rebase: Option<String>,
224 pub auto: bool,
225 pub cache_args: CacheCliArgs,
226 pub open_browser: bool,
227 pub accept_summary: bool,
228 pub commit: Option<String>,
229 pub amend: bool,
230 pub force: bool,
231 pub draft: bool,
232 pub dry_run: bool,
233 #[builder(default)]
234 pub summary: SummaryOptions,
235 #[builder(default)]
236 pub patch: bool,
237 #[builder(default)]
238 pub gpt_prompt: bool,
239}
240
241#[derive(Clone, Debug, Default, PartialEq)]
242pub enum SummaryOptions {
243 Short,
244 Long,
245 #[default]
246 None,
247}
248
249impl MergeRequestCliArgs {
250 pub fn builder() -> MergeRequestCliArgsBuilder {
251 MergeRequestCliArgsBuilder::default()
252 }
253}
254
255#[derive(Clone, Debug, PartialEq)]
259pub enum MergeRequestUser {
260 Me,
261 Other(String),
262}
263
264#[derive(Builder)]
265pub struct MergeRequestListCliArgs {
266 pub state: MergeRequestState,
267 pub list_args: ListRemoteCliArgs,
268 #[builder(default)]
270 pub assignee: Option<MergeRequestUser>,
271 #[builder(default)]
272 pub author: Option<MergeRequestUser>,
273 #[builder(default)]
274 pub reviewer: Option<MergeRequestUser>,
275}
276
277impl MergeRequestListCliArgs {
278 pub fn new(state: MergeRequestState, args: ListRemoteCliArgs) -> MergeRequestListCliArgs {
279 MergeRequestListCliArgs {
280 state,
281 list_args: args,
282 assignee: None,
283 author: None,
284 reviewer: None,
285 }
286 }
287 pub fn builder() -> MergeRequestListCliArgsBuilder {
288 MergeRequestListCliArgsBuilder::default()
289 }
290}
291
292#[derive(Builder)]
293pub struct MergeRequestGetCliArgs {
294 pub id: i64,
295 pub get_args: GetRemoteCliArgs,
296}
297
298impl MergeRequestGetCliArgs {
299 pub fn builder() -> MergeRequestGetCliArgsBuilder {
300 MergeRequestGetCliArgsBuilder::default()
301 }
302}
303
304#[derive(Builder)]
305pub struct CommentMergeRequestCliArgs {
306 pub id: i64,
307 pub comment: Option<String>,
308 pub comment_from_file: Option<String>,
309}
310
311impl CommentMergeRequestCliArgs {
312 pub fn builder() -> CommentMergeRequestCliArgsBuilder {
313 CommentMergeRequestCliArgsBuilder::default()
314 }
315}
316
317#[derive(Builder)]
318pub struct CommentMergeRequestListCliArgs {
319 pub id: i64,
320 pub list_args: ListRemoteCliArgs,
321}
322
323impl CommentMergeRequestListCliArgs {
324 pub fn builder() -> CommentMergeRequestListCliArgsBuilder {
325 CommentMergeRequestListCliArgsBuilder::default()
326 }
327}
328
329#[derive(Builder)]
330pub struct CommentMergeRequestListBodyArgs {
331 pub id: i64,
332 pub list_args: Option<ListBodyArgs>,
333}
334
335impl CommentMergeRequestListBodyArgs {
336 pub fn builder() -> CommentMergeRequestListBodyArgsBuilder {
337 CommentMergeRequestListBodyArgsBuilder::default()
338 }
339}
340
341#[derive(Builder)]
342pub struct CommentMergeRequestBodyArgs {
343 pub id: i64,
344 pub comment: String,
345}
346
347impl CommentMergeRequestBodyArgs {
348 pub fn builder() -> CommentMergeRequestBodyArgsBuilder {
349 CommentMergeRequestBodyArgsBuilder::default()
350 }
351}
352
353#[derive(Builder, Clone)]
354pub struct Comment {
355 pub id: i64,
356 pub body: String,
357 pub author: String,
358 pub created_at: String,
359}
360
361impl Comment {
362 pub fn builder() -> CommentBuilder {
363 CommentBuilder::default()
364 }
365}
366
367impl Timestamp for Comment {
368 fn created_at(&self) -> String {
369 self.created_at.clone()
370 }
371}
372
373impl From<Comment> for DisplayBody {
374 fn from(comment: Comment) -> Self {
375 DisplayBody::new(vec![
376 Column::new("ID", comment.id.to_string()),
377 Column::new("Body", comment.body),
378 Column::new("Author", comment.author),
379 Column::new("Created at", comment.created_at),
380 ])
381 }
382}
383
384pub fn execute(
385 options: MergeRequestOptions,
386 config: Arc<dyn ConfigProperties>,
387 domain: String,
388 path: String,
389) -> Result<()> {
390 match options {
391 MergeRequestOptions::Create(cli_args) => {
392 let mr_remote = remote::get_mr(
393 domain.clone(),
394 path.clone(),
395 config.clone(),
396 Some(&cli_args.cache_args),
397 CacheType::File,
398 )?;
399 let project_remote = remote::get_project(
400 domain,
401 path,
402 config.clone(),
403 Some(&cli_args.cache_args),
404 CacheType::File,
405 )?;
406 if let Some(commit_message) = &cli_args.commit {
407 git::add(&BlockingCommand)?;
408 git::commit(&BlockingCommand, commit_message)?;
409 }
410 let cmds = if let Some(description_file) = &cli_args.description_from_file {
411 let reader = get_reader_file_cli(description_file)?;
412 cmds(
413 project_remote,
414 &cli_args,
415 Arc::new(BlockingCommand),
416 Some(reader),
417 )
418 } else if let Some(body_from_file) = &cli_args.body_from_file {
419 let reader = get_reader_file_cli(body_from_file)?;
420 cmds(
421 project_remote,
422 &cli_args,
423 Arc::new(BlockingCommand),
424 Some(reader),
425 )
426 } else {
427 cmds(
428 project_remote,
429 &cli_args,
430 Arc::new(BlockingCommand),
431 None::<Cursor<&str>>,
432 )
433 };
434 let mr_body = get_repo_project_info(cmds)?;
435 if cli_args.summary != SummaryOptions::None {
436 return summary(mr_body, &cli_args);
437 }
438 if cli_args.patch {
439 return patch(mr_body, &cli_args);
440 }
441 open(mr_remote, config, mr_body, &cli_args)
442 }
443 MergeRequestOptions::List(cli_args) => list_merge_requests(domain, path, config, cli_args),
444 MergeRequestOptions::Merge { id } => {
445 let remote = remote::get_mr(domain, path, config, None, CacheType::None)?;
446 merge(remote, id)
447 }
448 MergeRequestOptions::Checkout { id } => {
449 let remote = remote::get_mr(domain, path, config, None, CacheType::File)?;
451 checkout(remote, id)
452 }
453 MergeRequestOptions::Close { id } => {
454 let remote = remote::get_mr(domain, path, config, None, CacheType::None)?;
455 close(remote, id)
456 }
457 MergeRequestOptions::CreateComment(cli_args) => {
458 let remote = remote::get_comment_mr(domain, path, config, None, CacheType::None)?;
459 if let Some(comment_file) = &cli_args.comment_from_file {
460 let reader = get_reader_file_cli(comment_file)?;
461 create_comment(remote, cli_args, Some(reader))
462 } else {
463 create_comment(remote, cli_args, None::<Cursor<&str>>)
464 }
465 }
466 MergeRequestOptions::ListComment(cli_args) => {
467 let remote = remote::get_comment_mr(
468 domain,
469 path,
470 config,
471 Some(&cli_args.list_args.get_args.cache_args),
472 CacheType::File,
473 )?;
474 let from_to_args = remote::validate_from_to_page(&cli_args.list_args)?;
475 let body_args = CommentMergeRequestListBodyArgs::builder()
476 .id(cli_args.id)
477 .list_args(from_to_args)
478 .build()?;
479 if cli_args.list_args.num_pages {
480 return common::num_comment_merge_request_pages(
481 remote,
482 body_args,
483 std::io::stdout(),
484 );
485 }
486 if cli_args.list_args.num_resources {
487 return common::num_comment_merge_request_resources(
488 remote,
489 body_args,
490 std::io::stdout(),
491 );
492 }
493 list_comments(remote, body_args, cli_args, std::io::stdout())
494 }
495 MergeRequestOptions::Get(cli_args) => {
496 let remote = remote::get_mr(
497 domain,
498 path,
499 config,
500 Some(&cli_args.get_args.cache_args),
501 CacheType::File,
502 )?;
503 get_merge_request_details(remote, cli_args, std::io::stdout())
504 }
505 MergeRequestOptions::Approve { id } => {
506 let remote = remote::get_mr(domain, path, config, None, CacheType::None)?;
507 approve(remote, id, std::io::stdout())
508 }
509 }
510}
511
512pub fn get_reader_file_cli(file_path: &str) -> Result<Box<dyn BufRead + Send + Sync>> {
513 if file_path == "-" {
514 Ok(Box::new(BufReader::new(std::io::stdin())))
515 } else {
516 let file = File::open(file_path).err_context(GRError::PreconditionNotMet(format!(
517 "Cannot open file {file_path}"
518 )))?;
519 Ok(Box::new(BufReader::new(file)))
520 }
521}
522
523fn get_filter_user(
524 user: &Option<MergeRequestUser>,
525 domain: &str,
526 path: &str,
527 config: &Arc<dyn ConfigProperties>,
528 list_args: &ListRemoteCliArgs,
529) -> Result<Option<Member>> {
530 let member = match user {
531 Some(MergeRequestUser::Me) => Some(get_user(domain, path, config, list_args)?),
532 _ => None,
535 };
536 Ok(member)
537}
538
539pub fn list_merge_requests(
540 domain: String,
541 path: String,
542 config: Arc<dyn ConfigProperties>,
543 cli_args: MergeRequestListCliArgs,
544) -> Result<()> {
545 let author = get_filter_user(
549 &cli_args.author,
550 &domain,
551 &path,
552 &config,
553 &cli_args.list_args,
554 )?;
555
556 let assignee = get_filter_user(
557 &cli_args.assignee,
558 &domain,
559 &path,
560 &config,
561 &cli_args.list_args,
562 )?;
563
564 let reviewer = get_filter_user(
565 &cli_args.reviewer,
566 &domain,
567 &path,
568 &config,
569 &cli_args.list_args,
570 )?;
571
572 let remote = remote::get_mr(
573 domain,
574 path,
575 config,
576 Some(&cli_args.list_args.get_args.cache_args),
577 CacheType::File,
578 )?;
579
580 let from_to_args = remote::validate_from_to_page(&cli_args.list_args)?;
581 let body_args = MergeRequestListBodyArgs::builder()
582 .list_args(from_to_args)
583 .state(cli_args.state)
584 .assignee(assignee)
585 .author(author)
586 .reviewer(reviewer)
587 .build()?;
588 if cli_args.list_args.num_pages {
589 return common::num_merge_request_pages(remote, body_args, std::io::stdout());
590 }
591 if cli_args.list_args.num_resources {
592 return common::num_merge_request_resources(remote, body_args, std::io::stdout());
593 }
594 list(remote, body_args, cli_args, std::io::stdout())
595}
596
597fn get_member(members: &[Member], username: &str) -> Option<Member> {
598 members
599 .iter()
600 .find(|member| member.username == username)
601 .cloned()
602}
603
604fn user_prompt_confirmation(
605 mr_body: &MergeRequestBody,
606 config: Arc<dyn ConfigProperties>,
607 description: String,
608 target_branch: &String,
609 cli_args: &MergeRequestCliArgs,
610) -> Result<MergeRequestBodyArgs> {
611 let mut title = mr_body.repo.title().to_string();
612 if cli_args.draft {
613 title = format!("DRAFT: {title}");
614 }
615 let members = config.merge_request_members();
618 let assignee = if cli_args.assignee.is_some() {
619 get_member(&members, &cli_args.assignee.clone().unwrap())
620 } else {
621 None
622 };
623
624 let reviewer = if cli_args.reviewer.is_some() {
625 get_member(&members, &cli_args.reviewer.clone().unwrap())
626 } else if cli_args.rand_reviewer {
627 let members = config.merge_request_members();
628 let num_members = members.len();
629 if num_members == 0 {
630 None
631 } else {
632 let rand_index = rand::rng().random_range(0..num_members);
633 let rand_user = members[rand_index % num_members].clone();
634 Some(rand_user)
635 }
636 } else {
637 None
638 };
639
640 let user_input = if cli_args.auto {
641 let preferred_assignee_members =
642 [assignee.unwrap_or(config.preferred_assignee_username().unwrap_or_default())];
643 dialog::MergeRequestUserInput::builder()
644 .title(title)
645 .description(description)
646 .assignee(preferred_assignee_members[0].clone())
647 .reviewer(reviewer.unwrap_or_default())
648 .build()
649 .unwrap()
650 } else {
651 dialog::prompt_user_merge_request_info(
652 &title,
653 &description,
654 assignee.as_ref(),
655 reviewer.as_ref(),
656 &config,
657 )?
658 };
659
660 Ok(MergeRequestBodyArgs::builder()
661 .title(user_input.title)
662 .description(user_input.description)
663 .source_branch(mr_body.repo.current_branch().to_string())
664 .target_branch(target_branch.to_string())
665 .assignee(user_input.assignee)
666 .reviewer(user_input.reviewer)
667 .remove_source_branch("true".to_string())
669 .draft(cli_args.draft)
670 .amend(cli_args.amend)
671 .build()?)
672}
673
674fn open(
676 remote: Arc<dyn MergeRequest>,
677 config: Arc<dyn ConfigProperties>,
678 mr_body: MergeRequestBody,
679 cli_args: &MergeRequestCliArgs,
680) -> Result<()> {
681 let source_branch = &mr_body.repo.current_branch();
682 let target_branch = cli_args.target_branch.clone();
683 let target_branch = target_branch.unwrap_or(mr_body.project.default_branch().to_string());
684
685 let description = build_description(
686 mr_body.repo.last_commit_message(),
687 config.merge_request_description_signature(),
688 );
689
690 in_feature_branch(source_branch, &target_branch)?;
692
693 let args = user_prompt_confirmation(&mr_body, config, description, &target_branch, cli_args)?;
695
696 if cli_args.rebase.is_some() {
697 git::rebase(&BlockingCommand, cli_args.rebase.as_ref().unwrap())?;
698 }
699
700 let outgoing_commits = git::outgoing_commits(
701 &BlockingCommand,
702 "origin",
703 &target_branch,
704 &SummaryOptions::Short,
705 )?;
706
707 if outgoing_commits.is_empty() {
708 return Err(GRError::PreconditionNotMet(
709 "No outgoing commits found. Please commit your changes.".to_string(),
710 )
711 .into());
712 }
713
714 if let Ok(()) =
716 dialog::show_summary_merge_request(&outgoing_commits, &args, cli_args.accept_summary)
717 {
718 println!("\nTaking off... 🚀\n");
719 if cli_args.dry_run {
720 println!("Dry run completed. No changes were made.");
721 return Ok(());
722 }
723 git::push(&BlockingCommand, "origin", &mr_body.repo, cli_args.force)?;
724 let merge_request_response = remote.open(args)?;
725 println!("Merge request opened: {}", merge_request_response.web_url);
726 if cli_args.open_browser {
727 open::that(merge_request_response.web_url)?;
728 }
729 }
730 Ok(())
731}
732
733fn summary(mr_body: MergeRequestBody, cli_args: &MergeRequestCliArgs) -> Result<()> {
735 let source_branch = mr_body.repo.current_branch();
736 let target_branch = cli_args.target_branch.clone();
737 let target_branch = target_branch.unwrap_or(mr_body.project.default_branch().to_string());
738
739 in_feature_branch(source_branch, &target_branch)?;
740
741 if cli_args.rebase.is_some() {
742 git::rebase(&BlockingCommand, cli_args.rebase.as_ref().unwrap())?;
743 }
744
745 let outgoing_commits = git::outgoing_commits(
746 &BlockingCommand,
747 "origin",
748 &target_branch,
749 &cli_args.summary,
750 )?;
751
752 if outgoing_commits.is_empty() {
753 return Err(GRError::PreconditionNotMet(
754 "No outgoing commits found. Please commit your changes.".to_string(),
755 )
756 .into());
757 }
758 if cli_args.gpt_prompt {
759 println!("{GPT_PROMPT}");
760 }
761 println!("\n{outgoing_commits}");
762 Ok(())
763}
764
765fn patch(mr_body: MergeRequestBody, cli_args: &MergeRequestCliArgs) -> Result<()> {
767 let source_branch = mr_body.repo.current_branch();
768 let target_branch = cli_args.target_branch.clone();
769 let target_branch = target_branch.unwrap_or(mr_body.project.default_branch().to_string());
770
771 in_feature_branch(source_branch, &target_branch)?;
772
773 if cli_args.rebase.is_some() {
774 git::rebase(&BlockingCommand, cli_args.rebase.as_ref().unwrap())?;
775 }
776 println!(
777 "{}",
778 git::patch(&BlockingCommand, source_branch, target_branch)?
779 );
780 Ok(())
781}
782
783fn cmds<R: BufRead + Send + Sync + 'static>(
785 remote: Arc<dyn RemoteProject + Send + Sync + 'static>,
786 cli_args: &MergeRequestCliArgs,
787 task_runner: Arc<impl TaskRunner<Response = ShellResponse> + Send + Sync + 'static>,
788 reader: Option<R>,
789) -> Vec<Cmd<CmdInfo>> {
790 let remote_cl = remote.clone();
791 let remote_project_cmd = move || -> Result<CmdInfo> { remote_cl.get_project_data(None, None) };
792 let status_runner = task_runner.clone();
793 let git_status_cmd = || -> Result<CmdInfo> { git::status(status_runner) };
794 let current_branch_runner = task_runner.clone();
795 let git_current_branch = || -> Result<CmdInfo> { git::current_branch(current_branch_runner) };
796 let mut cmds: Vec<Cmd<CmdInfo>> = vec![
797 Box::new(remote_project_cmd),
798 Box::new(git_status_cmd),
799 Box::new(git_current_branch),
800 ];
801
802 if cli_args.body_from_file.is_some() {
803 let reader = reader.unwrap();
804 let body_from_file_cmd = move || -> Result<CmdInfo> {
805 let mut description = String::new();
806 let mut lines = reader.lines();
807 let title = lines.next().unwrap_or_else(|| Ok("".to_string()))?;
808 lines.next();
810 for line in lines {
811 let line = line?;
812 description.push_str(&line);
813 description.push('\n');
814 }
815 Ok(CmdInfo::CommitBody(title, description))
816 };
817 cmds.push(Box::new(body_from_file_cmd));
818 } else {
819 let title = cli_args.title.clone();
821 let title = title.unwrap_or("".to_string());
822 let body_from_commit = cli_args.body_from_commit.clone();
823 let description_commit = cli_args.body_from_commit.clone();
827 let commit_summary_runner = task_runner.clone();
828 let git_title_cmd = move || -> Result<CmdInfo> {
829 if title.is_empty() {
830 git::commit_summary(commit_summary_runner, &body_from_commit)
831 } else {
832 Ok(CmdInfo::CommitSummary(title.clone()))
833 }
834 };
835 let description = cli_args.description.clone();
836 let description = description.unwrap_or("".to_string());
837 let commit_msg_runner = task_runner.clone();
838 let git_last_commit_message = move || -> Result<CmdInfo> {
839 if description.is_empty() {
840 if let Some(reader) = reader {
841 let mut description = String::new();
842 for line in reader.lines() {
843 let line = line?;
844 description.push_str(&line);
845 description.push('\n');
846 }
847 Ok(CmdInfo::CommitMessage(description))
848 } else {
849 git::commit_message(commit_msg_runner, &description_commit)
850 }
851 } else {
852 Ok(CmdInfo::CommitMessage(description.clone()))
853 }
854 };
855 cmds.push(Box::new(git_title_cmd));
856 cmds.push(Box::new(git_last_commit_message));
857 }
858 if cli_args.fetch.is_some() {
859 let fetch_runner = task_runner.clone();
860 let remote_alias = cli_args.fetch.as_ref().unwrap().clone();
861 let git_fetch_cmd = || -> Result<CmdInfo> { git::fetch(fetch_runner, remote_alias) };
862 cmds.push(Box::new(git_fetch_cmd));
863 }
864 cmds
865}
866
867fn build_description(description: &str, signature: &str) -> String {
869 if description.is_empty() && signature.is_empty() {
870 return "".to_string();
871 }
872 if description.is_empty() {
873 return signature.to_string();
874 }
875 if signature.is_empty() {
876 return description.to_string();
877 }
878 format!("{description}\n\n{signature}")
879}
880
881#[derive(Builder)]
882struct MergeRequestBody {
883 repo: Repo,
884 project: Project,
885}
886
887impl MergeRequestBody {
888 fn builder() -> MergeRequestBodyBuilder {
889 MergeRequestBodyBuilder::default()
890 }
891}
892
893fn get_repo_project_info(cmds: Vec<Cmd<CmdInfo>>) -> Result<MergeRequestBody> {
894 let mut project = Project::default();
895 let mut repo = git::Repo::default();
896 let cmd_results = exec::parallel_stream(cmds);
897 for cmd_result in cmd_results {
898 match cmd_result {
899 Ok(CmdInfo::Project(project_data)) => {
900 project = project_data;
901 }
902 Ok(CmdInfo::StatusModified(status)) => repo.with_status(status),
903 Ok(CmdInfo::Branch(branch)) => repo.with_branch(&branch),
904 Ok(CmdInfo::CommitSummary(title)) => repo.with_title(&title),
905 Ok(CmdInfo::CommitMessage(message)) => repo.with_last_commit_message(&message),
906 Ok(CmdInfo::CommitBody(title, description)) => {
907 repo.with_title(&title);
908 repo.with_last_commit_message(&description);
909 }
910 Err(e) => return Err(e),
912 _ => {}
913 }
914 }
915 Ok(MergeRequestBody::builder()
916 .repo(repo)
917 .project(project)
918 .build()?)
919}
920
921fn in_feature_branch(current_branch: &str, upstream_branch: &str) -> Result<()> {
923 if current_branch == upstream_branch {
924 let trace = format!(
925 "Current branch {current_branch} is the same as the upstream \
926 remote {upstream_branch}. Please use a feature branch"
927 );
928 return Err(GRError::PreconditionNotMet(trace).into());
929 }
930 match current_branch {
933 "master" | "main" | "develop" => {
934 let trace = format!(
935 "Current branch is {current_branch}, which could be a release upstream branch. \
936 Please use a different feature branch name"
937 );
938 Err(GRError::PreconditionNotMet(trace).into())
939 }
940 _ => Ok(()),
941 }
942}
943
944fn list<W: Write>(
945 remote: Arc<dyn MergeRequest>,
946 body_args: MergeRequestListBodyArgs,
947 cli_args: MergeRequestListCliArgs,
948 mut writer: W,
949) -> Result<()> {
950 common::list_merge_requests(remote, body_args, cli_args, &mut writer)
951}
952
953fn merge(remote: Arc<dyn MergeRequest>, merge_request_id: i64) -> Result<()> {
954 let merge_request = remote.merge(merge_request_id)?;
955 println!("Merge request merged: {}", merge_request.web_url);
956 Ok(())
957}
958
959fn checkout(remote: Arc<dyn MergeRequest>, id: i64) -> Result<()> {
960 let merge_request = remote.get(id)?;
961 git::fetch(Arc::new(BlockingCommand), "origin".to_string())?;
963 git::checkout(&BlockingCommand, &merge_request.source_branch)
964}
965
966fn close(remote: Arc<dyn MergeRequest>, id: i64) -> Result<()> {
967 let merge_request = remote.close(id)?;
968 println!("Merge request closed: {}", merge_request.web_url);
969 Ok(())
970}
971
972fn approve<W: Write>(remote: Arc<dyn MergeRequest>, id: i64, mut writer: W) -> Result<()> {
973 let merge_request = remote.approve(id)?;
974 writer.write_all(format!("Merge request approved: {}\n", merge_request.web_url).as_bytes())?;
975 Ok(())
976}
977
978fn create_comment<R: BufRead>(
979 remote: Arc<dyn CommentMergeRequest>,
980 args: CommentMergeRequestCliArgs,
981 reader: Option<R>,
982) -> Result<()> {
983 let comment = if let Some(comment) = args.comment {
984 comment
985 } else {
986 let mut comment = String::new();
987 reader.unwrap().read_to_string(&mut comment)?;
990 comment
991 };
992 remote.create(
993 CommentMergeRequestBodyArgs::builder()
994 .id(args.id)
995 .comment(comment)
996 .build()
997 .unwrap(),
998 )
999}
1000
1001pub fn get_merge_request_details<W: Write>(
1002 remote: Arc<dyn MergeRequest>,
1003 args: MergeRequestGetCliArgs,
1004 mut writer: W,
1005) -> Result<()> {
1006 let response = remote.get(args.id)?;
1007 display::print(&mut writer, vec![response], args.get_args)?;
1008 Ok(())
1009}
1010
1011fn list_comments<W: Write>(
1012 remote: Arc<dyn CommentMergeRequest>,
1013 body_args: CommentMergeRequestListBodyArgs,
1014 cli_args: CommentMergeRequestListCliArgs,
1015 writer: W,
1016) -> Result<()> {
1017 common::list_merge_request_comments(remote, body_args, cli_args, writer)
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use std::{
1023 io::{Cursor, Read},
1024 sync::Mutex,
1025 };
1026
1027 use crate::{
1028 api_traits::CommentMergeRequest, cli::browse::BrowseOptions,
1029 cmds::project::ProjectListBodyArgs, error,
1030 };
1031
1032 use super::*;
1033
1034 #[test]
1035 fn test_merge_request_args_with_custom_title() {
1036 let args = MergeRequestBodyArgs::builder()
1037 .source_branch("source".to_string())
1038 .target_branch("target".to_string())
1039 .title("title".to_string())
1040 .build()
1041 .unwrap();
1042
1043 assert_eq!(args.source_branch, "source");
1044 assert_eq!(args.target_branch, "target");
1045 assert_eq!(args.title, "title");
1046 assert_eq!(args.remove_source_branch, "true");
1047 assert_eq!(args.description, "");
1048 }
1049
1050 #[test]
1051 fn test_merge_request_get_all_fields() {
1052 let assignee = Member::builder()
1053 .id(1)
1054 .username("username".to_string())
1055 .build()
1056 .unwrap();
1057 let args = MergeRequestBodyArgs::builder()
1058 .source_branch("source".to_string())
1059 .target_branch("target".to_string())
1060 .title("title".to_string())
1061 .description("description".to_string())
1062 .assignee(assignee)
1063 .remove_source_branch("false".to_string())
1064 .build()
1065 .unwrap();
1066
1067 assert_eq!(args.source_branch, "source");
1068 assert_eq!(args.target_branch, "target");
1069 assert_eq!(args.title, "title");
1070 assert_eq!(args.description, "description");
1071 assert_eq!(args.assignee.id, 1);
1072 assert_eq!(args.assignee.username, "username");
1073 assert_eq!(args.remove_source_branch, "false");
1074 }
1075
1076 #[test]
1077 fn test_current_branch_should_not_be_the_upstream_branch() {
1078 let current_branch = "current-branch";
1079 let target_branch = "current-branch";
1080 let result = in_feature_branch(current_branch, target_branch);
1081 assert!(result.is_err());
1082 }
1083
1084 #[test]
1085 fn test_feature_branch_not_main_master_or_develop_is_ok() {
1086 let current_branch = "newfeature";
1087 let target_branch = "main";
1088 let result = in_feature_branch(current_branch, target_branch);
1089 assert!(result.is_ok());
1090 }
1091
1092 #[test]
1093 fn test_feature_branch_is_main_master_or_develop_should_err() {
1094 let test_cases = [
1095 ("main", "upstream-branch"),
1096 ("master", "upstream-branch"),
1097 ("develop", "upstream-branch"),
1098 ];
1099
1100 for (current_branch, upstream_branch) in test_cases {
1101 let result = in_feature_branch(current_branch, upstream_branch);
1102 assert!(result.is_err());
1103 }
1104 }
1105
1106 fn get_cmds_mock(cmd: Arc<CmdMock>) -> Vec<Cmd<CmdInfo>> {
1107 let cmd_status = cmd.clone();
1108 let git_status_cmd =
1109 move || -> Result<CmdInfo> { Ok(CmdInfo::StatusModified(cmd_status.status_modified)) };
1110 let title_cmd = cmd.clone();
1111 let git_title_cmd = move || -> Result<CmdInfo> {
1112 Ok(CmdInfo::CommitSummary(
1113 title_cmd.last_commit_summary.clone(),
1114 ))
1115 };
1116 let message_cmd = cmd.clone();
1117 let git_message_cmd = move || -> Result<CmdInfo> {
1118 Ok(CmdInfo::CommitMessage(
1119 message_cmd.last_commit_message.clone(),
1120 ))
1121 };
1122 let branch_cmd = cmd.clone();
1123 let git_current_branch =
1124 move || -> Result<CmdInfo> { Ok(CmdInfo::Branch(branch_cmd.current_branch.clone())) };
1125 let project_cmd = cmd.clone();
1126 let remote_project_cmd =
1127 move || -> Result<CmdInfo> { Ok(CmdInfo::Project(project_cmd.project.clone())) };
1128 let members_cmd = cmd.clone();
1129 let remote_members_cmd =
1130 move || -> Result<CmdInfo> { Ok(CmdInfo::Members(members_cmd.members.clone())) };
1131 let mut cmds: Vec<Cmd<CmdInfo>> = vec![
1132 Box::new(remote_project_cmd),
1133 Box::new(remote_members_cmd),
1134 Box::new(git_status_cmd),
1135 Box::new(git_title_cmd),
1136 Box::new(git_message_cmd),
1137 Box::new(git_current_branch),
1138 ];
1139 if cmd.error {
1140 let error_cmd =
1141 move || -> Result<CmdInfo> { Err(error::gen("Failure retrieving data")) };
1142 cmds.push(Box::new(error_cmd));
1143 }
1144 cmds
1145 }
1146
1147 #[derive(Clone, Builder)]
1148 struct CmdMock {
1149 #[builder(default = "false")]
1150 status_modified: bool,
1151 last_commit_summary: String,
1152 current_branch: String,
1153 last_commit_message: String,
1154 members: Vec<Member>,
1155 project: Project,
1156 #[builder(default = "false")]
1157 error: bool,
1158 }
1159
1160 #[test]
1161 fn test_get_repo_project_info() {
1162 let cmd_mock = CmdMockBuilder::default()
1163 .status_modified(true)
1164 .current_branch("current-branch".to_string())
1165 .last_commit_summary("title".to_string())
1166 .last_commit_message("last-commit-message".to_string())
1167 .members(Vec::new())
1168 .project(Project::default())
1169 .build()
1170 .unwrap();
1171 let cmds = get_cmds_mock(Arc::new(cmd_mock));
1172 let result = get_repo_project_info(cmds);
1173 assert!(result.is_ok());
1174 let result = result.unwrap();
1175 assert_eq!(result.repo.title(), "title");
1176 assert_eq!(result.repo.current_branch(), "current-branch");
1177 assert_eq!(result.repo.last_commit_message(), "last-commit-message");
1178 }
1179
1180 #[test]
1181 fn test_get_repo_project_info_cmds_error() {
1182 let cmd_mock = CmdMockBuilder::default()
1183 .status_modified(true)
1184 .current_branch("current-branch".to_string())
1185 .last_commit_summary("title".to_string())
1186 .last_commit_message("last-commit-message".to_string())
1187 .members(Vec::new())
1188 .project(Project::default())
1189 .error(true)
1190 .build()
1191 .unwrap();
1192 let cmds = get_cmds_mock(Arc::new(cmd_mock));
1193 let result = get_repo_project_info(cmds);
1194 assert!(result.is_err());
1195 }
1196
1197 #[test]
1198 fn test_get_description_signature() {
1199 let description_signature_table = [
1200 ("", "", ""),
1201 ("", "signature", "signature"),
1202 ("description", "", "description"),
1203 ("description", "signature", "description\n\nsignature"),
1204 ];
1205 for (description, signature, expected) in description_signature_table {
1206 let result = build_description(description, signature);
1207 assert_eq!(result, expected);
1208 }
1209 }
1210
1211 #[test]
1212 fn test_list_merge_requests() {
1213 let remote = Arc::new(
1214 MergeRequestRemoteMock::builder()
1215 .merge_requests(vec![MergeRequestResponse::builder()
1216 .id(1)
1217 .title("New feature".to_string())
1218 .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1219 .author("author".to_string())
1220 .updated_at("2021-01-01".to_string())
1221 .build()
1222 .unwrap()])
1223 .build()
1224 .unwrap(),
1225 );
1226 let mut buf = Vec::new();
1227 let body_args = MergeRequestListBodyArgs::builder()
1228 .list_args(None)
1229 .state(MergeRequestState::Opened)
1230 .assignee(None)
1231 .build()
1232 .unwrap();
1233 let cli_args = MergeRequestListCliArgs::new(
1234 MergeRequestState::Opened,
1235 ListRemoteCliArgs::builder().build().unwrap(),
1236 );
1237 list(remote, body_args, cli_args, &mut buf).unwrap();
1238 assert_eq!(
1239 "ID|Title|Source Branch|Author|URL|Updated at\n\
1240 1|New feature||author|https://gitlab.com/owner/repo/-/merge_requests/1|2021-01-01\n",
1241 String::from_utf8(buf).unwrap(),
1242 )
1243 }
1244
1245 #[test]
1246 fn test_if_no_merge_requests_are_available_list_should_return_no_merge_requests_found() {
1247 let remote = Arc::new(MergeRequestRemoteMock::builder().build().unwrap());
1248 let mut buf = Vec::new();
1249 let body_args = MergeRequestListBodyArgs::builder()
1250 .list_args(None)
1251 .state(MergeRequestState::Opened)
1252 .assignee(None)
1253 .build()
1254 .unwrap();
1255 let cli_args = MergeRequestListCliArgs::new(
1256 MergeRequestState::Opened,
1257 ListRemoteCliArgs::builder().build().unwrap(),
1258 );
1259 list(remote, body_args, cli_args, &mut buf).unwrap();
1260 assert_eq!("No resources found.\n", String::from_utf8(buf).unwrap(),)
1261 }
1262
1263 #[test]
1264 fn test_list_merge_requests_empty_with_flush_option_no_warn_message() {
1265 let remote = Arc::new(MergeRequestRemoteMock::builder().build().unwrap());
1266 let mut buf = Vec::new();
1267 let body_args = MergeRequestListBodyArgs::builder()
1268 .list_args(None)
1269 .state(MergeRequestState::Opened)
1270 .assignee(None)
1271 .build()
1272 .unwrap();
1273 let cli_args = MergeRequestListCliArgs::new(
1274 MergeRequestState::Opened,
1275 ListRemoteCliArgs::builder().flush(true).build().unwrap(),
1276 );
1277 list(remote, body_args, cli_args, &mut buf).unwrap();
1278 assert_eq!("", String::from_utf8(buf).unwrap());
1279 }
1280
1281 #[test]
1282 fn test_list_merge_requests_no_headers() {
1283 let remote = Arc::new(
1284 MergeRequestRemoteMock::builder()
1285 .merge_requests(vec![MergeRequestResponse::builder()
1286 .id(1)
1287 .title("New feature".to_string())
1288 .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1289 .author("author".to_string())
1290 .updated_at("2021-01-01".to_string())
1291 .build()
1292 .unwrap()])
1293 .build()
1294 .unwrap(),
1295 );
1296 let mut buf = Vec::new();
1297 let body_args = MergeRequestListBodyArgs::builder()
1298 .list_args(None)
1299 .state(MergeRequestState::Opened)
1300 .assignee(None)
1301 .build()
1302 .unwrap();
1303 let cli_args = MergeRequestListCliArgs::new(
1304 MergeRequestState::Opened,
1305 ListRemoteCliArgs::builder()
1306 .get_args(
1307 GetRemoteCliArgs::builder()
1308 .no_headers(true)
1309 .build()
1310 .unwrap(),
1311 )
1312 .build()
1313 .unwrap(),
1314 );
1315 list(remote, body_args, cli_args, &mut buf).unwrap();
1316 assert_eq!(
1317 "1|New feature||author|https://gitlab.com/owner/repo/-/merge_requests/1|2021-01-01\n",
1318 String::from_utf8(buf).unwrap(),
1319 )
1320 }
1321
1322 #[derive(Clone, Builder)]
1323 struct MergeRequestRemoteMock {
1324 #[builder(default = "Vec::new()")]
1325 merge_requests: Vec<MergeRequestResponse>,
1326 }
1327
1328 impl MergeRequestRemoteMock {
1329 pub fn builder() -> MergeRequestRemoteMockBuilder {
1330 MergeRequestRemoteMockBuilder::default()
1331 }
1332 }
1333
1334 impl MergeRequest for MergeRequestRemoteMock {
1335 fn open(&self, _args: MergeRequestBodyArgs) -> Result<MergeRequestResponse> {
1336 Ok(MergeRequestResponse::builder().build().unwrap())
1337 }
1338 fn list(&self, _args: MergeRequestListBodyArgs) -> Result<Vec<MergeRequestResponse>> {
1339 Ok(self.merge_requests.clone())
1340 }
1341 fn merge(&self, _id: i64) -> Result<MergeRequestResponse> {
1342 Ok(MergeRequestResponse::builder().build().unwrap())
1343 }
1344 fn get(&self, _id: i64) -> Result<MergeRequestResponse> {
1345 Ok(self.merge_requests[0].clone())
1346 }
1347 fn close(&self, _id: i64) -> Result<MergeRequestResponse> {
1348 Ok(MergeRequestResponse::builder().build().unwrap())
1349 }
1350 fn num_pages(&self, _args: MergeRequestListBodyArgs) -> Result<Option<u32>> {
1351 Ok(None)
1352 }
1353 fn approve(&self, _id: i64) -> Result<MergeRequestResponse> {
1354 Ok(self.merge_requests[0].clone())
1355 }
1356
1357 fn num_resources(
1358 &self,
1359 _args: MergeRequestListBodyArgs,
1360 ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
1361 todo!()
1362 }
1363 }
1364
1365 #[derive(Default)]
1366 struct MockRemoteProject {
1367 comment_called: Mutex<bool>,
1368 comment_argument: Mutex<String>,
1369 list_comments: Vec<Comment>,
1370 }
1371
1372 impl MockRemoteProject {
1373 fn new(comments: Vec<Comment>) -> MockRemoteProject {
1374 MockRemoteProject {
1375 comment_called: Mutex::new(false),
1376 comment_argument: Mutex::new("".to_string()),
1377 list_comments: comments,
1378 }
1379 }
1380 }
1381
1382 impl RemoteProject for MockRemoteProject {
1383 fn get_project_data(&self, _id: Option<i64>, _path: Option<&str>) -> Result<CmdInfo> {
1384 let project = Project::new(1, "main");
1385 Ok(CmdInfo::Project(project))
1386 }
1387
1388 fn get_project_members(&self) -> Result<CmdInfo> {
1389 let members = vec![
1390 Member::builder()
1391 .id(1)
1392 .username("user1".to_string())
1393 .name("User 1".to_string())
1394 .build()
1395 .unwrap(),
1396 Member::builder()
1397 .id(2)
1398 .username("user2".to_string())
1399 .name("User 2".to_string())
1400 .build()
1401 .unwrap(),
1402 ];
1403 Ok(CmdInfo::Members(members))
1404 }
1405
1406 fn get_url(&self, _option: BrowseOptions) -> String {
1407 todo!()
1408 }
1409
1410 fn list(&self, _args: ProjectListBodyArgs) -> Result<Vec<Project>> {
1411 todo!()
1412 }
1413
1414 fn num_pages(&self, _args: ProjectListBodyArgs) -> Result<Option<u32>> {
1415 todo!()
1416 }
1417
1418 fn num_resources(
1419 &self,
1420 _args: ProjectListBodyArgs,
1421 ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
1422 todo!()
1423 }
1424 }
1425
1426 impl CommentMergeRequest for MockRemoteProject {
1427 fn create(&self, args: CommentMergeRequestBodyArgs) -> Result<()> {
1428 let mut called = self.comment_called.lock().unwrap();
1429 *called = true;
1430 let mut argument = self.comment_argument.lock().unwrap();
1431 *argument = args.comment;
1432 Ok(())
1433 }
1434
1435 fn list(&self, _args: CommentMergeRequestListBodyArgs) -> Result<Vec<Comment>> {
1436 Ok(self.list_comments.clone())
1437 }
1438
1439 fn num_pages(&self, _args: CommentMergeRequestListBodyArgs) -> Result<Option<u32>> {
1440 todo!()
1441 }
1442
1443 fn num_resources(
1444 &self,
1445 _args: CommentMergeRequestListBodyArgs,
1446 ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
1447 todo!()
1448 }
1449 }
1450
1451 struct MockShellRunner {
1452 responses: Mutex<Vec<ShellResponse>>,
1453 }
1454
1455 impl MockShellRunner {
1456 pub fn new(response: Vec<ShellResponse>) -> MockShellRunner {
1457 MockShellRunner {
1458 responses: Mutex::new(response),
1459 }
1460 }
1461 }
1462
1463 impl TaskRunner for MockShellRunner {
1464 type Response = ShellResponse;
1465
1466 fn run<T>(&self, _cmd: T) -> Result<Self::Response>
1467 where
1468 T: IntoIterator,
1469 T::Item: AsRef<std::ffi::OsStr>,
1470 {
1471 let response = self.responses.lock().unwrap().pop().unwrap();
1472 Ok(ShellResponse::builder()
1473 .body(response.body)
1474 .build()
1475 .unwrap())
1476 }
1477 }
1478
1479 fn gen_cmd_responses() -> Vec<ShellResponse> {
1480 let responses = vec![
1481 ShellResponse::builder()
1482 .body("fetch cmd".to_string())
1483 .build()
1484 .unwrap(),
1485 ShellResponse::builder()
1486 .body("last commit message cmd".to_string())
1487 .build()
1488 .unwrap(),
1489 ShellResponse::builder()
1490 .body("title git cmd".to_string())
1491 .build()
1492 .unwrap(),
1493 ShellResponse::builder()
1494 .body("current branch cmd".to_string())
1495 .build()
1496 .unwrap(),
1497 ShellResponse::builder()
1498 .body("status cmd".to_string())
1499 .build()
1500 .unwrap(),
1501 ];
1502 responses
1503 }
1504
1505 #[test]
1506 fn test_cmds_gather_title_from_cli_arg() {
1507 let remote = Arc::new(MockRemoteProject::default());
1508 let cli_args = MergeRequestCliArgs::builder()
1509 .title(Some("title cli".to_string()))
1510 .body_from_commit(None)
1511 .description(None)
1512 .description_from_file(None)
1513 .target_branch(Some("target-branch".to_string()))
1514 .auto(false)
1515 .cache_args(CacheCliArgs::default())
1516 .open_browser(false)
1517 .accept_summary(false)
1518 .commit(Some("commit".to_string()))
1519 .draft(false)
1520 .force(false)
1521 .amend(false)
1522 .dry_run(false)
1523 .build()
1524 .unwrap();
1525
1526 let responses = gen_cmd_responses();
1527
1528 let task_runner = Arc::new(MockShellRunner::new(responses));
1529 let cmds = cmds(remote, &cli_args, task_runner, None::<Cursor<&str>>);
1530 assert_eq!(cmds.len(), 5);
1531 let cmds = cmds
1532 .into_iter()
1533 .map(|cmd| cmd())
1534 .collect::<Result<Vec<CmdInfo>>>()
1535 .unwrap();
1536 let title_result = cmds[3].clone();
1537 let title = match title_result {
1538 CmdInfo::CommitSummary(title) => title,
1539 _ => "".to_string(),
1540 };
1541 assert_eq!("title cli", title);
1542 }
1543
1544 #[test]
1545 fn test_cmds_gather_title_from_git_commit_summary() {
1546 let remote = Arc::new(MockRemoteProject::default());
1547 let cli_args = MergeRequestCliArgs::builder()
1548 .title(None)
1549 .body_from_commit(None)
1550 .description(None)
1551 .description_from_file(None)
1552 .target_branch(Some("target-branch".to_string()))
1553 .auto(false)
1554 .cache_args(CacheCliArgs::default())
1555 .open_browser(false)
1556 .accept_summary(false)
1557 .commit(None)
1558 .draft(false)
1559 .force(false)
1560 .amend(false)
1561 .dry_run(false)
1562 .build()
1563 .unwrap();
1564
1565 let responses = gen_cmd_responses();
1566 let task_runner = Arc::new(MockShellRunner::new(responses));
1567 let cmds = cmds(remote, &cli_args, task_runner, None::<Cursor<&str>>);
1568 let results = cmds
1569 .into_iter()
1570 .map(|cmd| cmd())
1571 .collect::<Result<Vec<CmdInfo>>>()
1572 .unwrap();
1573 let title_result = results[3].clone();
1574 let title = match title_result {
1575 CmdInfo::CommitSummary(title) => title,
1576 _ => "".to_string(),
1577 };
1578 assert_eq!("title git cmd", title);
1579 }
1580
1581 #[test]
1582 fn test_read_description_from_file() {
1583 let remote = Arc::new(MockRemoteProject::default());
1584 let cli_args = MergeRequestCliArgs::builder()
1585 .title(None)
1586 .body_from_commit(None)
1587 .description(None)
1588 .description_from_file(Some("description_file.txt".to_string()))
1589 .target_branch(Some("target-branch".to_string()))
1590 .auto(false)
1591 .cache_args(CacheCliArgs::default())
1592 .open_browser(false)
1593 .accept_summary(false)
1594 .commit(None)
1595 .draft(false)
1596 .force(false)
1597 .amend(false)
1598 .dry_run(false)
1599 .build()
1600 .unwrap();
1601
1602 let responses = gen_cmd_responses();
1603
1604 let task_runner = Arc::new(MockShellRunner::new(responses));
1605
1606 let description_contents = "This merge requests adds a new feature\n";
1607 let reader = Cursor::new(description_contents);
1608 let cmds = cmds(remote, &cli_args, task_runner, Some(reader));
1609 let results = cmds
1610 .into_iter()
1611 .map(|cmd| cmd())
1612 .collect::<Result<Vec<CmdInfo>>>()
1613 .unwrap();
1614 let description_result = results[4].clone();
1615 let description = match description_result {
1616 CmdInfo::CommitMessage(description) => description,
1617 _ => "".to_string(),
1618 };
1619 assert_eq!(description_contents, description);
1620 }
1621
1622 #[test]
1623 fn test_create_comment_on_a_merge_request_with_cli_comment_ok() {
1624 let remote = Arc::new(MockRemoteProject::default());
1625 let cli_args = CommentMergeRequestCliArgs::builder()
1626 .id(1)
1627 .comment(Some("All features complete, ship it".to_string()))
1628 .comment_from_file(None)
1629 .build()
1630 .unwrap();
1631 let reader = Cursor::new("comment");
1632 assert!(create_comment(remote.clone(), cli_args, Some(reader)).is_ok());
1633 assert!(*remote.comment_called.lock().unwrap());
1634 assert_eq!(
1635 "All features complete, ship it",
1636 remote.comment_argument.lock().unwrap().clone(),
1637 );
1638 }
1639
1640 #[test]
1641 fn test_create_comment_on_a_merge_request_with_comment_from_file_ok() {
1642 let remote = Arc::new(MockRemoteProject::default());
1643 let cli_args = CommentMergeRequestCliArgs::builder()
1644 .id(1)
1645 .comment(None)
1646 .comment_from_file(Some("comment_file.txt".to_string()))
1647 .build()
1648 .unwrap();
1649 let reader = Cursor::new("Just a long, long comment from a file");
1650 assert!(create_comment(remote.clone(), cli_args, Some(reader)).is_ok());
1651 assert!(*remote.comment_called.lock().unwrap());
1652 assert_eq!(
1653 "Just a long, long comment from a file",
1654 remote.comment_argument.lock().unwrap().clone(),
1655 );
1656 }
1657
1658 struct ErrorReader {}
1659
1660 impl Read for ErrorReader {
1661 fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1662 Err(std::io::Error::other("Error reading from reader"))
1663 }
1664 }
1665
1666 impl BufRead for ErrorReader {
1667 fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
1668 Err(std::io::Error::other("Error reading from reader"))
1669 }
1670 fn consume(&mut self, _amt: usize) {}
1671 }
1672
1673 #[test]
1674 fn test_create_comment_on_a_merge_request_fail_to_read_comment_from_file() {
1675 let remote = Arc::new(MockRemoteProject::default());
1676 let cli_args = CommentMergeRequestCliArgs::builder()
1677 .id(1)
1678 .comment(None)
1679 .comment_from_file(Some("comment_file.txt".to_string()))
1680 .build()
1681 .unwrap();
1682 let reader = ErrorReader {};
1683 assert!(create_comment(remote.clone(), cli_args, Some(reader)).is_err());
1684 }
1685
1686 #[test]
1687 fn test_get_merge_request_details() {
1688 let cli_args = MergeRequestGetCliArgs::builder()
1689 .id(1)
1690 .get_args(
1691 GetRemoteCliArgs::builder()
1692 .display_optional(true)
1693 .build()
1694 .unwrap(),
1695 )
1696 .build()
1697 .unwrap();
1698 let response = MergeRequestResponse::builder()
1699 .id(1)
1700 .title("New feature".to_string())
1701 .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1702 .description("Implement get merge request".to_string())
1703 .merged_at("2024-03-03T00:00:00Z".to_string())
1704 .pipeline_id(Some(1))
1705 .pipeline_url(Some(
1706 "https://gitlab.com/owner/repo/-/pipelines/1".to_string(),
1707 ))
1708 .build()
1709 .unwrap();
1710 let remote = Arc::new(
1711 MergeRequestRemoteMock::builder()
1712 .merge_requests(vec![response])
1713 .build()
1714 .unwrap(),
1715 );
1716 let mut writer = Vec::new();
1717 get_merge_request_details(remote, cli_args, &mut writer).unwrap();
1718 assert_eq!(
1719 "ID|Title|Source Branch|SHA|Description|Author|URL|Updated at|Merged at|Pipeline ID|Pipeline URL\n\
1720 1|New feature|||Implement get merge request||https://gitlab.com/owner/repo/-/merge_requests/1||2024-03-03T00:00:00Z|1|https://gitlab.com/owner/repo/-/pipelines/1\n",
1721 String::from_utf8(writer).unwrap(),
1722 )
1723 }
1724
1725 #[test]
1726 fn test_approve_merge_request_ok() {
1727 let approve_response = MergeRequestResponse::builder()
1728 .id(1)
1729 .web_url("https://gitlab.com/owner/repo/-/merge_requests/1".to_string())
1730 .build()
1731 .unwrap();
1732 let remote = Arc::new(
1733 MergeRequestRemoteMock::builder()
1734 .merge_requests(vec![approve_response])
1735 .build()
1736 .unwrap(),
1737 );
1738 let mut writer = Vec::new();
1739 approve(remote, 1, &mut writer).unwrap();
1740 assert_eq!(
1741 "Merge request approved: https://gitlab.com/owner/repo/-/merge_requests/1\n",
1742 String::from_utf8(writer).unwrap(),
1743 );
1744 }
1745
1746 #[test]
1747 fn test_cmds_fetch_cli_arg() {
1748 let remote = Arc::new(MockRemoteProject::default());
1749 let cli_args = MergeRequestCliArgs::builder()
1750 .title(Some("title cli".to_string()))
1751 .body_from_commit(None)
1752 .description(None)
1753 .description_from_file(None)
1754 .target_branch(Some("target-branch".to_string()))
1755 .fetch(Some("origin".to_string()))
1756 .auto(false)
1757 .cache_args(CacheCliArgs::default())
1758 .open_browser(false)
1759 .accept_summary(false)
1760 .commit(Some("commit".to_string()))
1761 .draft(false)
1762 .force(false)
1763 .amend(false)
1764 .dry_run(false)
1765 .build()
1766 .unwrap();
1767
1768 let responses = gen_cmd_responses();
1769
1770 let task_runner = Arc::new(MockShellRunner::new(responses));
1771 let cmds = cmds(remote, &cli_args, task_runner, None::<Cursor<&str>>);
1772 assert_eq!(cmds.len(), 6);
1773 let cmds = cmds
1774 .into_iter()
1775 .map(|cmd| cmd())
1776 .collect::<Result<Vec<CmdInfo>>>()
1777 .unwrap();
1778 let fetch_result = cmds[5].clone();
1779 match fetch_result {
1780 CmdInfo::Ignore => {}
1781 _ => panic!("Expected ignore cmdinfo variant on fetch"),
1782 };
1783 }
1784
1785 #[test]
1786 fn test_list_merge_request_comments() {
1787 let comments = vec![
1788 Comment::builder()
1789 .id(1)
1790 .body("Great work!".to_string())
1791 .author("user1".to_string())
1792 .created_at("2021-01-01".to_string())
1793 .build()
1794 .unwrap(),
1795 Comment::builder()
1796 .id(2)
1797 .body("Keep it up!".to_string())
1798 .author("user2".to_string())
1799 .created_at("2021-01-02".to_string())
1800 .build()
1801 .unwrap(),
1802 ];
1803 let remote = Arc::new(MockRemoteProject::new(comments));
1804 let body_args = CommentMergeRequestListBodyArgs::builder()
1805 .id(1)
1806 .list_args(None)
1807 .build()
1808 .unwrap();
1809 let cli_args = CommentMergeRequestListCliArgs::builder()
1810 .id(1)
1811 .list_args(ListRemoteCliArgs::builder().build().unwrap())
1812 .build()
1813 .unwrap();
1814 let mut buf = Vec::new();
1815 list_comments(remote, body_args, cli_args, &mut buf).unwrap();
1816 assert_eq!(
1817 "ID|Body|Author|Created at\n\
1818 1|Great work!|user1|2021-01-01\n\
1819 2|Keep it up!|user2|2021-01-02\n",
1820 String::from_utf8(buf).unwrap(),
1821 );
1822 }
1823
1824 #[test]
1825 fn test_gather_member_from_members_list() {
1826 let members = vec![
1827 Member::builder()
1828 .id(1)
1829 .username("user1".to_string())
1830 .name("User 1".to_string())
1831 .build()
1832 .unwrap(),
1833 Member::builder()
1834 .id(2)
1835 .username("user2".to_string())
1836 .name("User 2".to_string())
1837 .build()
1838 .unwrap(),
1839 ];
1840 let assignee_username = "user2";
1841 let member = get_member(&members, assignee_username).unwrap();
1842 assert_eq!(member.username, assignee_username);
1843 assert_eq!(member.id, 2);
1844 }
1845}