Skip to main content

gor/cmd/
run.rs

1//! Implementation of the `gor run` subcommand.
2//!
3//! Provides workflow run listing.
4
5#![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
14/// Run the `gor run` subcommand.
15///
16/// # Errors
17///
18/// Returns an error if the command execution fails.
19pub 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    // Fetch jobs
210    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    // Handle --log: show logs for a specific job
229    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    // Handle --log-failed: show logs for failed jobs
246    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    // Default: print run details and jobs
282    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    // First check the run status
327    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        // Download job logs
384        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    // Download artifacts
415    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        // Fetch jobs
546        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
583/// Execute `gor run delete`.
584///
585/// Deletes a workflow run and its logs.
586///
587/// # Errors
588///
589/// Returns an error if the run does not exist or the API request fails.
590fn 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}