ggen_cli_lib/cmds/ci/
workflow.rs

1//! GitHub Actions workflow management and monitoring.
2//!
3//! This module provides functionality to list, monitor, and manage GitHub Actions
4//! workflows. It supports checking workflow status, viewing logs, and canceling
5//! running workflows through GitHub API integration.
6//!
7//! # Examples
8//!
9//! ```bash
10//! ggen ci workflow list --active
11//! ggen ci workflow status --workflow "build" --verbose
12//! ggen ci workflow logs --workflow "test" --follow
13//! ggen ci workflow cancel --workflow "deploy"
14//! ```
15//!
16//! # Errors
17//!
18//! Returns errors if GitHub API calls fail, workflows don't exist, or if
19//! authentication is not properly configured.
20
21use clap::{Args, Subcommand};
22use ggen_utils::error::Result;
23// CLI output only - no library logging
24
25#[cfg_attr(test, mockall::automock)]
26pub trait WorkflowLister {
27    fn list(&self, active: bool, json: bool) -> Result<WorkflowListResult>;
28}
29
30#[cfg_attr(test, mockall::automock)]
31pub trait WorkflowStatusChecker {
32    fn check_status(
33        &self, workflow: Option<String>, verbose: bool, json: bool,
34    ) -> Result<WorkflowStatusResult>;
35}
36
37#[cfg_attr(test, mockall::automock)]
38pub trait WorkflowLogViewer {
39    fn view_logs(&self, workflow: Option<String>, follow: bool) -> Result<WorkflowLogsResult>;
40}
41
42#[cfg_attr(test, mockall::automock)]
43pub trait WorkflowCanceler {
44    fn cancel(&self, workflow: &str) -> Result<WorkflowCancelResult>;
45}
46
47// Mock implementations for testing
48pub struct CargoMakeWorkflowLister;
49pub struct CargoMakeWorkflowStatusChecker;
50pub struct CargoMakeWorkflowLogViewer;
51pub struct CargoMakeWorkflowCanceler;
52
53#[derive(Debug, Clone)]
54pub struct WorkflowListResult {
55    pub stdout: String,
56    pub stderr: String,
57    pub success: bool,
58}
59
60#[derive(Debug, Clone)]
61pub struct WorkflowStatusResult {
62    pub stdout: String,
63    pub stderr: String,
64    pub success: bool,
65}
66
67#[derive(Debug, Clone)]
68pub struct WorkflowLogsResult {
69    pub stdout: String,
70    pub stderr: String,
71    pub success: bool,
72}
73
74#[derive(Debug, Clone)]
75pub struct WorkflowCancelResult {
76    pub stdout: String,
77    pub stderr: String,
78    pub success: bool,
79}
80
81#[derive(Args, Debug)]
82pub struct WorkflowArgs {
83    #[command(subcommand)]
84    pub action: WorkflowAction,
85}
86
87#[derive(Subcommand, Debug)]
88pub enum WorkflowAction {
89    /// List available workflows
90    List(ListArgs),
91
92    /// Check workflow status
93    Status(StatusArgs),
94
95    /// View workflow logs
96    Logs(LogsArgs),
97
98    /// Cancel running workflows
99    Cancel(CancelArgs),
100}
101
102#[derive(Args, Debug)]
103pub struct ListArgs {
104    /// Show only active workflows
105    #[arg(long)]
106    pub active: bool,
107
108    /// Output in JSON format
109    #[arg(long)]
110    pub json: bool,
111}
112
113#[derive(Args, Debug)]
114pub struct StatusArgs {
115    /// Workflow name or ID to check
116    #[arg(long)]
117    pub workflow: Option<String>,
118
119    /// Show detailed status information
120    #[arg(long)]
121    pub verbose: bool,
122
123    /// Output in JSON format
124    #[arg(long)]
125    pub json: bool,
126}
127
128#[derive(Args, Debug)]
129pub struct LogsArgs {
130    /// Workflow name or ID to get logs for
131    #[arg(long)]
132    pub workflow: Option<String>,
133
134    /// Follow logs in real-time
135    #[arg(long)]
136    pub follow: bool,
137
138    /// Number of log lines to show [default: 100]
139    #[arg(long, default_value = "100")]
140    pub lines: usize,
141}
142
143#[derive(Args, Debug)]
144pub struct CancelArgs {
145    /// Workflow name or ID to cancel
146    #[arg(long)]
147    pub workflow: Option<String>,
148
149    /// Cancel all running workflows
150    #[arg(long)]
151    pub all: bool,
152}
153
154pub async fn run(args: &WorkflowArgs) -> Result<()> {
155    let lister = CargoMakeWorkflowLister;
156    let status_checker = CargoMakeWorkflowStatusChecker;
157    let log_viewer = CargoMakeWorkflowLogViewer;
158    let canceler = CargoMakeWorkflowCanceler;
159
160    run_with_deps(args, &lister, &status_checker, &log_viewer, &canceler).await
161}
162
163pub async fn run_with_deps(
164    args: &WorkflowArgs, lister: &dyn WorkflowLister, status_checker: &dyn WorkflowStatusChecker,
165    log_viewer: &dyn WorkflowLogViewer, canceler: &dyn WorkflowCanceler,
166) -> Result<()> {
167    match &args.action {
168        WorkflowAction::List(list_args) => list_workflows_with_deps(list_args, lister).await,
169        WorkflowAction::Status(status_args) => {
170            check_workflow_status_with_deps(status_args, status_checker).await
171        }
172        WorkflowAction::Logs(logs_args) => {
173            view_workflow_logs_with_deps(logs_args, log_viewer).await
174        }
175        WorkflowAction::Cancel(cancel_args) => {
176            cancel_workflows_with_deps(cancel_args, canceler).await
177        }
178    }
179}
180
181async fn list_workflows_with_deps(args: &ListArgs, lister: &dyn WorkflowLister) -> Result<()> {
182    println!("Listing GitHub Actions workflows");
183
184    let result = lister.list(args.active, args.json)?;
185
186    if !result.success {
187        return Err(ggen_utils::error::Error::new_fmt(format_args!(
188            "Workflow listing failed: {}",
189            result.stderr
190        )));
191    }
192
193    println!("{}", result.stdout);
194    Ok(())
195}
196
197#[allow(dead_code)]
198async fn list_workflows(args: &ListArgs) -> Result<()> {
199    let lister = CargoMakeWorkflowLister;
200    list_workflows_with_deps(args, &lister).await
201}
202
203async fn check_workflow_status_with_deps(
204    args: &StatusArgs, status_checker: &dyn WorkflowStatusChecker,
205) -> Result<()> {
206    println!("Checking GitHub Actions workflow status");
207
208    let result = status_checker.check_status(args.workflow.clone(), args.verbose, args.json)?;
209
210    if !result.success {
211        return Err(ggen_utils::error::Error::new_fmt(format_args!(
212            "Workflow status check failed: {}",
213            result.stderr
214        )));
215    }
216
217    println!("{}", result.stdout);
218    Ok(())
219}
220
221#[allow(dead_code)]
222async fn check_workflow_status(args: &StatusArgs) -> Result<()> {
223    let status_checker = CargoMakeWorkflowStatusChecker;
224    check_workflow_status_with_deps(args, &status_checker).await
225}
226
227async fn view_workflow_logs_with_deps(
228    args: &LogsArgs, log_viewer: &dyn WorkflowLogViewer,
229) -> Result<()> {
230    println!("Viewing GitHub Actions workflow logs");
231
232    let result = log_viewer.view_logs(args.workflow.clone(), args.follow)?;
233
234    if !result.success {
235        return Err(ggen_utils::error::Error::new_fmt(format_args!(
236            "Workflow logs retrieval failed: {}",
237            result.stderr
238        )));
239    }
240
241    println!("{}", result.stdout);
242    Ok(())
243}
244
245#[allow(dead_code)]
246async fn view_workflow_logs(args: &LogsArgs) -> Result<()> {
247    let log_viewer = CargoMakeWorkflowLogViewer;
248    view_workflow_logs_with_deps(args, &log_viewer).await
249}
250
251async fn cancel_workflows_with_deps(
252    args: &CancelArgs, canceler: &dyn WorkflowCanceler,
253) -> Result<()> {
254    println!("Cancelling GitHub Actions workflows");
255
256    if args.all {
257        println!("Cancelling all running workflows");
258        let result = canceler.cancel("all")?;
259
260        if !result.success {
261            return Err(ggen_utils::error::Error::new_fmt(format_args!(
262                "Failed to cancel all workflows: {}",
263                result.stderr
264            )));
265        }
266
267        println!("✅ All running workflows cancelled");
268    } else if let Some(workflow) = &args.workflow {
269        println!("Cancelling workflow: {}", workflow);
270
271        let result = canceler.cancel(workflow)?;
272
273        if !result.success {
274            return Err(ggen_utils::error::Error::new_fmt(format_args!(
275                "Failed to cancel workflow {}: {}",
276                workflow, result.stderr
277            )));
278        }
279
280        println!("✅ Workflow {} cancelled", workflow);
281    } else {
282        return Err(ggen_utils::error::Error::new(
283            "Must specify either --workflow or --all",
284        ));
285    }
286
287    Ok(())
288}
289
290#[allow(dead_code)]
291async fn cancel_workflows(args: &CancelArgs) -> Result<()> {
292    let canceler = CargoMakeWorkflowCanceler;
293    cancel_workflows_with_deps(args, &canceler).await
294}
295
296// Concrete implementations for production use
297
298impl WorkflowLister for CargoMakeWorkflowLister {
299    fn list(&self, active: bool, json: bool) -> Result<WorkflowListResult> {
300        let mut cmd = std::process::Command::new("cargo");
301        cmd.args(["make", "gh-workflow-status"]);
302
303        if active {
304            cmd.arg("--active");
305        }
306
307        if json {
308            cmd.arg("--json");
309        }
310
311        let output = cmd.output()?;
312        Ok(WorkflowListResult {
313            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
314            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
315            success: output.status.success(),
316        })
317    }
318}
319
320impl WorkflowStatusChecker for CargoMakeWorkflowStatusChecker {
321    fn check_status(
322        &self, workflow: Option<String>, verbose: bool, json: bool,
323    ) -> Result<WorkflowStatusResult> {
324        let mut cmd = std::process::Command::new("cargo");
325        cmd.args(["make", "gh-workflow-status"]);
326
327        if let Some(workflow) = workflow {
328            cmd.arg("--workflow").arg(workflow);
329        }
330
331        if verbose {
332            cmd.arg("--verbose");
333        }
334
335        if json {
336            cmd.arg("--json");
337        }
338
339        let output = cmd.output()?;
340        Ok(WorkflowStatusResult {
341            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
342            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
343            success: output.status.success(),
344        })
345    }
346}
347
348impl WorkflowLogViewer for CargoMakeWorkflowLogViewer {
349    fn view_logs(&self, workflow: Option<String>, follow: bool) -> Result<WorkflowLogsResult> {
350        let mut cmd = std::process::Command::new("cargo");
351        cmd.args(["make", "gh-workflow-logs"]);
352
353        if let Some(workflow) = workflow {
354            cmd.arg("--workflow").arg(workflow);
355        }
356
357        if follow {
358            cmd.arg("--follow");
359        }
360
361        let output = cmd.output()?;
362        Ok(WorkflowLogsResult {
363            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
364            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
365            success: output.status.success(),
366        })
367    }
368}
369
370impl WorkflowCanceler for CargoMakeWorkflowCanceler {
371    fn cancel(&self, workflow: &str) -> Result<WorkflowCancelResult> {
372        let mut cmd = std::process::Command::new("gh");
373        if workflow == "all" {
374            cmd.args(["run", "cancel", "--all"]);
375        } else {
376            cmd.args(["run", "cancel", workflow]);
377        }
378
379        let output = cmd.output()?;
380        Ok(WorkflowCancelResult {
381            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
382            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
383            success: output.status.success(),
384        })
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use mockall::predicate::*;
392
393    #[tokio::test]
394    async fn test_list_calls_lister() {
395        let mut mock = MockWorkflowLister::new();
396        mock.expect_list()
397            .with(eq(false), eq(false))
398            .times(1)
399            .returning(|_, _| {
400                Ok(WorkflowListResult {
401                    stdout: "Workflow list".to_string(),
402                    stderr: "".to_string(),
403                    success: true,
404                })
405            });
406
407        let args = ListArgs {
408            active: false,
409            json: false,
410        };
411        let result = list_workflows_with_deps(&args, &mock).await;
412        assert!(result.is_ok());
413    }
414
415    #[tokio::test]
416    async fn test_status_calls_checker() {
417        let mut mock = MockWorkflowStatusChecker::new();
418        mock.expect_check_status()
419            .with(eq(Some("build".to_string())), eq(false), eq(false))
420            .times(1)
421            .returning(|_, _, _| {
422                Ok(WorkflowStatusResult {
423                    stdout: "Status OK".to_string(),
424                    stderr: "".to_string(),
425                    success: true,
426                })
427            });
428
429        let args = StatusArgs {
430            workflow: Some("build".to_string()),
431            verbose: false,
432            json: false,
433        };
434        let result = check_workflow_status_with_deps(&args, &mock).await;
435        assert!(result.is_ok());
436    }
437
438    #[tokio::test]
439    async fn test_logs_calls_viewer() {
440        let mut mock = MockWorkflowLogViewer::new();
441        mock.expect_view_logs()
442            .with(eq(Some("test".to_string())), eq(false))
443            .times(1)
444            .returning(|_, _| {
445                Ok(WorkflowLogsResult {
446                    stdout: "Log output".to_string(),
447                    stderr: "".to_string(),
448                    success: true,
449                })
450            });
451
452        let args = LogsArgs {
453            workflow: Some("test".to_string()),
454            follow: false,
455            lines: 100,
456        };
457        let result = view_workflow_logs_with_deps(&args, &mock).await;
458        assert!(result.is_ok());
459    }
460
461    #[tokio::test]
462    async fn test_cancel_calls_canceler() {
463        let mut mock = MockWorkflowCanceler::new();
464        mock.expect_cancel()
465            .with(eq("deploy"))
466            .times(1)
467            .returning(|_| {
468                Ok(WorkflowCancelResult {
469                    stdout: "Cancel complete".to_string(),
470                    stderr: "".to_string(),
471                    success: true,
472                })
473            });
474
475        let args = CancelArgs {
476            workflow: Some("deploy".to_string()),
477            all: false,
478        };
479        let result = cancel_workflows_with_deps(&args, &mock).await;
480        assert!(result.is_ok());
481    }
482
483    #[tokio::test]
484    async fn test_cancel_all_calls_canceler() {
485        let mut mock = MockWorkflowCanceler::new();
486        mock.expect_cancel()
487            .with(eq("all"))
488            .times(1)
489            .returning(|_| {
490                Ok(WorkflowCancelResult {
491                    stdout: "All cancelled".to_string(),
492                    stderr: "".to_string(),
493                    success: true,
494                })
495            });
496
497        let args = CancelArgs {
498            workflow: None,
499            all: true,
500        };
501        let result = cancel_workflows_with_deps(&args, &mock).await;
502        assert!(result.is_ok());
503    }
504
505    #[tokio::test]
506    async fn test_cancel_requires_workflow_or_all() {
507        let mock = MockWorkflowCanceler::new();
508
509        let args = CancelArgs {
510            workflow: None,
511            all: false,
512        };
513        let result = cancel_workflows_with_deps(&args, &mock).await;
514        assert!(result.is_err());
515        assert!(result
516            .unwrap_err()
517            .to_string()
518            .contains("Must specify either --workflow or --all"));
519    }
520
521    #[tokio::test]
522    async fn test_run_with_deps_dispatches_correctly() {
523        let mut mock_lister = MockWorkflowLister::new();
524        mock_lister
525            .expect_list()
526            .with(eq(false), eq(false))
527            .times(1)
528            .returning(|_, _| {
529                Ok(WorkflowListResult {
530                    stdout: "Workflow list".to_string(),
531                    stderr: "".to_string(),
532                    success: true,
533                })
534            });
535
536        let mock_status_checker = MockWorkflowStatusChecker::new();
537        let mock_log_viewer = MockWorkflowLogViewer::new();
538        let mock_canceler = MockWorkflowCanceler::new();
539
540        let args = WorkflowArgs {
541            action: WorkflowAction::List(ListArgs {
542                active: false,
543                json: false,
544            }),
545        };
546
547        let result = run_with_deps(
548            &args,
549            &mock_lister,
550            &mock_status_checker,
551            &mock_log_viewer,
552            &mock_canceler,
553        )
554        .await;
555        assert!(result.is_ok());
556    }
557
558    #[test]
559    fn test_list_args_defaults() {
560        let args = ListArgs {
561            active: false,
562            json: false,
563        };
564        assert!(!args.active);
565        assert!(!args.json);
566    }
567
568    #[test]
569    fn test_status_args_defaults() {
570        let args = StatusArgs {
571            workflow: None,
572            verbose: false,
573            json: false,
574        };
575        assert!(args.workflow.is_none());
576        assert!(!args.verbose);
577        assert!(!args.json);
578    }
579
580    #[test]
581    fn test_logs_args_defaults() {
582        let args = LogsArgs {
583            workflow: None,
584            follow: false,
585            lines: 100,
586        };
587        assert!(args.workflow.is_none());
588        assert!(!args.follow);
589        assert_eq!(args.lines, 100);
590    }
591
592    #[test]
593    fn test_cancel_args_defaults() {
594        let args = CancelArgs {
595            workflow: None,
596            all: false,
597        };
598        assert!(args.workflow.is_none());
599        assert!(!args.all);
600    }
601}