gr/cli/
cicd.rs

1use clap::{Parser, ValueEnum};
2
3use crate::{
4    cmds::cicd::{
5        mermaid::ChartType, JobListCliArgs, LintFilePathArgs, RunnerListCliArgs,
6        RunnerMetadataGetCliArgs, RunnerPostDataCliArgs, RunnerStatus, RunnerType,
7    },
8    remote::ListRemoteCliArgs,
9};
10
11use super::common::{GetArgs, ListArgs};
12
13#[derive(Parser)]
14pub struct PipelineCommand {
15    #[clap(subcommand)]
16    subcommand: PipelineSubcommand,
17}
18
19#[derive(Parser)]
20enum PipelineSubcommand {
21    #[clap(about = "Lint ci yml files. Default is .gitlab-ci.yml")]
22    Lint(FilePathArgs),
23    #[clap(
24        about = "Get merged .gitlab-ci.yml. Total .gitlab-ci.yml result of merging included yaml pipeline files in the repository"
25    )]
26    MergedCi,
27    #[clap(about = "Create a Mermaid diagram of the .gitlab-ci.yml pipeline")]
28    Chart(ChartArgs),
29    #[clap(about = "List pipelines")]
30    List(ListArgs),
31    #[clap(subcommand, name = "jb", about = "Job operations")]
32    Jobs(JobsSubCommand),
33    #[clap(subcommand, name = "rn", about = "Runner operations")]
34    Runners(RunnerSubCommand),
35}
36
37#[derive(Parser)]
38enum JobsSubCommand {
39    #[clap(about = "List jobs")]
40    List(ListJob),
41}
42
43#[derive(Parser)]
44struct ListJob {
45    #[command(flatten)]
46    list_args: ListArgs,
47}
48
49#[derive(Parser)]
50struct FilePathArgs {
51    /// Path to the ci yml file.
52    #[clap(default_value = ".gitlab-ci.yml")]
53    path: String,
54}
55
56#[derive(Parser)]
57struct ChartArgs {
58    /// Chart variant. Stages with jobs, stages or just jobs
59    #[clap(long, default_value = "stageswithjobs")]
60    chart_type: ChartTypeCli,
61}
62
63#[derive(ValueEnum, Clone, PartialEq, Debug)]
64enum ChartTypeCli {
65    #[clap(name = "stageswithjobs")]
66    StagesWithJobs,
67    Jobs,
68    Stages,
69}
70
71#[derive(Parser)]
72enum RunnerSubCommand {
73    #[clap(about = "List runners")]
74    List(ListRunner),
75    #[clap(about = "Get runner metadata")]
76    Get(RunnerMetadata),
77    #[clap(about = "Create a new runner")]
78    Create(RunnerPostData),
79}
80
81#[derive(ValueEnum, Clone, PartialEq, Debug)]
82enum RunnerStatusCli {
83    Online,
84    Offline,
85    Stale,
86    NeverContacted,
87    All,
88}
89
90#[derive(Parser)]
91struct ListRunner {
92    /// Runner status
93    #[clap()]
94    status: RunnerStatusCli,
95    /// Comma separated list of tags
96    #[clap(long, value_delimiter = ',', help_heading = "Runner options")]
97    tags: Option<Vec<String>>,
98    /// List all runners available across all projects. Gitlab admins only.
99    #[clap(long, help_heading = "Runner options")]
100    all: bool,
101    #[command(flatten)]
102    list_args: ListArgs,
103}
104
105#[derive(Parser)]
106struct RunnerMetadata {
107    /// Runner ID
108    #[clap()]
109    id: i64,
110    #[clap(flatten)]
111    get_args: GetArgs,
112}
113
114#[derive(Parser, Default)]
115struct RunnerPostData {
116    /// Runner description
117    #[clap(long)]
118    description: Option<String>,
119    /// Runner tags. Comma separated list of tags
120    #[clap(long, value_delimiter = ',')]
121    tags: Option<Vec<String>>,
122    /// Runner type
123    #[clap(long)]
124    kind: RunnerTypeCli,
125    #[clap(long)]
126    /// Run untagged
127    run_untagged: bool,
128    /// Project id. Required if runner type is project
129    #[clap(long, group = "runner_target_id")]
130    project_id: Option<i64>,
131    /// Group id. Required if runner type is group
132    #[clap(long, group = "runner_target_id")]
133    group_id: Option<i64>,
134}
135
136impl RunnerPostData {
137    fn validate_runner_type_id(&self) -> Result<(), String> {
138        if self.kind == RunnerTypeCli::Project && self.project_id.is_none() {
139            return Err("error: project id is required for project runner".to_string());
140        }
141        if self.kind == RunnerTypeCli::Group && self.group_id.is_none() {
142            return Err("error: group id is required for group runner".to_string());
143        }
144        if self.kind == RunnerTypeCli::Instance
145            && (self.project_id.is_some() || self.group_id.is_some())
146        {
147            return Err(
148                "error: project id and group id are not required for instance runner".to_string(),
149            );
150        }
151        Ok(())
152    }
153}
154
155#[derive(ValueEnum, Clone, PartialEq, Debug, Default)]
156enum RunnerTypeCli {
157    #[default]
158    Instance,
159    Group,
160    Project,
161}
162
163impl From<ChartTypeCli> for ChartType {
164    fn from(chart_type: ChartTypeCli) -> Self {
165        match chart_type {
166            ChartTypeCli::StagesWithJobs => ChartType::StagesWithJobs,
167            ChartTypeCli::Jobs => ChartType::Jobs,
168            ChartTypeCli::Stages => ChartType::Stages,
169        }
170    }
171}
172
173impl From<ChartArgs> for ChartType {
174    fn from(args: ChartArgs) -> Self {
175        args.chart_type.into()
176    }
177}
178
179impl From<ChartArgs> for PipelineOptions {
180    fn from(options: ChartArgs) -> Self {
181        PipelineOptions::Chart(options.into())
182    }
183}
184
185impl From<PipelineCommand> for PipelineOptions {
186    fn from(options: PipelineCommand) -> Self {
187        match options.subcommand {
188            PipelineSubcommand::Lint(options) => options.into(),
189            PipelineSubcommand::MergedCi => PipelineOptions::MergedCi,
190            PipelineSubcommand::Chart(options) => PipelineOptions::Chart(options.into()),
191            PipelineSubcommand::List(options) => options.into(),
192            PipelineSubcommand::Runners(options) => options.into(),
193            PipelineSubcommand::Jobs(options) => options.into(),
194        }
195    }
196}
197
198impl From<FilePathArgs> for PipelineOptions {
199    fn from(options: FilePathArgs) -> Self {
200        PipelineOptions::Lint(options.into())
201    }
202}
203
204impl From<FilePathArgs> for LintFilePathArgs {
205    fn from(options: FilePathArgs) -> Self {
206        LintFilePathArgs::builder()
207            .path(options.path)
208            .build()
209            .unwrap()
210    }
211}
212
213impl From<ListArgs> for PipelineOptions {
214    fn from(options: ListArgs) -> Self {
215        PipelineOptions::List(options.into())
216    }
217}
218
219impl From<RunnerSubCommand> for PipelineOptions {
220    fn from(options: RunnerSubCommand) -> Self {
221        match options {
222            RunnerSubCommand::List(options) => PipelineOptions::Runners(options.into()),
223            RunnerSubCommand::Get(options) => PipelineOptions::Runners(options.into()),
224            RunnerSubCommand::Create(options) => PipelineOptions::Runners(options.into()),
225        }
226    }
227}
228
229impl From<RunnerStatusCli> for RunnerStatus {
230    fn from(status: RunnerStatusCli) -> Self {
231        match status {
232            RunnerStatusCli::Online => RunnerStatus::Online,
233            RunnerStatusCli::Offline => RunnerStatus::Offline,
234            RunnerStatusCli::Stale => RunnerStatus::Stale,
235            RunnerStatusCli::NeverContacted => RunnerStatus::NeverContacted,
236            RunnerStatusCli::All => RunnerStatus::All,
237        }
238    }
239}
240
241impl From<ListRunner> for RunnerOptions {
242    fn from(options: ListRunner) -> Self {
243        RunnerOptions::List(
244            RunnerListCliArgs::builder()
245                .status(options.status.into())
246                .tags(options.tags.map(|tags| tags.join(",").to_string()))
247                .all(options.all)
248                .list_args(options.list_args.into())
249                .build()
250                .unwrap(),
251        )
252    }
253}
254
255impl From<RunnerMetadata> for RunnerOptions {
256    fn from(options: RunnerMetadata) -> Self {
257        RunnerOptions::Get(
258            RunnerMetadataGetCliArgs::builder()
259                .id(options.id)
260                .get_args(options.get_args.into())
261                .build()
262                .unwrap(),
263        )
264    }
265}
266
267impl From<RunnerPostData> for RunnerOptions {
268    fn from(options: RunnerPostData) -> Self {
269        if let Err(e) = options.validate_runner_type_id() {
270            eprintln!("{e}");
271            std::process::exit(2);
272        };
273        RunnerOptions::Create(
274            RunnerPostDataCliArgs::builder()
275                .description(options.description)
276                .tags(options.tags.map(|tags| tags.join(",").to_string()))
277                .kind(options.kind.into())
278                .run_untagged(options.run_untagged)
279                .project_id(options.project_id)
280                .group_id(options.group_id)
281                .build()
282                .unwrap(),
283        )
284    }
285}
286
287impl From<RunnerTypeCli> for RunnerType {
288    fn from(kind: RunnerTypeCli) -> Self {
289        match kind {
290            RunnerTypeCli::Instance => RunnerType::Instance,
291            RunnerTypeCli::Group => RunnerType::Group,
292            RunnerTypeCli::Project => RunnerType::Project,
293        }
294    }
295}
296
297impl From<ListJob> for JobOptions {
298    fn from(options: ListJob) -> Self {
299        JobOptions::List(
300            JobListCliArgs::builder()
301                .list_args(options.list_args.into())
302                .build()
303                .unwrap(),
304        )
305    }
306}
307
308impl From<JobsSubCommand> for PipelineOptions {
309    fn from(options: JobsSubCommand) -> Self {
310        match options {
311            JobsSubCommand::List(options) => PipelineOptions::Jobs(options.into()),
312        }
313    }
314}
315
316pub enum PipelineOptions {
317    Lint(LintFilePathArgs),
318    List(ListRemoteCliArgs),
319    Runners(RunnerOptions),
320    MergedCi,
321    Chart(ChartType),
322    Jobs(JobOptions),
323}
324
325pub enum JobOptions {
326    List(JobListCliArgs),
327}
328
329pub enum RunnerOptions {
330    List(RunnerListCliArgs),
331    Get(RunnerMetadataGetCliArgs),
332    Create(RunnerPostDataCliArgs),
333}
334
335#[cfg(test)]
336mod test {
337    use super::*;
338    use crate::cli::{Args, Command};
339
340    #[test]
341    fn test_pipeline_cli_list() {
342        let args = Args::parse_from(vec![
343            "gr",
344            "pp",
345            "list",
346            "--from-page",
347            "1",
348            "--to-page",
349            "2",
350        ]);
351        let list_args = match args.command {
352            Command::Pipeline(PipelineCommand {
353                subcommand: PipelineSubcommand::List(options),
354            }) => {
355                assert_eq!(options.from_page, Some(1));
356                assert_eq!(options.to_page, Some(2));
357                options
358            }
359            _ => panic!("Expected PipelineCommand"),
360        };
361        let options: PipelineOptions = list_args.into();
362        match options {
363            PipelineOptions::List(args) => {
364                assert_eq!(args.from_page, Some(1));
365                assert_eq!(args.to_page, Some(2));
366            }
367            _ => panic!("Expected PipelineOptions::List"),
368        }
369    }
370
371    #[test]
372    fn test_pipeline_cli_runners_list() {
373        let args = Args::parse_from(vec![
374            "gr",
375            "pp",
376            "rn",
377            "list",
378            "online",
379            "--tags",
380            "tag1,tag2",
381            "--all",
382            "--from-page",
383            "1",
384            "--to-page",
385            "2",
386        ]);
387        let list_args = match args.command {
388            Command::Pipeline(PipelineCommand {
389                subcommand: PipelineSubcommand::Runners(RunnerSubCommand::List(options)),
390            }) => {
391                assert_eq!(options.status, RunnerStatusCli::Online);
392                assert_eq!(
393                    options.tags,
394                    Some(vec!["tag1".to_string(), "tag2".to_string()])
395                );
396                assert!(options.all);
397                assert_eq!(options.list_args.from_page, Some(1));
398                assert_eq!(options.list_args.to_page, Some(2));
399                options
400            }
401            _ => panic!("Expected PipelineCommand"),
402        };
403        let options: RunnerOptions = list_args.into();
404        match options {
405            RunnerOptions::List(args) => {
406                assert_eq!(args.status, RunnerStatus::Online);
407                assert_eq!(args.tags, Some("tag1,tag2".to_string()));
408                assert!(args.all);
409                assert_eq!(args.list_args.from_page, Some(1));
410                assert_eq!(args.list_args.to_page, Some(2));
411            }
412            _ => panic!("Expected RunnerOptions::List"),
413        }
414    }
415
416    #[test]
417    fn test_get_gitlab_runner_metadata() {
418        let args = Args::parse_from(vec!["gr", "pp", "rn", "get", "123"]);
419        let list_args = match args.command {
420            Command::Pipeline(PipelineCommand {
421                subcommand: PipelineSubcommand::Runners(RunnerSubCommand::Get(options)),
422            }) => {
423                assert_eq!(options.id, 123);
424                options
425            }
426            _ => panic!("Expected PipelineCommand"),
427        };
428        let options: RunnerOptions = list_args.into();
429        match options {
430            RunnerOptions::Get(args) => {
431                assert_eq!(args.id, 123);
432            }
433            _ => panic!("Expected RunnerOptions::Get"),
434        }
435    }
436
437    #[test]
438    fn test_pipeline_create_runner() {
439        let args = Args::parse_from(vec![
440            "gr",
441            "pp",
442            "rn",
443            "create",
444            "--description",
445            "test-runner",
446            "--tags",
447            "tag1,tag2",
448            "--kind",
449            "instance",
450        ]);
451        let args = match args.command {
452            Command::Pipeline(PipelineCommand {
453                subcommand: PipelineSubcommand::Runners(RunnerSubCommand::Create(options)),
454            }) => {
455                assert_eq!(options.description, Some("test-runner".to_string()));
456                assert_eq!(
457                    options.tags,
458                    Some(vec!["tag1".to_string(), "tag2".to_string()])
459                );
460                assert_eq!(options.kind, RunnerTypeCli::Instance);
461                options
462            }
463            _ => panic!("Expected PipelineCommand"),
464        };
465        let options: RunnerOptions = args.into();
466        match options {
467            RunnerOptions::Create(args) => {
468                assert_eq!(args.description, Some("test-runner".to_string()));
469                assert_eq!(args.tags, Some("tag1,tag2".to_string()));
470                assert_eq!(args.kind, RunnerType::Instance);
471            }
472            _ => panic!("Expected RunnerOptions::Create"),
473        }
474    }
475
476    #[test]
477    fn test_lint_ci_file_args() {
478        let args = Args::parse_from(vec!["gr", "pp", "lint"]);
479        let options = match args.command {
480            Command::Pipeline(PipelineCommand {
481                subcommand: PipelineSubcommand::Lint(options),
482            }) => {
483                assert_eq!(options.path, ".gitlab-ci.yml");
484                options
485            }
486            _ => panic!("Expected PipelineCommand"),
487        };
488        let options: PipelineOptions = options.into();
489        match options {
490            PipelineOptions::Lint(args) => {
491                assert_eq!(args.path, ".gitlab-ci.yml");
492            }
493            _ => panic!("Expected PipelineOptions::Lint"),
494        }
495    }
496
497    #[test]
498    fn test_lint_ci_file_args_with_path() {
499        let args = Args::parse_from(vec!["gr", "pp", "lint", "path/to/ci.yml"]);
500        let options = match args.command {
501            Command::Pipeline(PipelineCommand {
502                subcommand: PipelineSubcommand::Lint(options),
503            }) => {
504                assert_eq!(options.path, "path/to/ci.yml");
505                options
506            }
507            _ => panic!("Expected PipelineCommand"),
508        };
509        let options: PipelineOptions = options.into();
510        match options {
511            PipelineOptions::Lint(args) => {
512                assert_eq!(args.path, "path/to/ci.yml");
513            }
514            _ => panic!("Expected PipelineOptions::Lint"),
515        }
516    }
517
518    #[test]
519    fn test_merged_ci_file_args() {
520        let args = Args::parse_from(vec!["gr", "pp", "merged-ci"]);
521        let options = match args.command {
522            Command::Pipeline(PipelineCommand {
523                subcommand: PipelineSubcommand::MergedCi,
524            }) => PipelineOptions::MergedCi,
525            _ => panic!("Expected PipelineCommand"),
526        };
527        match options {
528            PipelineOptions::MergedCi => {}
529            _ => panic!("Expected PipelineOptions::MergedCi"),
530        }
531    }
532
533    #[test]
534    fn test_chart_cli_args() {
535        let args = Args::parse_from(vec!["gr", "pp", "chart"]);
536        let options = match args.command {
537            Command::Pipeline(PipelineCommand {
538                subcommand: PipelineSubcommand::Chart(options),
539            }) => {
540                assert_eq!(options.chart_type, ChartTypeCli::StagesWithJobs);
541                options
542            }
543            _ => panic!("Expected PipelineCommand"),
544        };
545        let options: PipelineOptions = options.into();
546        match options {
547            PipelineOptions::Chart(args) => {
548                assert_eq!(args, ChartType::StagesWithJobs);
549            }
550            _ => panic!("Expected PipelineOptions::Chart"),
551        }
552    }
553
554    #[test]
555    fn test_pipeline_cli_jobs_list() {
556        let args = Args::parse_from(vec![
557            "gr",
558            "pp",
559            "jb",
560            "list",
561            "--from-page",
562            "1",
563            "--to-page",
564            "2",
565        ]);
566
567        let list_args = match args.command {
568            Command::Pipeline(PipelineCommand {
569                subcommand: PipelineSubcommand::Jobs(JobsSubCommand::List(options)),
570            }) => {
571                assert_eq!(options.list_args.from_page, Some(1));
572                assert_eq!(options.list_args.to_page, Some(2));
573                options
574            }
575            _ => panic!("Expected PipelineCommand"),
576        };
577        let options: JobOptions = list_args.into();
578        match options {
579            JobOptions::List(args) => {
580                assert_eq!(args.list_args.from_page, Some(1));
581                assert_eq!(args.list_args.to_page, Some(2));
582            }
583        }
584    }
585
586    #[test]
587    fn test_project_runner_with_project_id() {
588        let data = RunnerPostData {
589            kind: RunnerTypeCli::Project,
590            project_id: Some(123),
591            group_id: None,
592            ..Default::default()
593        };
594        assert!(data.validate_runner_type_id().is_ok());
595    }
596
597    #[test]
598    fn test_project_runner_without_project_id() {
599        let data = RunnerPostData {
600            kind: RunnerTypeCli::Project,
601            project_id: None,
602            group_id: None,
603            ..Default::default()
604        };
605        assert_eq!(
606            data.validate_runner_type_id(),
607            Err("error: project id is required for project runner".to_string())
608        );
609    }
610
611    #[test]
612    fn test_group_runner_with_group_id() {
613        let data = RunnerPostData {
614            kind: RunnerTypeCli::Group,
615            project_id: None,
616            group_id: Some(456),
617            ..Default::default()
618        };
619        assert!(data.validate_runner_type_id().is_ok());
620    }
621
622    #[test]
623    fn test_group_runner_without_group_id() {
624        let data = RunnerPostData {
625            kind: RunnerTypeCli::Group,
626            project_id: None,
627            group_id: None,
628            ..Default::default()
629        };
630        assert_eq!(
631            data.validate_runner_type_id(),
632            Err("error: group id is required for group runner".to_string())
633        );
634    }
635
636    #[test]
637    fn test_instance_runner_without_ids() {
638        let data = RunnerPostData {
639            kind: RunnerTypeCli::Instance,
640            project_id: None,
641            group_id: None,
642            ..Default::default()
643        };
644        assert!(data.validate_runner_type_id().is_ok());
645    }
646
647    #[test]
648    fn test_instance_runner_with_project_id() {
649        let data = RunnerPostData {
650            kind: RunnerTypeCli::Instance,
651            project_id: Some(123),
652            group_id: None,
653            ..Default::default()
654        };
655        assert_eq!(
656            data.validate_runner_type_id(),
657            Err("error: project id and group id are not required for instance runner".to_string())
658        );
659    }
660
661    #[test]
662    fn test_instance_runner_with_group_id() {
663        let data = RunnerPostData {
664            kind: RunnerTypeCli::Instance,
665            project_id: None,
666            group_id: Some(456),
667            ..Default::default()
668        };
669        assert_eq!(
670            data.validate_runner_type_id(),
671            Err("error: project id and group id are not required for instance runner".to_string())
672        );
673    }
674
675    #[test]
676    fn test_instance_runner_with_both_ids() {
677        let data = RunnerPostData {
678            kind: RunnerTypeCli::Instance,
679            project_id: Some(123),
680            group_id: Some(456),
681            ..Default::default()
682        };
683        assert_eq!(
684            data.validate_runner_type_id(),
685            Err("error: project id and group id are not required for instance runner".to_string())
686        );
687    }
688}