1#![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
13pub 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
56fn 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
120fn 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 let path = if workflow.parse::<u64>().is_ok() {
147 format!(
148 "/repos/{}/{}/actions/workflows/{workflow}",
149 spec.owner, spec.repo
150 )
151 } else {
152 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 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 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 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 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 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
254fn 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
281fn 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
319fn 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
357fn 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}