Skip to main content

gor/cmd/
workflow.rs

1//! Implementation of the `gor workflow` subcommand.
2//!
3//! Provides GitHub Actions workflow listing.
4
5#![allow(clippy::print_stdout)]
6
7use crate::cli::WorkflowCommand;
8use crate::client::Client;
9use crate::output::print_json;
10use crate::repository::{detect_remote, parse_repo_spec};
11use anyhow::Context;
12
13/// Run the `gor workflow` subcommand.
14///
15/// # Errors
16///
17/// Returns an error if the command execution fails.
18pub fn run(cmd: WorkflowCommand) -> anyhow::Result<()> {
19    match cmd {
20        WorkflowCommand::List {
21            repo,
22            limit,
23            json,
24            hostname,
25        } => list(repo.as_deref(), limit, json, hostname.as_deref()),
26        WorkflowCommand::View {
27            workflow,
28            repo,
29            json,
30            hostname,
31        } => view(&workflow, repo.as_deref(), json, hostname.as_deref()),
32        WorkflowCommand::Enable {
33            workflow,
34            repo,
35            hostname,
36        } => enable(&workflow, repo.as_deref(), hostname.as_deref()),
37        WorkflowCommand::Disable {
38            workflow,
39            repo,
40            hostname,
41        } => disable(&workflow, repo.as_deref(), hostname.as_deref()),
42        WorkflowCommand::Run {
43            workflow,
44            repo,
45            branch,
46            hostname,
47        } => trigger_workflow_run(
48            &workflow,
49            repo.as_deref(),
50            branch.as_deref(),
51            hostname.as_deref(),
52        ),
53    }
54}
55
56/// Execute `gor workflow list`.
57///
58/// Lists GitHub Actions workflows in a repository.
59///
60/// # Errors
61///
62/// Returns an error if the repository cannot be found or the API request fails.
63fn list(
64    repo: Option<&str>,
65    limit: u32,
66    json: Option<Vec<String>>,
67    hostname: Option<&str>,
68) -> anyhow::Result<()> {
69    let spec = match repo {
70        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
71        None => detect_remote().ok_or_else(|| {
72            anyhow::anyhow!(
73                "could not detect repository from current directory; specify OWNER/REPO with --repo"
74            )
75        })?,
76    };
77
78    let host = hostname.unwrap_or("github.com");
79    let client = Client::new(host).context("failed to create HTTP client")?;
80
81    let path = format!(
82        "/repos/{}/{}/actions/workflows?per_page={}",
83        spec.owner,
84        spec.repo,
85        limit.min(100)
86    );
87    let response = client.get(&path).context("failed to fetch workflows")?;
88
89    let status = response.status();
90    if status == reqwest::StatusCode::NOT_FOUND {
91        anyhow::bail!("repository '{spec}' not found");
92    }
93    if !status.is_success() {
94        anyhow::bail!("failed to list workflows for '{spec}': HTTP {status}");
95    }
96
97    let result: serde_json::Value = response
98        .json()
99        .context("failed to parse workflows response")?;
100    let mut workflows: Vec<serde_json::Value> = result["workflows"]
101        .as_array()
102        .map_or_else(Vec::new, Clone::clone);
103
104    workflows.truncate(limit as usize);
105
106    if let Some(fields) = json {
107        let fields_ref: Option<&[String]> = if fields.is_empty() {
108            None
109        } else {
110            Some(&fields)
111        };
112        print_json(&workflows, fields_ref);
113        return Ok(());
114    }
115
116    print_workflow_table(&workflows);
117    Ok(())
118}
119
120/// Execute `gor workflow view`.
121///
122/// Views a workflow's details and recent runs.
123///
124/// # Errors
125///
126/// Returns an error if the repository cannot be found or the API request fails.
127fn view(
128    workflow: &str,
129    repo: Option<&str>,
130    json: Option<Vec<String>>,
131    hostname: Option<&str>,
132) -> anyhow::Result<()> {
133    let spec = match repo {
134        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
135        None => detect_remote().ok_or_else(|| {
136            anyhow::anyhow!(
137                "could not detect repository from current directory; specify OWNER/REPO with --repo"
138            )
139        })?,
140    };
141
142    let host = hostname.unwrap_or("github.com");
143    let client = Client::new(host).context("failed to create HTTP client")?;
144
145    // Determine if the workflow arg is a numeric ID or a filename/name
146    let path = if workflow.parse::<u64>().is_ok() {
147        format!(
148            "/repos/{}/{}/actions/workflows/{workflow}",
149            spec.owner, spec.repo
150        )
151    } else {
152        // URL-encode the filename: replace / with %2F
153        let encoded = workflow.replace('/', "%2F");
154        format!(
155            "/repos/{}/{}/actions/workflows/{encoded}",
156            spec.owner, spec.repo
157        )
158    };
159
160    let response = client.get(&path).context("failed to fetch workflow")?;
161
162    let status = response.status();
163    if status == reqwest::StatusCode::NOT_FOUND {
164        anyhow::bail!("workflow '{workflow}' not found in repository '{spec}'");
165    }
166    if !status.is_success() {
167        anyhow::bail!("failed to view workflow: HTTP {status}");
168    }
169
170    let wf: serde_json::Value = response
171        .json()
172        .context("failed to parse workflow response")?;
173
174    // Fetch recent runs (up to 5)
175    let runs_path = format!(
176        "/repos/{}/{}/actions/workflows/{}/runs?per_page=5",
177        spec.owner, spec.repo, workflow
178    );
179    let runs_response = client.get(&runs_path);
180    let recent_runs: Vec<serde_json::Value> = runs_response.map_or_else(
181        |_| Vec::new(),
182        |resp| {
183            if resp.status().is_success() {
184                resp.json::<serde_json::Value>().map_or_else(
185                    |_| Vec::new(),
186                    |runs_data| {
187                        runs_data["workflow_runs"]
188                            .as_array()
189                            .map_or_else(Vec::new, Clone::clone)
190                    },
191                )
192            } else {
193                Vec::new()
194            }
195        },
196    );
197
198    // --json: output as JSON
199    if let Some(fields) = json {
200        let fields_ref: Option<&[String]> = if fields.is_empty() {
201            None
202        } else {
203            Some(&fields)
204        };
205        print_json(&wf, fields_ref);
206        return Ok(());
207    }
208
209    // Default: print details
210    let name = wf["name"].as_str().unwrap_or("—");
211    let state = wf["state"].as_str().unwrap_or("—");
212    let wf_path = wf["path"].as_str().unwrap_or("—");
213
214    println!("  Name: {name}");
215    println!("  State: {state}");
216    println!("  Path: {wf_path}");
217
218    // Print trigger events
219    if let Some(events) = wf["triggering_events"].as_array() {
220        if !events.is_empty() {
221            println!("  Events:");
222            for event in events {
223                let event_str = serde_json::to_string(event).unwrap_or_default();
224                println!("    - {event_str}");
225            }
226        }
227    }
228
229    // Print recent runs
230    if !recent_runs.is_empty() {
231        println!("  Recent runs:");
232        println!(
233            "    {:<8}  {:<10}  {:<12}  {:<20}  {:<16}",
234            "RUN ID", "STATUS", "CONCLUSION", "BRANCH", "TIMESTAMP"
235        );
236        for run in &recent_runs {
237            let run_id = run["id"].as_u64().unwrap_or(0);
238            let run_status = run["status"].as_str().unwrap_or("—");
239            let conclusion = run["conclusion"].as_str().unwrap_or("—");
240            let branch = run["head_branch"].as_str().unwrap_or("—");
241            let created = run["created_at"]
242                .as_str()
243                .map_or_else(|| "—".to_string(), crate::output::format_date);
244
245            println!(
246                "    {run_id:<8}  {run_status:<10}  {conclusion:<12}  {branch:<20}  {created:<16}",
247            );
248        }
249    }
250
251    Ok(())
252}
253
254/// Print a formatted workflow list table.
255fn print_workflow_table(workflows: &[serde_json::Value]) {
256    if workflows.is_empty() {
257        println!("No workflows found.");
258        return;
259    }
260
261    let name_width = 40;
262    let state_width = 12;
263    let id_width = 8;
264
265    println!(
266        "{:<id_width$}  {:<name_width$}  {:<state_width$}",
267        "ID", "NAME", "STATE",
268    );
269
270    for wf in workflows {
271        let id = wf["id"].as_u64().unwrap_or(0);
272        let name = wf["name"].as_str().unwrap_or("—");
273        let state = wf["state"].as_str().unwrap_or("—");
274
275        let name_truncated = crate::cmd::util::truncate(name, name_width);
276
277        println!("{id:<id_width$}  {name_truncated:<name_width$}  {state:<state_width$}");
278    }
279}
280
281/// Execute `gor workflow enable`.
282///
283/// Enables a workflow.
284///
285/// # Errors
286///
287/// Returns an error if the repository cannot be found or the API request fails.
288fn enable(workflow: &str, repo: Option<&str>, hostname: Option<&str>) -> anyhow::Result<()> {
289    let spec = match repo {
290        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
291        None => detect_remote().ok_or_else(|| {
292            anyhow::anyhow!(
293                "could not detect repository from current directory; specify OWNER/REPO with --repo"
294            )
295        })?,
296    };
297
298    let host = hostname.unwrap_or("github.com");
299    let client = Client::new(host).context("failed to create HTTP client")?;
300
301    let path = format!(
302        "/repos/{}/{}/actions/workflows/{workflow}/enable",
303        spec.owner, spec.repo
304    );
305
306    let response = client
307        .request("PUT", &path, &[], None)
308        .context("failed to enable workflow")?;
309
310    let status = response.status();
311    if !status.is_success() {
312        anyhow::bail!("failed to enable workflow '{workflow}': HTTP {status}");
313    }
314
315    println!("Workflow '{workflow}' is now enabled.");
316    Ok(())
317}
318
319/// Execute `gor workflow disable`.
320///
321/// Disables a workflow.
322///
323/// # Errors
324///
325/// Returns an error if the repository cannot be found or the API request fails.
326fn disable(workflow: &str, repo: Option<&str>, hostname: Option<&str>) -> anyhow::Result<()> {
327    let spec = match repo {
328        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
329        None => detect_remote().ok_or_else(|| {
330            anyhow::anyhow!(
331                "could not detect repository from current directory; specify OWNER/REPO with --repo"
332            )
333        })?,
334    };
335
336    let host = hostname.unwrap_or("github.com");
337    let client = Client::new(host).context("failed to create HTTP client")?;
338
339    let path = format!(
340        "/repos/{}/{}/actions/workflows/{workflow}/disable",
341        spec.owner, spec.repo
342    );
343
344    let response = client
345        .request("PUT", &path, &[], None)
346        .context("failed to disable workflow")?;
347
348    let status = response.status();
349    if !status.is_success() {
350        anyhow::bail!("failed to disable workflow '{workflow}': HTTP {status}");
351    }
352
353    println!("Workflow '{workflow}' is now disabled.");
354    Ok(())
355}
356
357/// Execute `gor workflow run`.
358///
359/// Triggers a workflow dispatch run.
360///
361/// # Errors
362///
363/// Returns an error if the repository cannot be found or the API request fails.
364fn trigger_workflow_run(
365    workflow: &str,
366    repo: Option<&str>,
367    branch: Option<&str>,
368    hostname: Option<&str>,
369) -> anyhow::Result<()> {
370    let spec = match repo {
371        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
372        None => detect_remote().ok_or_else(|| {
373            anyhow::anyhow!(
374                "could not detect repository from current directory; specify OWNER/REPO with --repo"
375            )
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    let body_value = serde_json::json!({
383        "ref": branch.unwrap_or("main"),
384    });
385    let body_bytes = serde_json::to_vec(&body_value).context("failed to serialize body")?;
386
387    let path = format!(
388        "/repos/{}/{}/actions/workflows/{workflow}/dispatches",
389        spec.owner, spec.repo
390    );
391
392    let response = client
393        .request("POST", &path, &[], Some(body_bytes))
394        .context("failed to trigger workflow run")?;
395
396    let status = response.status();
397    if !status.is_success() {
398        anyhow::bail!("failed to trigger workflow run: HTTP {status}");
399    }
400
401    println!("Workflow run triggered for '{workflow}'.");
402    Ok(())
403}
404
405#[cfg(test)]
406#[allow(clippy::expect_used)]
407mod tests {
408    use super::*;
409    use serde_json::json;
410
411    #[test]
412    fn print_workflow_table_basic() {
413        let workflows = vec![
414            json!({
415                "id": 12345,
416                "name": "CI",
417                "state": "active"
418            }),
419            json!({
420                "id": 67890,
421                "name": "Deploy",
422                "state": "active"
423            }),
424        ];
425        print_workflow_table(&workflows);
426    }
427
428    #[test]
429    fn print_workflow_table_empty() {
430        let workflows: Vec<serde_json::Value> = vec![];
431        print_workflow_table(&workflows);
432    }
433}