Skip to main content

runtime_cli/commands/
run.rs

1//! `runtime run` — action-run listing / view / logs / cancel.
2
3use std::io::Write;
4use std::thread::sleep;
5use std::time::Duration;
6
7use anyhow::{anyhow, Result};
8use clap::{Args, Subcommand};
9use serde_json::{json, Value};
10
11use crate::client;
12use crate::commands::issue::resolve_repo;
13use crate::output::{render_record, render_table, FormatChoice};
14
15#[derive(Debug, Subcommand)]
16pub enum RunCmd {
17    /// List action runs for a repository.
18    List(ListArgs),
19    /// View a single run.
20    View(ViewArgs),
21    /// Print the run's log buffer.
22    Logs(LogsArgs),
23    /// Cancel a queued or running action run.
24    Cancel(IdArgs),
25}
26
27#[derive(Debug, Args)]
28pub struct ListArgs {
29    pub repo: String,
30    #[arg(long, default_value_t = false)]
31    pub json: bool,
32    #[arg(long, default_value_t = false)]
33    pub tsv: bool,
34}
35
36#[derive(Debug, Args)]
37pub struct ViewArgs {
38    pub id: String,
39    #[arg(long, default_value_t = false)]
40    pub json: bool,
41    #[arg(long, default_value_t = false)]
42    pub tsv: bool,
43}
44
45#[derive(Debug, Args)]
46pub struct LogsArgs {
47    pub id: String,
48    /// Poll for new log content until the run completes.
49    #[arg(long, default_value_t = false)]
50    pub follow: bool,
51}
52
53#[derive(Debug, Args)]
54pub struct IdArgs {
55    pub id: String,
56}
57
58pub fn run(cmd: RunCmd) -> Result<()> {
59    match cmd {
60        RunCmd::List(a) => list(a),
61        RunCmd::View(a) => view(a),
62        RunCmd::Logs(a) => logs(a),
63        RunCmd::Cancel(a) => cancel(a),
64    }
65}
66
67fn list(args: ListArgs) -> Result<()> {
68    let (cli, _cfg) = client::authenticated()?;
69    let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
70    let data = cli.execute(LIST_RUNS, json!({ "id": repo_id }))?;
71    let runs = data["actionRuns"].as_array().cloned().unwrap_or_default();
72    let rows = run_rows(&runs);
73    let fmt = FormatChoice {
74        json: args.json,
75        tsv: args.tsv,
76    }
77    .resolve();
78    let out = render_table(&["id", "status", "workflow", "job", "started"], &rows, fmt);
79    std::io::stdout().lock().write_all(out.as_bytes())?;
80    Ok(())
81}
82
83fn view(args: ViewArgs) -> Result<()> {
84    let (cli, _cfg) = client::authenticated()?;
85    let data = cli.execute(VIEW_RUN, json!({ "id": args.id }))?;
86    let r = &data["actionRun"];
87    if r.is_null() {
88        return Err(anyhow!("no run with id {}", args.id));
89    }
90    let pairs = run_record_pairs(r, &args.id);
91    let fmt = FormatChoice {
92        json: args.json,
93        tsv: args.tsv,
94    }
95    .resolve();
96    std::io::stdout()
97        .lock()
98        .write_all(render_record(&pairs, fmt).as_bytes())?;
99    Ok(())
100}
101
102fn logs(args: LogsArgs) -> Result<()> {
103    let (cli, _cfg) = client::authenticated()?;
104    let mut printed: usize = 0;
105    loop {
106        let data = cli.execute(LOG_RUN, json!({ "id": args.id }))?;
107        let buf = data["actionRun"]["log"].as_str().unwrap_or("");
108        let status = data["actionRun"]["status"]
109            .as_str()
110            .unwrap_or("")
111            .to_string();
112        if let Some(chunk) = next_log_chunk(buf, printed) {
113            std::io::stdout().lock().write_all(chunk.as_bytes())?;
114            printed = buf.len();
115        }
116        if !args.follow {
117            break;
118        }
119        if is_terminal_log_status(&status) {
120            break;
121        }
122        sleep(Duration::from_millis(750));
123    }
124    Ok(())
125}
126
127fn cancel(args: IdArgs) -> Result<()> {
128    let (cli, _cfg) = client::authenticated()?;
129    cli.execute(CANCEL_RUN, json!({ "id": args.id }))?;
130    writeln!(std::io::stdout().lock(), "Cancelled run {}", args.id)?;
131    Ok(())
132}
133
134fn is_terminal_log_status(status: &str) -> bool {
135    matches!(
136        status,
137        "SUCCESS" | "SUCCEEDED" | "FAILURE" | "FAILED" | "CANCELLED" | "CANCELED" | "SKIPPED"
138    )
139}
140
141fn run_rows(runs: &[Value]) -> Vec<Vec<String>> {
142    runs.iter()
143        .map(|r| {
144            vec![
145                r["id"].as_str().unwrap_or("").to_string(),
146                r["status"].as_str().unwrap_or("").to_string(),
147                r["workflowName"].as_str().unwrap_or("").to_string(),
148                r["jobName"].as_str().unwrap_or("").to_string(),
149                r["startedAt"].as_str().unwrap_or("").to_string(),
150            ]
151        })
152        .collect()
153}
154
155fn run_record_pairs(run: &Value, id: &str) -> Vec<(&'static str, String)> {
156    vec![
157        ("Run", id.to_string()),
158        ("Status", run["status"].as_str().unwrap_or("").to_string()),
159        (
160            "Workflow",
161            run["workflowName"].as_str().unwrap_or("").to_string(),
162        ),
163        ("Job", run["jobName"].as_str().unwrap_or("").to_string()),
164        (
165            "Commit",
166            run["commitOid"].as_str().unwrap_or("").to_string(),
167        ),
168        (
169            "Triggered by",
170            run["triggeredByUsername"]
171                .as_str()
172                .unwrap_or("")
173                .to_string(),
174        ),
175        ("Queued", run["queuedAt"].as_str().unwrap_or("").to_string()),
176        (
177            "Started",
178            run["startedAt"].as_str().unwrap_or("").to_string(),
179        ),
180        (
181            "Completed",
182            run["completedAt"].as_str().unwrap_or("").to_string(),
183        ),
184    ]
185}
186
187fn next_log_chunk(buf: &str, printed: usize) -> Option<&str> {
188    (buf.len() > printed).then(|| &buf[printed..])
189}
190
191const LIST_RUNS: &str = "query($id: UUID!) { \
192    actionRuns(repositoryId: $id) { \
193        id status workflowName jobName startedAt completedAt \
194    } \
195}";
196
197const VIEW_RUN: &str = "query($id: UUID!) { \
198    actionRun(id: $id) { \
199        id status workflowName jobName commitOid triggeredByUsername \
200        queuedAt startedAt completedAt \
201    } \
202}";
203
204const LOG_RUN: &str = "query($id: UUID!) { actionRun(id: $id) { status log } }";
205const CANCEL_RUN: &str = "mutation($id: UUID!) { cancelActionRun(id: $id) { id status } }";
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn terminal_log_statuses_match_api_and_legacy_values() {
213        for status in [
214            "SUCCESS",
215            "SUCCEEDED",
216            "FAILURE",
217            "FAILED",
218            "CANCELLED",
219            "CANCELED",
220            "SKIPPED",
221        ] {
222            assert!(
223                is_terminal_log_status(status),
224                "{status} should stop following logs"
225            );
226        }
227
228        for status in ["QUEUED", "RUNNING", "IN_PROGRESS", ""] {
229            assert!(
230                !is_terminal_log_status(status),
231                "{status:?} should keep following logs"
232            );
233        }
234    }
235
236    #[test]
237    fn run_rows_and_record_pairs_format_values_with_defaults() {
238        let runs = vec![
239            json!({
240                "id": "run-1",
241                "status": "RUNNING",
242                "workflowName": "ci",
243                "jobName": "test",
244                "startedAt": "2026-06-18T01:00:00Z"
245            }),
246            json!({}),
247        ];
248
249        assert_eq!(
250            run_rows(&runs),
251            vec![
252                vec![
253                    "run-1".to_string(),
254                    "RUNNING".to_string(),
255                    "ci".to_string(),
256                    "test".to_string(),
257                    "2026-06-18T01:00:00Z".to_string(),
258                ],
259                vec![
260                    String::new(),
261                    String::new(),
262                    String::new(),
263                    String::new(),
264                    String::new(),
265                ],
266            ]
267        );
268
269        let run = json!({
270            "status": "SUCCESS",
271            "workflowName": "release",
272            "jobName": "publish",
273            "commitOid": "abc123",
274            "triggeredByUsername": "bri",
275            "queuedAt": "2026-06-18T00:59:00Z",
276            "startedAt": "2026-06-18T01:00:00Z",
277            "completedAt": "2026-06-18T01:05:00Z"
278        });
279
280        assert_eq!(
281            run_record_pairs(&run, "run-2"),
282            vec![
283                ("Run", "run-2".to_string()),
284                ("Status", "SUCCESS".to_string()),
285                ("Workflow", "release".to_string()),
286                ("Job", "publish".to_string()),
287                ("Commit", "abc123".to_string()),
288                ("Triggered by", "bri".to_string()),
289                ("Queued", "2026-06-18T00:59:00Z".to_string()),
290                ("Started", "2026-06-18T01:00:00Z".to_string()),
291                ("Completed", "2026-06-18T01:05:00Z".to_string()),
292            ]
293        );
294        assert_eq!(
295            run_record_pairs(&json!({}), "run-3")[0],
296            ("Run", "run-3".to_string())
297        );
298    }
299
300    #[test]
301    fn next_log_chunk_returns_only_unprinted_suffix() {
302        assert_eq!(next_log_chunk("hello world", 6), Some("world"));
303        assert_eq!(next_log_chunk("hello", 5), None);
304        assert_eq!(next_log_chunk("", 0), None);
305    }
306}