1use 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(ListArgs),
19 View(ViewArgs),
21 Logs(LogsArgs),
23 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 #[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}