bitbucket_cli/cli/
pipeline.rs

1use anyhow::Result;
2use clap::Subcommand;
3use colored::Colorize;
4use indicatif::{ProgressBar, ProgressStyle};
5use tabled::{Table, Tabled};
6
7use crate::api::BitbucketClient;
8use crate::models::{PipelineResultName, PipelineStateName, TriggerPipelineRequest};
9
10#[derive(Subcommand)]
11pub enum PipelineCommands {
12    /// List pipelines
13    List {
14        /// Repository in format workspace/repo-slug
15        repo: String,
16
17        /// Number of results
18        #[arg(short, long, default_value = "25")]
19        limit: u32,
20    },
21
22    /// View pipeline details
23    View {
24        /// Repository in format workspace/repo-slug
25        repo: String,
26
27        /// Pipeline build number
28        #[arg(short, long)]
29        build: u64,
30
31        /// Show step logs
32        #[arg(short, long)]
33        logs: bool,
34    },
35
36    /// Trigger a new pipeline
37    Trigger {
38        /// Repository in format workspace/repo-slug
39        repo: String,
40
41        /// Branch to run pipeline on
42        #[arg(short, long, default_value = "main")]
43        branch: String,
44
45        /// Custom pipeline name (from bitbucket-pipelines.yml)
46        #[arg(short, long)]
47        pipeline: Option<String>,
48
49        /// Wait for pipeline to complete
50        #[arg(short, long)]
51        wait: bool,
52    },
53
54    /// Stop a running pipeline
55    Stop {
56        /// Repository in format workspace/repo-slug
57        repo: String,
58
59        /// Pipeline build number
60        #[arg(short, long)]
61        build: u64,
62    },
63}
64
65#[derive(Tabled)]
66struct PipelineRow {
67    #[tabled(rename = "#")]
68    build: u64,
69    #[tabled(rename = "STATUS")]
70    status: String,
71    #[tabled(rename = "BRANCH")]
72    branch: String,
73    #[tabled(rename = "TRIGGERED")]
74    triggered: String,
75    #[tabled(rename = "DURATION")]
76    duration: String,
77}
78
79impl PipelineCommands {
80    pub async fn run(self) -> Result<()> {
81        match self {
82            PipelineCommands::List { repo, limit } => {
83                let (workspace, repo_slug) = parse_repo(&repo)?;
84                let client = BitbucketClient::from_stored()?;
85
86                let pipelines = client
87                    .list_pipelines(&workspace, &repo_slug, None, Some(limit))
88                    .await?;
89
90                if pipelines.values.is_empty() {
91                    println!("No pipelines found");
92                    return Ok(());
93                }
94
95                let rows: Vec<PipelineRow> = pipelines
96                    .values
97                    .iter()
98                    .map(|p| {
99                        let duration = if let Some(seconds) = p.build_seconds_used {
100                            format_duration(seconds)
101                        } else if p.state.name == PipelineStateName::Building {
102                            "running...".to_string()
103                        } else {
104                            "-".to_string()
105                        };
106
107                        PipelineRow {
108                            build: p.build_number,
109                            status: format_status(
110                                &p.state.name,
111                                p.state.result.as_ref().map(|r| &r.name),
112                            ),
113                            branch: p.target.ref_name.clone().unwrap_or_else(|| "-".to_string()),
114                            triggered: p.created_on.format("%Y-%m-%d %H:%M").to_string(),
115                            duration,
116                        }
117                    })
118                    .collect();
119
120                let table = Table::new(rows).to_string();
121                println!("{}", table);
122
123                Ok(())
124            }
125
126            PipelineCommands::View { repo, build, logs } => {
127                let (workspace, repo_slug) = parse_repo(&repo)?;
128                let client = BitbucketClient::from_stored()?;
129
130                let pipeline = client
131                    .get_pipeline_by_build_number(&workspace, &repo_slug, build)
132                    .await?;
133
134                println!(
135                    "{} Pipeline #{} - {}",
136                    format_status(
137                        &pipeline.state.name,
138                        pipeline.state.result.as_ref().map(|r| &r.name)
139                    ),
140                    pipeline.build_number,
141                    pipeline.target.ref_name.as_deref().unwrap_or("unknown")
142                );
143                println!("{}", "─".repeat(60));
144
145                if let Some(creator) = &pipeline.creator {
146                    println!("{} {}", "Triggered by:".dimmed(), creator.display_name);
147                }
148
149                if let Some(trigger) = &pipeline.trigger {
150                    println!("{} {}", "Trigger type:".dimmed(), trigger.trigger_type);
151                }
152
153                println!(
154                    "{} {}",
155                    "Started:".dimmed(),
156                    pipeline.created_on.format("%Y-%m-%d %H:%M:%S")
157                );
158
159                if let Some(completed) = pipeline.completed_on {
160                    println!(
161                        "{} {}",
162                        "Completed:".dimmed(),
163                        completed.format("%Y-%m-%d %H:%M:%S")
164                    );
165                }
166
167                if let Some(seconds) = pipeline.build_seconds_used {
168                    println!("{} {}", "Duration:".dimmed(), format_duration(seconds));
169                }
170
171                // Show pipeline steps
172                let steps = client
173                    .list_pipeline_steps(&workspace, &repo_slug, &pipeline.uuid)
174                    .await?;
175
176                if !steps.values.is_empty() {
177                    println!();
178                    println!("{}", "Steps:".bold());
179
180                    for step in &steps.values {
181                        let status = step
182                            .state
183                            .as_ref()
184                            .map(|s| s.name.as_str())
185                            .unwrap_or("unknown");
186
187                        let status_icon = match status {
188                            "COMPLETED" => {
189                                let result = step
190                                    .state
191                                    .as_ref()
192                                    .and_then(|s| s.result.as_ref())
193                                    .map(|r| r.name.as_str())
194                                    .unwrap_or("");
195                                match result {
196                                    "SUCCESSFUL" => "✓".green(),
197                                    "FAILED" => "✗".red(),
198                                    _ => "○".normal(),
199                                }
200                            }
201                            "IN_PROGRESS" => "◉".blue(),
202                            "PENDING" => "○".dimmed(),
203                            _ => "○".normal(),
204                        };
205
206                        let name = step.name.as_deref().unwrap_or("Step");
207                        println!("  {} {}", status_icon, name);
208
209                        if logs {
210                            // Fetch and display step log
211                            match client
212                                .get_step_log(&workspace, &repo_slug, &pipeline.uuid, &step.uuid)
213                                .await
214                            {
215                                Ok(log) => {
216                                    if !log.is_empty() {
217                                        println!();
218                                        for line in log.lines().take(50) {
219                                            println!("    {}", line.dimmed());
220                                        }
221                                        if log.lines().count() > 50 {
222                                            println!("    {} ... (truncated)", "".dimmed());
223                                        }
224                                        println!();
225                                    }
226                                }
227                                Err(_) => {
228                                    // Log might not be available yet
229                                }
230                            }
231                        }
232                    }
233                }
234
235                Ok(())
236            }
237
238            PipelineCommands::Trigger {
239                repo,
240                branch,
241                pipeline,
242                wait,
243            } => {
244                let (workspace, repo_slug) = parse_repo(&repo)?;
245                let client = BitbucketClient::from_stored()?;
246
247                let request = if let Some(pipeline_name) = pipeline {
248                    TriggerPipelineRequest::for_branch_with_pipeline(&branch, &pipeline_name)
249                } else {
250                    TriggerPipelineRequest::for_branch(&branch)
251                };
252
253                let triggered = client
254                    .trigger_pipeline(&workspace, &repo_slug, &request)
255                    .await?;
256
257                println!(
258                    "{} Triggered pipeline #{} on branch {}",
259                    "✓".green(),
260                    triggered.build_number,
261                    branch.cyan()
262                );
263
264                if wait {
265                    println!();
266                    let pb = ProgressBar::new_spinner();
267                    pb.set_style(
268                        ProgressStyle::default_spinner()
269                            .template("{spinner:.blue} {msg}")
270                            .unwrap(),
271                    );
272                    pb.set_message("Waiting for pipeline to complete...");
273
274                    loop {
275                        tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
276
277                        let current = client
278                            .get_pipeline(&workspace, &repo_slug, &triggered.uuid)
279                            .await?;
280
281                        match current.state.name {
282                            PipelineStateName::Completed => {
283                                pb.finish_and_clear();
284
285                                if let Some(result) = &current.state.result {
286                                    match result.name {
287                                        PipelineResultName::Successful => {
288                                            println!(
289                                                "{} Pipeline #{} completed successfully!",
290                                                "✓".green(),
291                                                current.build_number
292                                            );
293                                        }
294                                        PipelineResultName::Failed => {
295                                            println!(
296                                                "{} Pipeline #{} failed",
297                                                "✗".red(),
298                                                current.build_number
299                                            );
300                                        }
301                                        _ => {
302                                            println!(
303                                                "Pipeline #{} completed with status: {:?}",
304                                                current.build_number, result.name
305                                            );
306                                        }
307                                    }
308                                }
309                                break;
310                            }
311                            PipelineStateName::Halted => {
312                                pb.finish_and_clear();
313                                println!(
314                                    "{} Pipeline #{} was halted",
315                                    "⚠".yellow(),
316                                    current.build_number
317                                );
318                                break;
319                            }
320                            _ => {
321                                pb.tick();
322                            }
323                        }
324                    }
325                }
326
327                Ok(())
328            }
329
330            PipelineCommands::Stop { repo, build } => {
331                let (workspace, repo_slug) = parse_repo(&repo)?;
332                let client = BitbucketClient::from_stored()?;
333
334                let pipeline = client
335                    .get_pipeline_by_build_number(&workspace, &repo_slug, build)
336                    .await?;
337
338                client
339                    .stop_pipeline(&workspace, &repo_slug, &pipeline.uuid)
340                    .await?;
341
342                println!("{} Stopped pipeline #{}", "✓".green(), build);
343
344                Ok(())
345            }
346        }
347    }
348}
349
350fn parse_repo(repo: &str) -> Result<(String, String)> {
351    let parts: Vec<&str> = repo.split('/').collect();
352    if parts.len() != 2 {
353        anyhow::bail!(
354            "Invalid repository format. Expected 'workspace/repo-slug', got '{}'",
355            repo
356        );
357    }
358    Ok((parts[0].to_string(), parts[1].to_string()))
359}
360
361fn format_status(state: &PipelineStateName, result: Option<&PipelineResultName>) -> String {
362    match state {
363        PipelineStateName::Pending => "PENDING".yellow().to_string(),
364        PipelineStateName::Building => "RUNNING".blue().to_string(),
365        PipelineStateName::Paused => "PAUSED".yellow().to_string(),
366        PipelineStateName::Halted => "HALTED".red().to_string(),
367        PipelineStateName::Completed => {
368            if let Some(result) = result {
369                match result {
370                    PipelineResultName::Successful => "SUCCESS".green().to_string(),
371                    PipelineResultName::Failed => "FAILED".red().to_string(),
372                    PipelineResultName::Error => "ERROR".red().to_string(),
373                    PipelineResultName::Stopped => "STOPPED".yellow().to_string(),
374                    PipelineResultName::Expired => "EXPIRED".dimmed().to_string(),
375                }
376            } else {
377                "COMPLETED".normal().to_string()
378            }
379        }
380    }
381}
382
383fn format_duration(seconds: u64) -> String {
384    if seconds < 60 {
385        format!("{}s", seconds)
386    } else if seconds < 3600 {
387        format!("{}m {}s", seconds / 60, seconds % 60)
388    } else {
389        format!("{}h {}m", seconds / 3600, (seconds % 3600) / 60)
390    }
391}