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 {
14 repo: String,
16
17 #[arg(short, long, default_value = "25")]
19 limit: u32,
20 },
21
22 View {
24 repo: String,
26
27 #[arg(short, long)]
29 build: u64,
30
31 #[arg(short, long)]
33 logs: bool,
34 },
35
36 Trigger {
38 repo: String,
40
41 #[arg(short, long, default_value = "main")]
43 branch: String,
44
45 #[arg(short, long)]
47 pipeline: Option<String>,
48
49 #[arg(short, long)]
51 wait: bool,
52 },
53
54 Stop {
56 repo: String,
58
59 #[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 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 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 }
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) = ¤t.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}