1#![allow(clippy::print_stdout, clippy::option_if_let_else)]
6
7use crate::cli::RunCommand;
8use crate::client::Client;
9use crate::output::print_json;
10use crate::repository;
11use anyhow::Context;
12use std::fmt::Write;
13
14pub fn run(cmd: RunCommand) -> anyhow::Result<()> {
20 match cmd {
21 RunCommand::List {
22 workflow,
23 branch,
24 repo,
25 limit,
26 json,
27 hostname,
28 } => list(
29 workflow.as_deref(),
30 branch.as_deref(),
31 repo.as_deref(),
32 limit,
33 json,
34 hostname.as_deref(),
35 ),
36 RunCommand::View {
37 id,
38 repo,
39 web,
40 log,
41 log_failed,
42 json,
43 hostname,
44 } => view(
45 id,
46 repo.as_deref(),
47 web,
48 log,
49 log_failed,
50 json,
51 hostname.as_deref(),
52 ),
53 RunCommand::Cancel { id, repo, hostname } => {
54 cancel(id, repo.as_deref(), hostname.as_deref())
55 }
56 RunCommand::Download {
57 id,
58 repo,
59 dir,
60 names,
61 log,
62 hostname,
63 } => download(id, repo.as_deref(), &dir, &names, log, hostname.as_deref()),
64 RunCommand::Rerun {
65 id,
66 repo,
67 failed_jobs,
68 debug,
69 hostname,
70 } => rerun(id, repo.as_deref(), failed_jobs, debug, hostname.as_deref()),
71 RunCommand::Watch {
72 id,
73 repo,
74 interval,
75 exit_status,
76 hostname,
77 } => watch(
78 id,
79 repo.as_deref(),
80 interval,
81 exit_status,
82 hostname.as_deref(),
83 ),
84 RunCommand::Delete {
85 id,
86 repo,
87 yes,
88 hostname,
89 } => delete_run(id, repo.as_deref(), yes, hostname.as_deref()),
90 }
91}
92
93fn list(
94 workflow: Option<&str>,
95 branch: Option<&str>,
96 repo: Option<&str>,
97 limit: u32,
98 json: Option<Vec<String>>,
99 hostname: Option<&str>,
100) -> anyhow::Result<()> {
101 let spec = match repo {
102 Some(s) => repository::parse_repo_spec(s).context("invalid repository spec")?,
103 None => repository::detect_remote().ok_or_else(|| {
104 anyhow::anyhow!("could not detect repository; specify OWNER/REPO with --repo")
105 })?,
106 };
107
108 let host = hostname.unwrap_or("github.com");
109 let client = Client::new(host).context("failed to create HTTP client")?;
110
111 let mut path = format!(
112 "/repos/{}/{}/actions/runs?per_page={}",
113 spec.owner,
114 spec.repo,
115 limit.min(100)
116 );
117
118 if let Some(wf) = workflow {
119 let _ = write!(path, "&workflow={wf}");
120 }
121 if let Some(br) = branch {
122 let _ = write!(path, "&branch={br}");
123 }
124
125 let response = client.get(&path).context("failed to fetch workflow runs")?;
126 let status = response.status();
127 if !status.is_success() {
128 anyhow::bail!("failed to list runs: HTTP {status}");
129 }
130
131 let result: serde_json::Value = response.json().context("failed to parse response")?;
132 let mut runs: Vec<serde_json::Value> = result["workflow_runs"]
133 .as_array()
134 .map_or_else(Vec::new, Clone::clone);
135
136 runs.truncate(limit as usize);
137
138 if let Some(fields) = json {
139 let fields_ref: Option<&[String]> = if fields.is_empty() {
140 None
141 } else {
142 Some(&fields)
143 };
144 print_json(&runs, fields_ref);
145 return Ok(());
146 }
147
148 if runs.is_empty() {
149 println!("No workflow runs found.");
150 return Ok(());
151 }
152
153 println!(
154 "{:<8} {:<10} {:<12} {:20} EVENT",
155 "RUN ID", "STATUS", "CONCLUSION", "BRANCH"
156 );
157 for r in &runs {
158 let id = r["id"].as_u64().unwrap_or(0);
159 let status = r["status"].as_str().unwrap_or("—");
160 let conclusion = r["conclusion"].as_str().unwrap_or("—");
161 let branch = r["head_branch"].as_str().unwrap_or("—");
162 let event = r["event"].as_str().unwrap_or("—");
163 let branch_truncated = crate::cmd::util::truncate(branch, 20);
164 println!("{id:<8} {status:<10} {conclusion:<12} {branch_truncated:<20} {event}");
165 }
166
167 Ok(())
168}
169
170fn view(
171 id: u64,
172 repo: Option<&str>,
173 web: bool,
174 log: Option<u64>,
175 log_failed: bool,
176 json: Option<Vec<String>>,
177 hostname: Option<&str>,
178) -> anyhow::Result<()> {
179 let spec = match repo {
180 Some(s) => repository::parse_repo_spec(s).context("invalid repository spec")?,
181 None => repository::detect_remote().ok_or_else(|| {
182 anyhow::anyhow!("could not detect repository; specify OWNER/REPO with --repo")
183 })?,
184 };
185
186 let host = hostname.unwrap_or("github.com");
187 let client = Client::new(host).context("failed to create HTTP client")?;
188
189 let path = format!("/repos/{}/{}/actions/runs/{id}", spec.owner, spec.repo);
190 let response = client.get(&path).context("failed to fetch run")?;
191
192 let status = response.status();
193 if status == reqwest::StatusCode::NOT_FOUND {
194 anyhow::bail!("run #{id} not found");
195 }
196 if !status.is_success() {
197 anyhow::bail!("failed to view run: HTTP {status}");
198 }
199
200 let run_data: serde_json::Value = response.json().context("failed to parse response")?;
201
202 if web {
203 if let Some(url) = run_data["html_url"].as_str() {
204 println!("Open {url} in your browser");
205 return Ok(());
206 }
207 }
208
209 let jobs_path = format!("/repos/{}/{}/actions/runs/{id}/jobs", spec.owner, spec.repo);
211 let jobs_response = client.get(&jobs_path);
212 let jobs: Vec<serde_json::Value> = if let Ok(resp) = jobs_response {
213 if resp.status().is_success() {
214 if let Ok(jobs_data) = resp.json::<serde_json::Value>() {
215 jobs_data["jobs"]
216 .as_array()
217 .map_or_else(Vec::new, Clone::clone)
218 } else {
219 Vec::new()
220 }
221 } else {
222 Vec::new()
223 }
224 } else {
225 Vec::new()
226 };
227
228 if let Some(job_id) = log {
230 let log_path = format!(
231 "/repos/{}/{}/actions/jobs/{job_id}/logs",
232 spec.owner, spec.repo
233 );
234 let log_response = client.get(&log_path);
235 if let Ok(resp) = log_response {
236 if resp.status().is_success() {
237 if let Ok(body) = resp.text() {
238 print!("{body}");
239 }
240 }
241 }
242 return Ok(());
243 }
244
245 if log_failed {
247 for job in &jobs {
248 let conclusion = job["conclusion"].as_str().unwrap_or("");
249 if conclusion == "failure" || conclusion == "cancelled" {
250 let job_id = job["id"].as_u64().unwrap_or(0);
251 let job_name = job["name"].as_str().unwrap_or("—");
252 println!(":: {job_name} (failed) ::");
253 let log_path = format!(
254 "/repos/{}/{}/actions/jobs/{job_id}/logs",
255 spec.owner, spec.repo
256 );
257 let log_response = client.get(&log_path);
258 if let Ok(resp) = log_response {
259 if resp.status().is_success() {
260 if let Ok(body) = resp.text() {
261 print!("{body}");
262 }
263 }
264 }
265 println!();
266 }
267 }
268 return Ok(());
269 }
270
271 if let Some(fields) = json {
272 let fields_ref: Option<&[String]> = if fields.is_empty() {
273 None
274 } else {
275 Some(&fields)
276 };
277 print_json(&run_data, fields_ref);
278 return Ok(());
279 }
280
281 let run_status = run_data["status"].as_str().unwrap_or("—");
283 let conclusion = run_data["conclusion"].as_str().unwrap_or("—");
284 let event = run_data["event"].as_str().unwrap_or("—");
285 let branch = run_data["head_branch"].as_str().unwrap_or("—");
286 let created = run_data["created_at"].as_str().unwrap_or("—");
287
288 println!(" Status: {run_status}");
289 println!(" Conclusion: {conclusion}");
290 println!(" Event: {event}");
291 println!(" Branch: {branch}");
292 println!(" Created: {created}");
293
294 if !jobs.is_empty() {
295 println!(" Jobs:");
296 println!(
297 " {:<8} {:<30} {:<10} {:<12}",
298 "JOB ID", "NAME", "STATUS", "CONCLUSION"
299 );
300 for job in &jobs {
301 let job_id = job["id"].as_u64().unwrap_or(0);
302 let job_name = job["name"].as_str().unwrap_or("—");
303 let job_status = job["status"].as_str().unwrap_or("—");
304 let job_conclusion = job["conclusion"].as_str().unwrap_or("—");
305 let name_truncated = crate::cmd::util::truncate(job_name, 30);
306 println!(
307 " {job_id:<8} {name_truncated:<30} {job_status:<10} {job_conclusion:<12}"
308 );
309 }
310 }
311
312 Ok(())
313}
314
315fn cancel(id: u64, repo: Option<&str>, hostname: Option<&str>) -> anyhow::Result<()> {
316 let spec = match repo {
317 Some(s) => repository::parse_repo_spec(s).context("invalid repository spec")?,
318 None => repository::detect_remote().ok_or_else(|| {
319 anyhow::anyhow!("could not detect repository; specify OWNER/REPO with --repo")
320 })?,
321 };
322
323 let host = hostname.unwrap_or("github.com");
324 let client = Client::new(host).context("failed to create HTTP client")?;
325
326 let path = format!("/repos/{}/{}/actions/runs/{id}", spec.owner, spec.repo);
328 let response = client.get(&path).context("failed to fetch run")?;
329 let status = response.status();
330 if status == reqwest::StatusCode::NOT_FOUND {
331 anyhow::bail!("run #{id} not found");
332 }
333 if !status.is_success() {
334 anyhow::bail!("failed to fetch run: HTTP {status}");
335 }
336
337 let run_data: serde_json::Value = response.json().context("failed to parse response")?;
338 let run_status = run_data["status"].as_str().unwrap_or("");
339
340 let terminal_states = ["completed", "cancelled", "skipped", "timed_out"];
341 if terminal_states.contains(&run_status) {
342 let html_url = run_data["html_url"].as_str().unwrap_or("");
343 anyhow::bail!("run #{id} is already {run_status} ({html_url})");
344 }
345
346 let cancel_path = format!(
347 "/repos/{}/{}/actions/runs/{id}/cancel",
348 spec.owner, spec.repo
349 );
350 let cancel_response = client
351 .request("POST", &cancel_path, &[], None)
352 .context("failed to cancel run")?;
353
354 let cancel_status = cancel_response.status();
355 if !cancel_status.is_success() {
356 anyhow::bail!("failed to cancel run #{id}: HTTP {cancel_status}");
357 }
358
359 let html_url = run_data["html_url"].as_str().unwrap_or("");
360 println!("Run #{id} cancelled ({html_url})");
361 Ok(())
362}
363
364fn download(
365 id: u64,
366 repo: Option<&str>,
367 dir: &str,
368 names: &[String],
369 log: bool,
370 hostname: Option<&str>,
371) -> anyhow::Result<()> {
372 let spec = match repo {
373 Some(s) => repository::parse_repo_spec(s).context("invalid repository spec")?,
374 None => repository::detect_remote().ok_or_else(|| {
375 anyhow::anyhow!("could not detect repository; specify OWNER/REPO with --repo")
376 })?,
377 };
378
379 let host = hostname.unwrap_or("github.com");
380 let client = Client::new(host).context("failed to create HTTP client")?;
381
382 if log {
383 let jobs_path = format!("/repos/{}/{}/actions/runs/{id}/jobs", spec.owner, spec.repo);
385 let jobs_response = client.get(&jobs_path).context("failed to fetch jobs")?;
386 let jobs_data: serde_json::Value = jobs_response.json().context("failed to parse jobs")?;
387 let jobs: Vec<serde_json::Value> = jobs_data["jobs"]
388 .as_array()
389 .map_or_else(Vec::new, Clone::clone);
390
391 if jobs.is_empty() {
392 anyhow::bail!("no jobs found for run #{id}");
393 }
394
395 for job in &jobs {
396 let job_id = job["id"].as_u64().unwrap_or(0);
397 let job_name = job["name"].as_str().unwrap_or("unknown");
398 let log_path = format!(
399 "/repos/{}/{}/actions/jobs/{job_id}/logs",
400 spec.owner, spec.repo
401 );
402 let log_response = client.get(&log_path).context("failed to download logs")?;
403 if log_response.status().is_success() {
404 let body = log_response.text().context("failed to read logs")?;
405 let filename = format!("{dir}/{job_name}.log");
406 std::fs::write(&filename, &body)
407 .with_context(|| format!("failed to write {filename}"))?;
408 println!("Downloaded: {filename}");
409 }
410 }
411 return Ok(());
412 }
413
414 let artifacts_path = format!(
416 "/repos/{}/{}/actions/runs/{id}/artifacts",
417 spec.owner, spec.repo
418 );
419 let artifacts_response = client
420 .get(&artifacts_path)
421 .context("failed to fetch artifacts")?;
422 let artifacts_data: serde_json::Value = artifacts_response
423 .json()
424 .context("failed to parse artifacts")?;
425 let artifacts: Vec<serde_json::Value> = artifacts_data["artifacts"]
426 .as_array()
427 .map_or_else(Vec::new, Clone::clone);
428
429 let filtered: Vec<&serde_json::Value> = if names.is_empty() {
430 artifacts.iter().collect()
431 } else {
432 artifacts
433 .iter()
434 .filter(|a| {
435 let a_name = a["name"].as_str().unwrap_or("");
436 names.iter().any(|n| n == a_name)
437 })
438 .collect()
439 };
440
441 if filtered.is_empty() {
442 anyhow::bail!("no artifacts found for run #{id}");
443 }
444
445 for artifact in &filtered {
446 let artifact_id = artifact["id"].as_u64().unwrap_or(0);
447 let artifact_name = artifact["name"].as_str().unwrap_or("unknown");
448 let zip_path = format!(
449 "/repos/{}/{}/actions/artifacts/{artifact_id}/zip",
450 spec.owner, spec.repo
451 );
452 let zip_response = client
453 .get(&zip_path)
454 .context("failed to download artifact")?;
455 if zip_response.status().is_success() {
456 let bytes = zip_response.bytes().context("failed to read artifact")?;
457 let filename = format!("{dir}/{artifact_name}.zip");
458 std::fs::write(&filename, &bytes)
459 .with_context(|| format!("failed to write {filename}"))?;
460 println!("Downloaded: {filename}");
461 }
462 }
463
464 Ok(())
465}
466
467fn rerun(
468 id: u64,
469 repo: Option<&str>,
470 failed_jobs: bool,
471 debug: bool,
472 hostname: Option<&str>,
473) -> anyhow::Result<()> {
474 let spec = match repo {
475 Some(s) => repository::parse_repo_spec(s).context("invalid repository spec")?,
476 None => repository::detect_remote().ok_or_else(|| {
477 anyhow::anyhow!("could not detect repository; specify OWNER/REPO with --repo")
478 })?,
479 };
480
481 let host = hostname.unwrap_or("github.com");
482 let client = Client::new(host).context("failed to create HTTP client")?;
483
484 let path = format!(
485 "/repos/{}/{}/actions/runs/{id}/rerun",
486 spec.owner, spec.repo
487 );
488
489 let mut body = serde_json::Map::new();
490 if failed_jobs {
491 body.insert(
492 "enable_debug_logging".to_string(),
493 serde_json::Value::Bool(debug),
494 );
495 }
496
497 let body_bytes = if body.is_empty() {
498 None
499 } else {
500 Some(serde_json::to_vec(&body).context("serialize")?)
501 };
502
503 let response = client
504 .request("POST", &path, &[], body_bytes)
505 .context("failed to rerun workflow")?;
506
507 let status = response.status();
508 if !status.is_success() {
509 anyhow::bail!("failed to rerun run #{id}: HTTP {status}");
510 }
511
512 let result: serde_json::Value = response.json().context("failed to parse response")?;
513 let html_url = result["html_url"].as_str().unwrap_or("");
514 println!("Run #{id} rerun: {html_url}");
515 Ok(())
516}
517
518fn watch(
519 id: u64,
520 repo: Option<&str>,
521 interval: u64,
522 exit_status: bool,
523 hostname: Option<&str>,
524) -> anyhow::Result<()> {
525 let spec = match repo {
526 Some(s) => repository::parse_repo_spec(s).context("invalid repository spec")?,
527 None => repository::detect_remote().ok_or_else(|| {
528 anyhow::anyhow!("could not detect repository; specify OWNER/REPO with --repo")
529 })?,
530 };
531
532 let host = hostname.unwrap_or("github.com");
533 let client = Client::new(host).context("failed to create HTTP client")?;
534
535 let terminal_states = ["completed", "cancelled", "skipped", "timed_out"];
536 let mut prev_job_states: Vec<(u64, String)> = Vec::new();
537
538 loop {
539 let path = format!("/repos/{}/{}/actions/runs/{id}", spec.owner, spec.repo);
540 let response = client.get(&path).context("failed to fetch run")?;
541 let run_data: serde_json::Value = response.json().context("failed to parse response")?;
542 let run_status = run_data["status"].as_str().unwrap_or("");
543 let conclusion = run_data["conclusion"].as_str().unwrap_or("");
544
545 let jobs_path = format!("/repos/{}/{}/actions/runs/{id}/jobs", spec.owner, spec.repo);
547 if let Ok(jobs_resp) = client.get(&jobs_path) {
548 if let Ok(jobs_data) = jobs_resp.json::<serde_json::Value>() {
549 if let Some(jobs) = jobs_data["jobs"].as_array() {
550 for job in jobs {
551 let job_id = job["id"].as_u64().unwrap_or(0);
552 let job_name = job["name"].as_str().unwrap_or("");
553 let job_status = job["status"].as_str().unwrap_or("");
554 let job_conclusion = job["conclusion"].as_str().unwrap_or("");
555 let state_str = format!("{job_status}/{job_conclusion}");
556
557 let prev = prev_job_states.iter().find(|(jid, _)| *jid == job_id);
558 let changed = prev.is_none_or(|(_, s)| s != &state_str);
559
560 if changed {
561 println!(" {job_name}: {job_status} ({job_conclusion})");
562 prev_job_states.retain(|(jid, _)| *jid != job_id);
563 prev_job_states.push((job_id, state_str));
564 }
565 }
566 }
567 }
568 }
569
570 if terminal_states.contains(&run_status) {
571 let html_url = run_data["html_url"].as_str().unwrap_or("");
572 println!("Run #{id}: {run_status} ({conclusion}) — {html_url}");
573 if exit_status && conclusion == "failure" {
574 std::process::exit(1);
575 }
576 return Ok(());
577 }
578
579 std::thread::sleep(std::time::Duration::from_secs(interval));
580 }
581}
582
583fn delete_run(
591 id: u64,
592 repo: Option<&str>,
593 yes: bool,
594 hostname: Option<&str>,
595) -> anyhow::Result<()> {
596 if !yes {
597 use std::io::Write;
598 print!("Are you sure you want to delete run #{id}? [y/N] ");
599 std::io::stdout().flush().ok();
600
601 let mut input = String::new();
602 std::io::stdin()
603 .read_line(&mut input)
604 .context("failed to read input")?;
605 let input = input.trim().to_lowercase();
606 if input != "y" && input != "yes" {
607 println!("Cancelled.");
608 return Ok(());
609 }
610 }
611
612 let host = hostname.unwrap_or("github.com");
613 let client = Client::new(host).context("failed to create HTTP client")?;
614
615 let spec = if let Some(r) = repo {
616 repository::parse_repo_spec(r).with_context(|| format!("invalid repository: {r}"))?
617 } else {
618 repository::detect_remote().context("could not detect repository from git remote")?
619 };
620
621 let path = format!("/repos/{}/{}/actions/runs/{id}", spec.owner, spec.repo);
622
623 let response = client
624 .request("DELETE", &path, &[], None)
625 .context("failed to delete run")?;
626
627 let status = response.status();
628 if !status.is_success() {
629 let err_body: serde_json::Value = response.json().unwrap_or_default();
630 let msg = err_body["message"].as_str().unwrap_or("delete failed");
631 anyhow::bail!("failed to delete run #{id}: {msg}");
632 }
633
634 println!("Run #{id} deleted.");
635 Ok(())
636}