CLI: fork run approval, Run workflow, and the newer CI statuses

d1406da · Gabriel Morell · 2026-10-18 05:59

14 files +902 -53
Message
{commit_body(@commit)}

Files changed

modified cli/README.md
+29 −1
@@ -50,6 +50,9 @@ gg ci logs [N] [--job JOB] a job's logs, grouped by step
50 50 gg ci rerun N [--job JOB] re-queue a run, or one job of it
51 51 gg ci cancel N cancel a queued or running run
52 52 gg ci watch [N] poll until it settles; exits non-zero on failure
53 +gg ci workflows workflows that can be run by hand, with their inputs
54 +gg ci dispatch WORKFLOW [--ref REF] [-f NAME=VALUE …] [--watch]
55 + run one (workflow_dispatch); prints the new run
53 56
54 57 gg issues list [--state open|closed|all] [--search TEXT]
55 58 gg issue show N [--no-comments]
@@ -61,6 +64,8 @@ gg pull show N
61 64 gg pull comment N --body TEXT
62 65 gg pull review N --state approve|request-changes|comment [--body TEXT]
63 66 gg pull merge N [--message TEXT] [--override-rules]
67 +gg pull approve-runs N let a fork's waiting workflow runs start
68 +gg pull reject-runs N cancel them instead
64 69
65 70 gg wiki list | gg wiki show SLUG
66 71 gg packages list | gg package show FORMAT NAME | gg package usage FORMAT
@@ -69,7 +74,30 @@ gg dashboard [--refresh SECS] interactive CI dashboard
69 74 ```
70 75
71 76 `--json` on any command prints the server's response verbatim instead of
72 −a table, which is the intended way to script against it.
77 +a table, which is the intended way to script against it. (`gg ci dispatch
78 +--json` prints the new run and doesn't watch it.)
79 +
80 +## CI statuses
81 +
82 +Statuses read as the Android app's do. Besides queued, running, blocked,
83 +success, failure, cancelled and skipped:
84 +
85 +| mark | status | what |
86 +| --- | --- | --- |
87 +| `…` | waiting | held by its concurrency group |
88 +| `⚑` | needs approval | a fork pull request's run, waiting for someone with write access (`gg pull approve-runs N`) |
89 +| `⊘` | superseded | cancelled because a newer run or job in its concurrency group replaced it — dim, not red |
90 +
91 +`gg ci show` lists why each job is waiting, skipped or cancelled, a re-run
92 +job's attempt, and says when a dynamic matrix is still a placeholder; such a
93 +job is replaced by its legs (new ids) when it expands, so `gg ci logs` on
94 +one that's gone says so. `gg ci watch` keeps watching anything that hasn't
95 +finished — including a run waiting for approval, which it points out once.
96 +`gg pull show` lists a fork's runs waiting for approval.
97 +
98 +`gg ci dispatch` takes inputs as strings, `true`/`false` for a boolean;
99 +the server checks them against what the workflow declares and the CLI
100 +prints its reason per input when it refuses them.
73 101
74 102 ## Dashboard
75 103
modified cli/src/api/mod.rs
+143 −7
@@ -14,6 +14,7 @@ use anyhow::{Context, Result, anyhow, bail};
14 14 use reqwest::{Method, StatusCode};
15 15 use serde::de::DeserializeOwned;
16 16 use serde_json::{Value, json};
17 +use std::collections::BTreeMap;
17 18 use std::ops::Deref;
18 19 use std::time::Duration;
19 20 use url::Url;
@@ -141,7 +142,7 @@ impl Client {
141 142 let text = res.text().await.context("reading the response body")?;
142 143
143 144 if !status.is_success() {
144 − bail!("{}", describe(status, &text));
145 + return Err(ApiFailure::new(status, &text).into());
145 146 }
146 147
147 148 let raw: Value = serde_json::from_str(&text)
@@ -286,6 +287,34 @@ impl Client {
286 287 .await
287 288 }
288 289
290 + /// The default branch's `on: workflow_dispatch` workflows, with the
291 + /// inputs each declares.
292 + pub async fn workflows(&self, repo: &RepoRef) -> Result<Res<WorkflowsResponse>> {
293 + self.get(&["repos", &repo.owner, &repo.name, "workflows"], &[])
294 + .await
295 + }
296 +
297 + /// `workflow_dispatch`. Inputs go as strings — `"true"`/`"false"` for
298 + /// a boolean — as GitHub's API takes them; the server checks each
299 + /// against its declared type and answers 422 with per-input reasons.
300 + pub async fn dispatch(
301 + &self,
302 + repo: &RepoRef,
303 + workflow: &str,
304 + git_ref: Option<&str>,
305 + inputs: &BTreeMap<String, String>,
306 + ) -> Result<Res<RunResponse>> {
307 + let mut body = json!({ "workflow": workflow, "inputs": inputs });
308 + if let Some(git_ref) = git_ref {
309 + body["ref"] = json!(git_ref);
310 + }
311 + self.post(
312 + &["repos", &repo.owner, &repo.name, "workflows", "dispatch"],
313 + Some(body),
314 + )
315 + .await
316 + }
317 +
289 318 // ── issues ───────────────────────────────────────────────────────
290 319
291 320 pub async fn issues(
@@ -454,6 +483,43 @@ impl Client {
454 483 .await
455 484 }
456 485
486 + /// Let a fork's runs waiting on the pull request's current head go
487 + /// ahead. Answers with the runs it approved.
488 + pub async fn approve_pull_runs(
489 + &self,
490 + repo: &RepoRef,
491 + number: i64,
492 + ) -> Result<Res<RunsResponse>> {
493 + self.pull_runs(repo, number, "approve").await
494 + }
495 +
496 + /// Cancel a fork's runs waiting for approval on the pull request, on
497 + /// any head. Answers with the runs it cancelled.
498 + pub async fn reject_pull_runs(&self, repo: &RepoRef, number: i64) -> Result<Res<RunsResponse>> {
499 + self.pull_runs(repo, number, "reject").await
500 + }
501 +
502 + async fn pull_runs(
503 + &self,
504 + repo: &RepoRef,
505 + number: i64,
506 + verb: &str,
507 + ) -> Result<Res<RunsResponse>> {
508 + self.post(
509 + &[
510 + "repos",
511 + &repo.owner,
512 + &repo.name,
513 + "pulls",
514 + &number.to_string(),
515 + "runs",
516 + verb,
517 + ],
518 + None,
519 + )
520 + .await
521 + }
522 +
457 523 // ── wiki ─────────────────────────────────────────────────────────
458 524
459 525 pub async fn wiki(&self, repo: &RepoRef) -> Result<Res<WikiIndexResponse>> {
@@ -523,6 +589,59 @@ fn list_query(
523 589 q
524 590 }
525 591
592 +/// A request the server answered with an error status.
593 +///
594 +/// Kept typed rather than flattened into a string, so a caller can tell a
595 +/// 404 (a matrix placeholder replaced by its legs, say) from anything else
596 +/// with `downcast_ref`, and so a dispatch's per-input reasons survive.
597 +#[derive(Debug, Clone)]
598 +pub struct ApiFailure {
599 + pub status: StatusCode,
600 + pub message: Option<String>,
601 + /// Input name → reason, when a dispatch's inputs don't fit.
602 + pub errors: BTreeMap<String, String>,
603 +}
604 +
605 +impl ApiFailure {
606 + fn new(status: StatusCode, body: &str) -> Self {
607 + let parsed = serde_json::from_str::<ApiError>(body).ok();
608 + Self {
609 + status,
610 + message: parsed
611 + .as_ref()
612 + .and_then(|e| e.message.clone())
613 + .filter(|m| !m.trim().is_empty()),
614 + errors: parsed.map(|e| e.errors).unwrap_or_default(),
615 + }
616 + }
617 +
618 + /// The failure behind `error`, if it was one.
619 + pub fn of(error: &anyhow::Error) -> Option<&ApiFailure> {
620 + error.downcast_ref::<ApiFailure>()
621 + }
622 +}
623 +
624 +impl std::fmt::Display for ApiFailure {
625 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
626 + // The message already strings the per-input reasons into one
627 + // sentence; one line per input reads better, so it replaces it.
628 + if self.errors.is_empty() {
629 + return f.write_str(&describe(self.status, self.message.as_deref()));
630 + }
631 + write!(
632 + f,
633 + "the server refused these inputs ({}):",
634 + self.status.as_u16()
635 + )?;
636 + for (name, reason) in &self.errors {
637 + write!(f, "\n {name}: {reason}")?;
638 + }
639 + Ok(())
640 + }
641 +}
642 +
643 +impl std::error::Error for ApiFailure {}
644 +
526 645 /// A message worth showing a user.
527 646 ///
528 647 /// The API puts the actual reason in a JSON `message` on every 4xx —
@@ -530,12 +649,7 @@ fn list_query(
530 649 /// re-minting a token with the right scopes and filing a bug about a
531 650 /// command that does nothing. So unwrap the body before falling back to
532 651 /// the bare status line.
533 −fn describe(status: StatusCode, body: &str) -> String {
534 − let message = serde_json::from_str::<ApiError>(body)
535 − .ok()
536 − .and_then(|e| e.message)
537 − .filter(|m| !m.trim().is_empty());
538 −
652 +fn describe(status: StatusCode, message: Option<&str>) -> String {
539 653 match message {
540 654 Some(message) => format!("{} ({})", message, status.as_u16()),
541 655 None if status == StatusCode::UNAUTHORIZED => {
@@ -570,6 +684,28 @@ mod tests {
570 684 assert_eq!(RepoRef::parse("acme/widgets.git").unwrap().name, "widgets");
571 685 }
572 686
687 + #[test]
688 + fn failures_keep_the_message_and_per_input_errors() {
689 + let failure = ApiFailure::new(
690 + StatusCode::UNPROCESSABLE_ENTITY,
691 + r#"{"message":"input level must be one of: debug, info","errors":{"level":"must be one of: debug, info"}}"#,
692 + );
693 + assert_eq!(failure.status.as_u16(), 422);
694 + assert_eq!(
695 + failure.to_string(),
696 + "the server refused these inputs (422):\n level: must be one of: debug, info"
697 + );
698 +
699 + let bare = ApiFailure::new(StatusCode::NOT_FOUND, "not json");
700 + assert_eq!(bare.to_string(), "HTTP 404 Not Found");
701 +
702 + let wrapped = anyhow::Error::new(bare).context("reading logs");
703 + assert_eq!(
704 + ApiFailure::of(&wrapped).map(|f| f.status),
705 + Some(StatusCode::NOT_FOUND)
706 + );
707 + }
708 +
573 709 #[test]
574 710 fn rejects_incomplete_refs() {
575 711 for bad in ["widgets", "acme/", "/widgets", "a/b/c"] {
modified cli/src/api/models.rs
+122 −1
@@ -20,6 +20,10 @@ fn pending() -> String {
20 20 "pending".into()
21 21 }
22 22
23 +fn first_attempt() -> i64 {
24 + 1
25 +}
26 +
23 27 fn open() -> String {
24 28 "open".into()
25 29 }
@@ -154,9 +158,29 @@ pub struct Run {
154 158 pub completed_at: Option<String>,
155 159 pub inserted_at: Option<String>,
156 160 pub triggered_by: Option<ApiUser>,
161 + /// The pull request a `pull_request` / `pull_request_target` run is for.
162 + pub pull_request_number: Option<i64>,
163 + /// A fork pull request's run: no secrets, a read-only token.
164 + #[serde(default)]
165 + pub fork: bool,
166 + /// A fork run nobody with write access has approved yet. Its jobs are
167 + /// `awaiting_approval` meanwhile.
168 + #[serde(default)]
169 + pub awaiting_approval: bool,
157 170 }
158 171
159 172 impl Run {
173 + /// What the run's status reads as. A run waiting for approval is
174 + /// `queued` on the server, but "queued" would promise a runner is
175 + /// coming — nothing happens until someone approves it.
176 + pub fn display_status(&self) -> &str {
177 + if self.awaiting_approval && matches!(self.status.as_str(), "queued" | "running") {
178 + "awaiting_approval"
179 + } else {
180 + crate::output::effective_status(&self.status, self.conclusion.as_deref())
181 + }
182 + }
183 +
160 184 /// `refs/heads/main` reads as `main`.
161 185 pub fn short_ref(&self) -> Option<&str> {
162 186 self.head_ref.as_deref().map(|r| {
@@ -202,6 +226,21 @@ pub struct Job {
202 226 pub conclusion: Option<String>,
203 227 #[serde(default)]
204 228 pub needs: Vec<String>,
229 + /// Why it's waiting, skipped or cancelled, or why its matrix couldn't
230 + /// expand — worded for display.
231 + pub status_reason: Option<String>,
232 + /// `github.run_attempt`: 1, plus one per re-run of the job.
233 + #[serde(default = "first_attempt")]
234 + pub attempt: i64,
235 + /// Cancelled because a newer run or job in its concurrency group
236 + /// replaced it: news, but not bad news.
237 + #[serde(default)]
238 + pub superseded: bool,
239 + /// A dynamic matrix that hasn't expanded yet. When it does, this row
240 + /// is deleted and its legs appear with new ids — so nothing should
241 + /// hold on to this one's id.
242 + #[serde(default)]
243 + pub placeholder: bool,
205 244 pub started_at: Option<String>,
206 245 pub completed_at: Option<String>,
207 246 #[serde(default)]
@@ -209,6 +248,30 @@ pub struct Job {
209 248 }
210 249
211 250 impl Job {
251 + /// `superseded` for a job a newer one replaced, else its effective
252 + /// status.
253 + pub fn display_status(&self) -> &str {
254 + if self.superseded {
255 + "superseded"
256 + } else {
257 + crate::output::effective_status(&self.status, self.conclusion.as_deref())
258 + }
259 + }
260 +
261 + /// What to say under the job: the server's reason, or for a
262 + /// placeholder with nothing to explain yet, what it is.
263 + pub fn reason(&self) -> Option<String> {
264 + if let Some(reason) = self
265 + .status_reason
266 + .as_deref()
267 + .filter(|r| !r.trim().is_empty())
268 + {
269 + return Some(reason.to_string());
270 + }
271 + (self.placeholder && !self.needs.is_empty() && self.display_status() == "blocked")
272 + .then(|| format!("Matrix expands when {} finish", self.needs.join(", ")))
273 + }
274 +
212 275 pub fn label(&self) -> String {
213 276 self.name
214 277 .clone()
@@ -272,6 +335,54 @@ pub struct RunResponse {
272 335 pub jobs: Vec<Job>,
273 336 }
274 337
338 +/// One input of a `workflow_dispatch`, as the workflow declares it.
339 +/// `type` is GitHub's: `string`, `boolean`, `choice`, `number` or
340 +/// `environment` (a plain string here — the forge has no environments).
341 +#[derive(Debug, Clone, Deserialize)]
342 +pub struct DispatchInput {
343 + pub name: String,
344 + #[serde(rename = "type", default = "string_type")]
345 + pub kind: String,
346 + pub description: Option<String>,
347 + #[serde(default)]
348 + pub required: bool,
349 + pub default: Option<String>,
350 + #[serde(default)]
351 + pub options: Vec<String>,
352 +}
353 +
354 +fn string_type() -> String {
355 + "string".into()
356 +}
357 +
358 +#[derive(Debug, Clone, Deserialize)]
359 +pub struct DispatchWorkflow {
360 + pub path: String,
361 + pub name: Option<String>,
362 + #[serde(default)]
363 + pub inputs: Vec<DispatchInput>,
364 +}
365 +
366 +impl DispatchWorkflow {
367 + pub fn label(&self) -> &str {
368 + self.name
369 + .as_deref()
370 + .filter(|s| !s.is_empty())
371 + .unwrap_or_else(|| self.path.rsplit('/').next().unwrap_or(&self.path))
372 + }
373 +}
374 +
375 +#[derive(Debug, Clone, Deserialize)]
376 +pub struct WorkflowsResponse {
377 + #[serde(default)]
378 + pub workflows: Vec<DispatchWorkflow>,
379 + pub default_branch: Option<String>,
380 + #[serde(default)]
381 + pub branches: Vec<String>,
382 + #[serde(default)]
383 + pub can_dispatch: bool,
384 +}
385 +
275 386 // ── issues, pulls, comments ──────────────────────────────────────────
276 387
277 388 #[derive(Debug, Clone, Deserialize)]
@@ -395,6 +506,13 @@ pub struct PullDetailResponse {
395 506 pub can_merge: bool,
396 507 #[serde(default)]
397 508 pub can_override: bool,
509 + /// A fork's runs on the current head, waiting for someone to approve
510 + /// them.
511 + #[serde(default)]
512 + pub awaiting_approval_runs: Vec<Run>,
513 + /// Whether the viewer may approve (or reject) them: write access.
514 + #[serde(default)]
515 + pub can_approve_runs: bool,
398 516 }
399 517
400 518 // ── wiki ─────────────────────────────────────────────────────────────
@@ -519,8 +637,11 @@ pub struct UsageStep {
519 637 pub code: String,
520 638 }
521 639
522 −/// The server's error shape — `{"message": "..."}` on every 4xx.
640 +/// The server's error shape — `{"message": "..."}` on every 4xx, plus
641 +/// `errors` (input name → reason) when a dispatch's inputs don't fit.
523 642 #[derive(Debug, Clone, Deserialize)]
524 643 pub struct ApiError {
525 644 pub message: Option<String>,
645 + #[serde(default)]
646 + pub errors: std::collections::BTreeMap<String, String>,
526 647 }
modified cli/src/cli.rs
+91 −0
@@ -204,6 +204,45 @@ pub enum CiCommand {
204 204 #[arg(long, default_value_t = 5)]
205 205 interval: u64,
206 206 },
207 +
208 + /// Workflows that can be run by hand (`on: workflow_dispatch` on the
209 + /// default branch), with the inputs each declares.
210 + Workflows,
211 +
212 + /// Run a workflow by hand (`workflow_dispatch`).
213 + #[command(visible_alias = "run-workflow")]
214 + Dispatch {
215 + /// The workflow, by path or file name (see `gg ci workflows`).
216 + workflow: String,
217 +
218 + /// Branch or tag to run it on. Defaults to the default branch.
219 + #[arg(long = "ref", value_name = "REF")]
220 + git_ref: Option<String>,
221 +
222 + /// An input, as NAME=VALUE. Repeat for each; booleans are
223 + /// `true`/`false`. Undeclared ones are refused by the server.
224 + #[arg(short = 'f', long = "field", value_name = "NAME=VALUE", value_parser = parse_field)]
225 + fields: Vec<(String, String)>,
226 +
227 + /// Follow the new run until it finishes, as `gg ci watch` does.
228 + /// Ignored with --json.
229 + #[arg(long)]
230 + watch: bool,
231 +
232 + /// Seconds between polls with --watch.
233 + #[arg(long, default_value_t = 5, requires = "watch")]
234 + interval: u64,
235 + },
236 +}
237 +
238 +/// `NAME=VALUE`, split at the first `=` so a value may contain more.
239 +fn parse_field(raw: &str) -> Result<(String, String), String> {
240 + match raw.split_once('=') {
241 + Some((name, value)) if !name.trim().is_empty() => {
242 + Ok((name.trim().to_string(), value.to_string()))
243 + }
244 + _ => Err(format!("expected NAME=VALUE, got {raw:?}")),
245 + }
207 246 }
208 247
209 248 // ── issues ───────────────────────────────────────────────────────────
@@ -335,6 +374,15 @@ pub enum PullCommand {
335 374 #[arg(long)]
336 375 override_rules: bool,
337 376 },
377 +
378 + /// Let a fork's workflow runs on the current head start. They run
379 + /// the fork's code, without this repository's secrets. Needs write
380 + /// access.
381 + ApproveRuns { number: i64 },
382 +
383 + /// Cancel a fork's workflow runs waiting for approval, on any head.
384 + /// Needs write access.
385 + RejectRuns { number: i64 },
338 386 }
339 387
340 388 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
@@ -469,4 +517,47 @@ mod tests {
469 517 fn command_tree_is_well_formed() {
470 518 Cli::command().debug_assert();
471 519 }
520 +
521 + #[test]
522 + fn dispatch_fields_split_at_the_first_equals() {
523 + assert_eq!(
524 + parse_field("query=a=b").unwrap(),
525 + ("query".to_string(), "a=b".to_string())
526 + );
527 + assert_eq!(parse_field("flag=").unwrap().1, "");
528 + assert!(parse_field("novalue").is_err());
529 + assert!(parse_field("=x").is_err());
530 + }
531 +
532 + #[test]
533 + fn dispatch_parses_repeated_fields() {
534 + let cli = Cli::try_parse_from([
535 + "gg",
536 + "ci",
537 + "dispatch",
538 + "deploy.yml",
539 + "--ref",
540 + "main",
541 + "-f",
542 + "env=prod",
543 + "-f",
544 + "dry_run=true",
545 + ])
546 + .unwrap();
547 + let Command::Ci(CiArgs {
548 + command:
549 + CiCommand::Dispatch {
550 + workflow,
551 + git_ref,
552 + fields,
553 + ..
554 + },
555 + }) = cli.command
556 + else {
557 + panic!("not a dispatch");
558 + };
559 + assert_eq!(workflow, "deploy.yml");
560 + assert_eq!(git_ref.as_deref(), Some("main"));
561 + assert_eq!(fields.len(), 2);
562 + }
472 563 }
modified cli/src/commands/ci.rs
+288 −21
@@ -1,15 +1,16 @@
1 1 //! Workflow runs: listing, drilling in, logs, and the two write actions.
2 2
3 3 use anyhow::{Result, anyhow, bail};
4 +use std::collections::BTreeMap;
4 5 use std::time::Duration;
5 6
6 −use crate::api::models::{Job, RunDetailResponse};
7 −use crate::api::{RepoRef, Res};
7 +use crate::api::models::{Job, Run, RunDetailResponse};
8 +use crate::api::{ApiFailure, RepoRef, Res};
8 9 use crate::cli::CiCommand;
9 10 use crate::commands::Ctx;
10 11 use crate::output::{
11 − Table, Tone, ago, bold, bytes, duration, effective_status, heading, note, paint, status_cell,
12 − status_mark, status_tone, truncate,
12 + Table, Tone, ago, bold, bytes, duration, effective_status, finished, heading, note, paint,
13 + status_badge, status_mark, status_tone, trigger_label, truncate,
13 14 };
14 15
15 16 /// How many consecutive polling failures end a watch. At the default
@@ -31,6 +32,24 @@ pub async fn run(ctx: &Ctx, command: &CiCommand) -> Result<()> {
31 32 CiCommand::Rerun { number, job } => rerun(ctx, &repo, *number, job.as_deref()).await,
32 33 CiCommand::Cancel { number } => cancel(ctx, &repo, *number).await,
33 34 CiCommand::Watch { number, interval } => watch(ctx, &repo, *number, *interval).await,
35 + CiCommand::Workflows => workflows(ctx, &repo).await,
36 + CiCommand::Dispatch {
37 + workflow,
38 + git_ref,
39 + fields,
40 + watch: follow,
41 + interval,
42 + } => {
43 + dispatch(
44 + ctx,
45 + &repo,
46 + workflow,
47 + git_ref.as_deref(),
48 + fields,
49 + follow.then_some(*interval),
50 + )
51 + .await
52 + }
34 53 }
35 54 }
36 55
@@ -48,7 +67,7 @@ async fn list(ctx: &Ctx, repo: &RepoRef, limit: u32) -> Result<()> {
48 67 for run in &res.runs {
49 68 table.row([
50 69 format!("#{}", run.number),
51 − status_cell(&run.status, run.conclusion.as_deref()),
70 + status_badge(run.display_status()),
52 71 truncate(run.workflow_label(), 28),
53 72 truncate(run.short_ref().unwrap_or("—"), 24),
54 73 run.short_sha().unwrap_or("—").to_string(),
@@ -77,10 +96,10 @@ fn print_run(ctx: &Ctx, repo: &RepoRef, res: &Res<RunDetailResponse>, steps: boo
77 96 "#{} {} — {}",
78 97 run.number,
79 98 run.workflow_label(),
80 − status_cell(&run.status, run.conclusion.as_deref())
99 + status_badge(run.display_status())
81 100 ));
82 101 println!(
83 − "{} {} · {} · triggered by {} ({})",
102 + "{} {} · {} · triggered by {} ({}){}",
84 103 run.short_ref().unwrap_or("—"),
85 104 run.short_sha().unwrap_or(""),
86 105 ago(run.inserted_at.as_deref().or(run.started_at.as_deref())),
@@ -88,11 +107,18 @@ fn print_run(ctx: &Ctx, repo: &RepoRef, res: &Res<RunDetailResponse>, steps: boo
88 107 .as_ref()
89 108 .map(|u| u.label().to_string())
90 109 .unwrap_or_else(|| "—".into()),
91 − run.trigger.as_deref().unwrap_or("unknown event")
110 + run.trigger
111 + .as_deref()
112 + .map(trigger_label)
113 + .unwrap_or_else(|| "unknown event".into()),
114 + if run.fork { " · from a fork" } else { "" }
92 115 );
93 116 if let Some(took) = duration(run.started_at.as_deref(), run.completed_at.as_deref()) {
94 117 println!("took {took}");
95 118 }
119 + if run.awaiting_approval {
120 + println!("\n{}", paint(&approval_notice(run), Tone::Busy));
121 + }
96 122
97 123 println!();
98 124 if res.jobs.is_empty() {
@@ -101,14 +127,33 @@ fn print_run(ctx: &Ctx, repo: &RepoRef, res: &Res<RunDetailResponse>, steps: boo
101 127 let mut table = Table::new(["JOB", "STATUS", "TOOK", "NEEDS"]);
102 128 for job in &res.jobs {
103 129 table.row([
104 − truncate(&job.label(), 36),
105 − status_cell(&job.status, job.conclusion.as_deref()),
130 + job_cell(job),
131 + status_badge(job.display_status()),
106 132 duration(job.started_at.as_deref(), job.completed_at.as_deref())
107 133 .unwrap_or_else(|| "—".into()),
108 134 job.needs.join(", "),
109 135 ]);
110 136 }
111 137 table.print();
138 +
139 + // Why each job is waiting, skipped or cancelled — or why its matrix
140 + // couldn't expand. Below the table rather than in it: the reasons
141 + // are sentences, and a column of sentences is a very wide table.
142 + let reasons: Vec<(String, String)> = res
143 + .jobs
144 + .iter()
145 + .filter_map(|j| j.reason().map(|r| (j.label(), r)))
146 + .collect();
147 + if !reasons.is_empty() {
148 + println!();
149 + for (label, reason) in reasons {
150 + println!(
151 + " {} {}",
152 + bold(&format!("{label}:")),
153 + paint(&reason, Tone::Muted)
154 + );
155 + }
156 + }
112 157 }
113 158
114 159 if steps {
@@ -150,6 +195,33 @@ fn print_run(ctx: &Ctx, repo: &RepoRef, res: &Res<RunDetailResponse>, steps: boo
150 195 note(&ctx.repo_web_url(repo, &format!("/actions/{}", run.number)));
151 196 }
152 197
198 +/// A job's name, with its attempt when it's been re-run.
199 +fn job_cell(job: &Job) -> String {
200 + let label = truncate(&job.label(), 36);
201 + if job.attempt > 1 {
202 + format!(
203 + "{label} {}",
204 + paint(&format!("(attempt {})", job.attempt), Tone::Muted)
205 + )
206 + } else {
207 + label
208 + }
209 +}
210 +
211 +/// A fork's run waits for someone with write access to approve it, and
212 +/// that happens on the pull request — so say so, and say how.
213 +fn approval_notice(run: &Run) -> String {
214 + match run.pull_request_number {
215 + Some(pr) => format!(
216 + "Waiting for approval: this run comes from a fork. Its jobs start once someone \
217 + with write access approves it on pull request #{pr} (`gg pull approve-runs {pr}`)."
218 + ),
219 + None => "Waiting for approval: this run comes from a fork. Its jobs start once someone \
220 + with write access approves it."
221 + .to_string(),
222 + }
223 +}
224 +
153 225 async fn logs(
154 226 ctx: &Ctx,
155 227 repo: &RepoRef,
@@ -162,7 +234,23 @@ async fn logs(
162 234 let detail = ctx.api.run(repo, number).await?;
163 235 let job = pick_job(&detail.jobs, job)?;
164 236
165 − let res = ctx.api.logs(repo, job.id, limit).await?;
237 + // A dynamic matrix's placeholder is deleted when its legs replace it,
238 + // so the job picked a moment ago can be gone by the time its logs are
239 + // asked for. That's news about the run, not an error in the request.
240 + let res = match ctx.api.logs(repo, job.id, limit).await {
241 + Ok(res) => res,
242 + Err(e) if ApiFailure::of(&e).is_some_and(|f| f.status.as_u16() == 404) => {
243 + if job.placeholder {
244 + bail!(
245 + "{} no longer exists — a matrix job is replaced by its legs once it expands; \
246 + `gg ci show {number}` lists them",
247 + job.label()
248 + );
249 + }
250 + return Err(e);
251 + }
252 + Err(e) => return Err(e),
253 + };
166 254 if ctx.emitted(&res.raw)? {
167 255 return Ok(());
168 256 }
@@ -173,12 +261,18 @@ async fn logs(
173 261 number,
174 262 detail.run.workflow_label(),
175 263 job.label(),
176 − status_cell(&job.status, job.conclusion.as_deref())
264 + status_badge(res.job.display_status())
177 265 ));
178 266 }
179 267
180 − if res.steps.is_empty() {
181 − note("No logs — the job may not have started, or they may have expired.");
268 + // A job that hasn't started still lists its steps, each with nothing
269 + // in it — which says less than the reason it hasn't started.
270 + if res.steps.iter().all(|s| s.lines.is_empty()) {
271 + // A job that hasn't run says why, when the server says.
272 + match res.job.reason() {
273 + Some(reason) => note(&format!("No logs yet — {reason}.")),
274 + None => note("No logs — the job may not have started, or they may have expired."),
275 + }
182 276 return Ok(());
183 277 }
184 278
@@ -287,7 +381,7 @@ async fn rerun(ctx: &Ctx, repo: &RepoRef, number: i64, job: Option<&str>) -> Res
287 381 println!(
288 382 "{} {what} — now {}",
289 383 paint("Re-queued", Tone::Good),
290 − status_cell(&res.run.status, res.run.conclusion.as_deref())
384 + status_badge(res.run.display_status())
291 385 );
292 386 Ok(())
293 387 }
@@ -300,7 +394,7 @@ async fn cancel(ctx: &Ctx, repo: &RepoRef, number: i64) -> Result<()> {
300 394 println!(
301 395 "{} run #{number} — now {}",
302 396 paint("Cancelled", Tone::Busy),
303 − status_cell(&res.run.status, res.run.conclusion.as_deref())
397 + status_badge(res.run.display_status())
304 398 );
305 399 Ok(())
306 400 }
@@ -314,6 +408,7 @@ async fn watch(ctx: &Ctx, repo: &RepoRef, number: Option<i64>, interval: u64) ->
314 408 let interval = Duration::from_secs(interval.max(1));
315 409 let mut last_line = String::new();
316 410 let mut failures = 0;
411 + let mut told_approval = false;
317 412
318 413 loop {
319 414 // A watch outlives things the forge does to itself. Deploying it
@@ -345,16 +440,19 @@ async fn watch(ctx: &Ctx, repo: &RepoRef, number: Option<i64>, interval: u64) ->
345 440 };
346 441
347 442 let run = &res.run;
348 − let state = effective_status(&run.status, run.conclusion.as_deref());
443 + let state = run.display_status();
349 444
445 + // Job marks are re-read from the run on every poll, never kept by
446 + // job id: a dynamic matrix's placeholder is replaced by its legs
447 + // (new ids), and the next poll simply shows the legs.
350 448 let line = format!(
351 449 "#{} {} · {}",
352 450 run.number,
353 − status_cell(&run.status, run.conclusion.as_deref()),
451 + status_badge(state),
354 452 res.jobs
355 453 .iter()
356 454 .map(|j| {
357 − let s = effective_status(&j.status, j.conclusion.as_deref());
455 + let s = j.display_status();
358 456 format!("{} {}", paint(status_mark(s), status_tone(s)), j.label())
359 457 })
360 458 .collect::<Vec<_>>()
@@ -365,10 +463,18 @@ async fn watch(ctx: &Ctx, repo: &RepoRef, number: Option<i64>, interval: u64) ->
365 463 last_line = line;
366 464 }
367 465
368 − if !matches!(state, "queued" | "running" | "pending" | "blocked") {
466 + // A fork's run waiting for approval stays watched, as the app's
467 + // RunWatch does: the approval is what it's waiting for. Said once,
468 + // so nobody mistakes the silence for a stuck runner.
469 + if run.awaiting_approval && !told_approval {
470 + note(&approval_notice(run));
471 + told_approval = true;
472 + }
473 +
474 + if finished(state) {
369 475 return match state {
370 476 "success" => Ok(()),
371 − "skipped" | "cancelled" => {
477 + "skipped" | "cancelled" | "superseded" => {
372 478 note(&format!("run #{number} {state}"));
373 479 Ok(())
374 480 }
@@ -380,6 +486,116 @@ async fn watch(ctx: &Ctx, repo: &RepoRef, number: Option<i64>, interval: u64) ->
380 486 }
381 487 }
382 488
489 +/// The workflows that can be run by hand, with their inputs.
490 +async fn workflows(ctx: &Ctx, repo: &RepoRef) -> Result<()> {
491 + let res = ctx.api.workflows(repo).await?;
492 + if ctx.emitted(&res.raw)? {
493 + return Ok(());
494 + }
495 + let branch = res
496 + .default_branch
497 + .as_deref()
498 + .unwrap_or("the default branch");
499 + if res.workflows.is_empty() {
500 + note(&format!(
501 + "No workflow on {branch} in {repo} has an `on: workflow_dispatch` trigger."
502 + ));
503 + return Ok(());
504 + }
505 +
506 + for (i, workflow) in res.workflows.iter().enumerate() {
507 + if i > 0 {
508 + println!();
509 + }
510 + println!(
511 + "{} {}",
512 + bold(workflow.label()),
513 + paint(&workflow.path, Tone::Muted)
514 + );
515 + if workflow.inputs.is_empty() {
516 + note(" no inputs");
517 + continue;
518 + }
519 + let mut table = Table::new([" INPUT", "TYPE", "DEFAULT", "DESCRIPTION"]);
520 + for input in &workflow.inputs {
521 + let kind = if input.options.is_empty() {
522 + input.kind.clone()
523 + } else {
524 + format!("{} ({})", input.kind, input.options.join("|"))
525 + };
526 + table.row([
527 + format!(" {}{}", input.name, if input.required { " *" } else { "" }),
528 + kind,
529 + input
530 + .default
531 + .as_deref()
532 + .filter(|d| !d.is_empty())
533 + .unwrap_or("—")
534 + .to_string(),
535 + truncate(input.description.as_deref().unwrap_or(""), 60),
536 + ]);
537 + }
538 + table.print();
539 + }
540 +
541 + println!();
542 + note(&format!(
543 + "Inputs marked * are required. Runs on {branch} unless --ref says otherwise."
544 + ));
545 + if res.can_dispatch {
546 + note("Run one with `gg ci dispatch <workflow> [--ref REF] [-f NAME=VALUE …]`.");
547 + } else {
548 + note("(read-only: running a workflow needs write access here)");
549 + }
550 + Ok(())
551 +}
552 +
553 +/// `workflow_dispatch`, and optionally a watch of the run it started.
554 +async fn dispatch(
555 + ctx: &Ctx,
556 + repo: &RepoRef,
557 + workflow: &str,
558 + git_ref: Option<&str>,
559 + fields: &[(String, String)],
560 + watch_interval: Option<u64>,
561 +) -> Result<()> {
562 + let mut inputs = BTreeMap::new();
563 + for (name, value) in fields {
564 + if inputs.insert(name.clone(), value.clone()).is_some() {
565 + bail!("input {name} given more than once");
566 + }
567 + }
568 +
569 + let res = ctx
570 + .api
571 + .dispatch(repo, workflow, git_ref, &inputs)
572 + .await
573 + .map_err(|e| match ApiFailure::of(&e) {
574 + Some(f) if !f.errors.is_empty() => e.context(format!(
575 + "{workflow}: see `gg ci workflows` for what it declares"
576 + )),
577 + _ => e,
578 + })?;
579 + if ctx.emitted(&res.raw)? {
580 + return Ok(());
581 + }
582 +
583 + let run = &res.run;
584 + println!(
585 + "{} run #{} {} on {}",
586 + paint("Started", Tone::Good),
587 + run.number,
588 + run.workflow_label(),
589 + run.short_ref().or(git_ref).unwrap_or("the default branch")
590 + );
591 + note(&ctx.repo_web_url(repo, &format!("/actions/{}", run.number)));
592 +
593 + match watch_interval {
594 + Some(interval) => watch(ctx, repo, Some(run.number), interval).await,
595 + None => Ok(()),
596 + }
597 +}
598 +
383 599 /// The run the user meant: the one they named, or the most recent.
384 600 async fn resolve_number(ctx: &Ctx, repo: &RepoRef, number: Option<i64>) -> Result<i64> {
385 601 if let Some(number) = number {
@@ -404,6 +620,10 @@ mod tests {
404 620 status: status.to_string(),
405 621 conclusion: conclusion.map(str::to_string),
406 622 needs: vec![],
623 + status_reason: None,
624 + attempt: 1,
625 + superseded: false,
626 + placeholder: false,
407 627 started_at: None,
408 628 completed_at: None,
409 629 steps: vec![],
@@ -434,6 +654,53 @@ mod tests {
434 654 assert!(pick_job(&jobs, None).is_err());
435 655 }
436 656
657 + #[test]
658 + fn superseded_jobs_read_as_superseded_not_cancelled() {
659 + let mut j = job(1, "build", "cancelled", None);
660 + assert_eq!(j.display_status(), "cancelled");
661 + j.superseded = true;
662 + assert_eq!(j.display_status(), "superseded");
663 + assert!(finished(j.display_status()));
664 + assert_eq!(status_tone(j.display_status()), Tone::Muted);
665 + }
666 +
667 + #[test]
668 + fn a_placeholder_explains_itself() {
669 + let mut j = job(1, "build", "blocked", None);
670 + j.placeholder = true;
671 + j.needs = vec!["setup".into()];
672 + assert_eq!(
673 + j.reason().as_deref(),
674 + Some("Matrix expands when setup finish")
675 + );
676 + j.status_reason = Some("Waiting for approval".into());
677 + assert_eq!(j.reason().as_deref(), Some("Waiting for approval"));
678 + }
679 +
680 + #[test]
681 + fn a_run_waiting_for_approval_is_not_finished() {
682 + let run: Run = serde_json::from_value(serde_json::json!({
683 + "number": 7,
684 + "status": "queued",
685 + "fork": true,
686 + "awaiting_approval": true,
687 + "pull_request_number": 3
688 + }))
689 + .unwrap();
690 + assert_eq!(run.display_status(), "awaiting_approval");
691 + assert!(!finished(run.display_status()));
692 + assert!(approval_notice(&run).contains("#3"));
693 + }
694 +
695 + #[test]
696 + fn older_servers_still_parse() {
697 + let j: Job =
698 + serde_json::from_value(serde_json::json!({"id": 1, "status": "waiting"})).unwrap();
699 + assert_eq!(j.attempt, 1);
700 + assert!(!j.superseded && !j.placeholder);
701 + assert!(!finished(j.display_status()));
702 + }
703 +
437 704 #[test]
438 705 fn matches_by_id_or_name() {
439 706 let jobs = vec![
modified cli/src/commands/pull.rs
+77 −1
@@ -4,13 +4,19 @@
4 4 //! action you most often want without opening a browser. It enforces the
5 5 //! repository's review rules; `--override-rules` is accepted from repo
6 6 //! admins and recorded as an override, exactly as the web UI does it.
7 +//!
8 +//! So is approving (or rejecting) a fork's workflow runs: they wait,
9 +//! without secrets, until someone with write access says the code may
10 +//! run.
7 11
8 12 use anyhow::Result;
9 13
14 +use crate::api::models::Run;
10 15 use crate::cli::PullCommand;
11 16 use crate::commands::{Ctx, read_body};
12 17 use crate::output::{
13 − Table, Tone, ago, author_of, bold, heading, indented, labels, note, paint, state_cell, truncate,
18 + Table, Tone, ago, author_of, bold, heading, indented, labels, note, paint, state_cell,
19 + status_badge, truncate,
14 20 };
15 21
16 22 pub async fn run(ctx: &Ctx, command: &PullCommand) -> Result<()> {
@@ -125,6 +131,33 @@ pub async fn run(ctx: &Ctx, command: &PullCommand) -> Result<()> {
125 131 println!("\nawaiting review from {}", waiting.join(", "));
126 132 }
127 133
134 + // A fork's runs are held until someone with write access lets
135 + // them run the fork's code — worth saying before the blockers,
136 + // since a required check waiting on them is often one.
137 + if !res.awaiting_approval_runs.is_empty() {
138 + println!(
139 + "\n{}",
140 + bold("Workflows from this fork are waiting for approval")
141 + );
142 + for run in &res.awaiting_approval_runs {
143 + println!(
144 + " {} #{} {}",
145 + status_badge(run.display_status()),
146 + run.number,
147 + run.workflow_label()
148 + );
149 + }
150 + if res.can_approve_runs {
151 + note(&format!(
152 + " They run the fork's code, without this repository's secrets. \
153 + Approve with `gg pull approve-runs {n}`, or `gg pull reject-runs {n}`.",
154 + n = pull.number
155 + ));
156 + } else {
157 + note(" Someone with write access to this repository has to approve them.");
158 + }
159 + }
160 +
128 161 if !res.merge_blockers.is_empty() {
129 162 println!("\n{}", bold("Merge blockers"));
130 163 for blocker in &res.merge_blockers {
@@ -262,6 +295,49 @@ pub async fn run(ctx: &Ctx, command: &PullCommand) -> Result<()> {
262 295 );
263 296 Ok(())
264 297 }
298 +
299 + PullCommand::ApproveRuns { number } => {
300 + let res = ctx.api.approve_pull_runs(&repo, *number).await?;
301 + if ctx.emitted(&res.raw)? {
302 + return Ok(());
303 + }
304 + report_runs(&res.runs, "Approved", Tone::Good, *number);
305 + Ok(())
306 + }
307 +
308 + PullCommand::RejectRuns { number } => {
309 + let res = ctx.api.reject_pull_runs(&repo, *number).await?;
310 + if ctx.emitted(&res.raw)? {
311 + return Ok(());
312 + }
313 + report_runs(&res.runs, "Cancelled", Tone::Busy, *number);
314 + Ok(())
315 + }
316 + }
317 +}
318 +
319 +/// What approving or rejecting touched — which may be nothing, when no
320 +/// run was waiting.
321 +fn report_runs(runs: &[Run], verb: &str, tone: Tone, number: i64) {
322 + if runs.is_empty() {
323 + note(&format!(
324 + "No workflow runs were waiting for approval on pull request #{number}."
325 + ));
326 + return;
327 + }
328 + println!(
329 + "{} {} workflow run{} on pull request #{number}:",
330 + paint(verb, tone),
331 + runs.len(),
332 + if runs.len() == 1 { "" } else { "s" }
333 + );
334 + for run in runs {
335 + println!(
336 + " #{} {} — {}",
337 + run.number,
338 + run.workflow_label(),
339 + status_badge(run.display_status())
340 + );
265 341 }
266 342 }
267 343
modified cli/src/commands/repo.rs
+4 −2
@@ -5,7 +5,9 @@ use anyhow::Result;
5 5 use crate::api::models::Repository;
6 6 use crate::cli::{RepoArgs, RepoListArgs, RepoSort};
7 7 use crate::commands::Ctx;
8 −use crate::output::{Table, Tone, ago, ci_rank, heading, note, paint, status_cell, truncate};
8 +use crate::output::{
9 + Table, Tone, ago, ci_rank, heading, note, paint, status_badge, status_cell, truncate,
10 +};
9 11
10 12 pub async fn list(ctx: &Ctx, args: &RepoListArgs) -> Result<()> {
11 13 let res = ctx.api.repos().await?;
@@ -135,7 +137,7 @@ pub async fn show(ctx: &Ctx, args: &RepoArgs) -> Result<()> {
135 137 Some(run) => println!(
136 138 "latest run #{} {} {} on {}",
137 139 run.number,
138 − status_cell(&run.status, run.conclusion.as_deref()),
140 + status_badge(run.display_status()),
139 141 run.workflow_label(),
140 142 run.short_ref().unwrap_or("—")
141 143 ),
modified cli/src/output.rs
+102 −7
@@ -72,11 +72,19 @@ pub fn effective_status<'a>(status: &'a str, conclusion: Option<&'a str>) -> &'a
72 72 }
73 73 }
74 74
75 +/// Two names here aren't server statuses: `awaiting_approval` on a run
76 +/// ([`Run::display_status`] — the server says `queued`) and `superseded`,
77 +/// a job cancelled because a newer one in its concurrency group replaced
78 +/// it ([`Job::display_status`]). Superseded is dim, not red: the newer
79 +/// run decides, as its commit status (`cancelled`) says.
75 80 pub fn status_tone(status: &str) -> Tone {
76 81 match status {
77 82 "success" => Tone::Good,
78 83 "failure" | "cancelled" | "timed_out" => Tone::Bad,
79 − "queued" | "running" | "pending" | "blocked" => Tone::Busy,
84 + "queued" | "running" | "pending" | "blocked" | "waiting" | "awaiting_approval" => {
85 + Tone::Busy
86 + }
87 + "skipped" | "superseded" => Tone::Muted,
80 88 _ => Tone::Muted,
81 89 }
82 90 }
@@ -86,17 +94,62 @@ pub fn status_mark(status: &str) -> &'static str {
86 94 match status {
87 95 "success" => "✔",
88 96 "failure" | "timed_out" => "✘",
89 − "cancelled" => "⊘",
97 + "cancelled" | "superseded" => "⊘",
90 98 "running" => "▶",
91 − "queued" | "pending" | "blocked" => "…",
99 + "queued" | "pending" | "blocked" | "waiting" => "…",
100 + "awaiting_approval" => "⚑",
92 101 "skipped" => "–",
93 102 _ => "?",
94 103 }
95 104 }
96 105
106 +/// A status as words: `awaiting_approval` reads "needs approval",
107 +/// `timed_out` "timed out".
108 +pub fn status_label(status: &str) -> String {
109 + match status {
110 + "awaiting_approval" => "needs approval".to_string(),
111 + other => other.replace('_', " "),
112 + }
113 +}
114 +
115 +/// Not finished: running, or not started yet for one reason or another —
116 +/// blocked on its needs, waiting on a concurrency group or for approval.
117 +/// Anything the server invents later that isn't a known ending counts as
118 +/// unfinished too, so a watch keeps watching rather than calling it done.
119 +pub fn finished(status: &str) -> bool {
120 + matches!(
121 + status,
122 + "success" | "failure" | "cancelled" | "skipped" | "timed_out" | "superseded"
123 + )
124 +}
125 +
126 +/// What started a run, as a person would say it. Unknown events — the
127 +/// next one the server learns — fall through as their raw name with
128 +/// underscores spaced, which is legible if not pretty.
129 +pub fn trigger_label(trigger: &str) -> String {
130 + match trigger {
131 + "pull_request" => "pull request".to_string(),
132 + "pull_request_target" => "pull request (target)".to_string(),
133 + "workflow_dispatch" => "manual".to_string(),
134 + "schedule" => "scheduled".to_string(),
135 + "create" => "branch/tag created".to_string(),
136 + "delete" => "branch/tag deleted".to_string(),
137 + "workflow_run" => "after a workflow".to_string(),
138 + other => other.replace('_', " "),
139 + }
140 +}
141 +
97 142 pub fn status_cell(status: &str, conclusion: Option<&str>) -> String {
98 − let s = effective_status(status, conclusion);
99 − paint(&format!("{} {s}", status_mark(s)), status_tone(s))
143 + status_badge(effective_status(status, conclusion))
144 +}
145 +
146 +/// Mark and label in the status's colour, for an already-collapsed
147 +/// status such as [`Run::display_status`].
148 +pub fn status_badge(status: &str) -> String {
149 + paint(
150 + &format!("{} {}", status_mark(status), status_label(status)),
151 + status_tone(status),
152 + )
100 153 }
101 154
102 155 /// Issue / PR state, a different vocabulary from CI's — `open` is good
@@ -121,8 +174,8 @@ pub fn ci_rank(status: Option<&str>) -> u8 {
121 174 match status {
122 175 Some("failure") | Some("timed_out") => 0,
123 176 Some("running") => 1,
124 − Some("queued") | Some("blocked") | Some("pending") => 2,
125 − Some("cancelled") => 3,
177 + Some("queued" | "blocked" | "waiting" | "awaiting_approval" | "pending") => 2,
178 + Some("cancelled" | "superseded") => 3,
126 179 Some("success") => 4,
127 180 _ => 5,
128 181 }
@@ -337,6 +390,48 @@ mod tests {
337 390 );
338 391 }
339 392
393 + #[test]
394 + fn newer_statuses_have_their_own_vocabulary() {
395 + assert_eq!(status_tone("waiting"), Tone::Busy);
396 + assert_eq!(status_tone("awaiting_approval"), Tone::Busy);
397 + assert_eq!(status_tone("superseded"), Tone::Muted);
398 + assert_eq!(status_tone("skipped"), Tone::Muted);
399 + assert_ne!(status_mark("awaiting_approval"), status_mark("queued"));
400 + assert_eq!(status_label("awaiting_approval"), "needs approval");
401 + assert_eq!(status_label("timed_out"), "timed out");
402 + assert_eq!(ci_rank(Some("awaiting_approval")), ci_rank(Some("queued")));
403 + }
404 +
405 + #[test]
406 + fn only_known_endings_are_finished() {
407 + for done in ["success", "failure", "cancelled", "skipped", "superseded"] {
408 + assert!(finished(done), "{done}");
409 + }
410 + for busy in [
411 + "queued",
412 + "running",
413 + "blocked",
414 + "waiting",
415 + "awaiting_approval",
416 + "new_thing",
417 + ] {
418 + assert!(!finished(busy), "{busy}");
419 + }
420 + }
421 +
422 + #[test]
423 + fn triggers_read_as_words() {
424 + assert_eq!(trigger_label("schedule"), "scheduled");
425 + assert_eq!(trigger_label("workflow_dispatch"), "manual");
426 + assert_eq!(
427 + trigger_label("pull_request_target"),
428 + "pull request (target)"
429 + );
430 + assert_eq!(trigger_label("workflow_run"), "after a workflow");
431 + assert_eq!(trigger_label("push"), "push");
432 + assert_eq!(trigger_label("merge_group"), "merge group");
433 + }
434 +
340 435 #[test]
341 436 fn truncates_with_ellipsis() {
342 437 assert_eq!(truncate("hello", 10), "hello");
modified cli/src/tui/mod.rs
+46 −13
@@ -22,10 +22,10 @@ use std::time::{Duration, Instant};
22 22 use tokio::sync::mpsc;
23 23
24 24 use crate::api::models::{Job, LogsResponse, Run, RunDetailResponse};
25 −use crate::api::{Client, RepoRef};
25 +use crate::api::{ApiFailure, Client, RepoRef};
26 26 use crate::cli::DashboardArgs;
27 27 use crate::commands::Ctx;
28 −use crate::output::{ago, duration, effective_status, status_mark};
28 +use crate::output::{ago, duration, effective_status, status_label, status_mark};
29 29
30 30 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31 31 enum Focus {
@@ -272,7 +272,7 @@ async fn reload_detail(app: &mut App, api: &Client) {
272 272 }
273 273
274 274 async fn open_logs(app: &mut App, api: &Client) {
275 − let Some(job) = app.selected_job().map(|j| j.id) else {
275 + let Some((job, placeholder)) = app.selected_job().map(|j| (j.id, j.placeholder)) else {
276 276 return;
277 277 };
278 278 match api.logs(&app.repo, job, None).await {
@@ -281,6 +281,12 @@ async fn open_logs(app: &mut App, api: &Client) {
281 281 app.log_scroll = 0;
282 282 app.focus = Focus::Logs;
283 283 }
284 + // A dynamic matrix's placeholder is deleted when its legs replace
285 + // it; the next refresh shows them, so point there.
286 + Err(e) if placeholder && ApiFailure::of(&e).is_some_and(|f| f.status.as_u16() == 404) => {
287 + app.message = "that matrix job has expanded into its legs — r to refresh".into();
288 + reload_detail(app, api).await;
289 + }
284 290 Err(e) => app.message = format!("{e}"),
285 291 }
286 292 }
@@ -335,7 +341,7 @@ fn draw_runs(frame: &mut Frame, app: &mut App, area: Rect) {
335 341 .runs
336 342 .iter()
337 343 .map(|run| {
338 − let state = effective_status(&run.status, run.conclusion.as_deref());
344 + let state = run.display_status();
339 345 ListItem::new(Line::from(vec![
340 346 Span::styled(
341 347 format!("{} ", status_mark(state)),
@@ -388,23 +394,46 @@ fn draw_detail(frame: &mut Frame, app: &mut App, area: Rect) {
388 394 };
389 395
390 396 let mut items: Vec<ListItem> = Vec::new();
397 + if detail.run.awaiting_approval {
398 + let lines = vec![Line::styled(
399 + match detail.run.pull_request_number {
400 + Some(pr) => format!("⚑ from a fork — waiting for approval on pull request #{pr}"),
401 + None => "⚑ from a fork — waiting for approval".to_string(),
402 + },
403 + Style::default().fg(Color::Yellow),
404 + )];
405 + items.push(ListItem::new(lines));
406 + }
407 + // The notice is a list item too, so the cursor skips past it.
408 + let offset = items.len();
409 +
391 410 for job in &detail.jobs {
392 − let state = effective_status(&job.status, job.conclusion.as_deref());
411 + let state = job.display_status();
393 412 let took = duration(job.started_at.as_deref(), job.completed_at.as_deref())
394 413 .unwrap_or_else(|| "—".into());
414 + let mut meta = format!(" {} · {took}", status_label(state));
415 + if job.attempt > 1 {
416 + meta.push_str(&format!(" · attempt {}", job.attempt));
417 + }
395 418 let mut lines = vec![Line::from(vec![
396 419 Span::styled(
397 420 format!("{} ", status_mark(state)),
398 421 Style::default().fg(tone_color(state)),
399 422 ),
400 423 Span::styled(job.label(), Style::default().add_modifier(Modifier::BOLD)),
401 − Span::styled(format!(" {took}"), Style::default().fg(Color::DarkGray)),
424 + Span::styled(meta, Style::default().fg(Color::DarkGray)),
402 425 ])];
403 426
404 427 // Steps are shown only for the job under the cursor: every job's
405 428 // steps at once is a wall, and the one you're looking at is the one
406 − // whose failing step you want to find.
429 + // whose failing step you want to find. So is its reason.
407 430 if detail.jobs.get(app.job_cursor).map(|j| j.id) == Some(job.id) {
431 + if let Some(reason) = job.reason() {
432 + lines.push(Line::styled(
433 + format!(" {reason}"),
434 + Style::default().fg(Color::DarkGray),
435 + ));
436 + }
408 437 for step in &job.steps {
409 438 let state = effective_status(&step.status, step.conclusion.as_deref());
410 439 lines.push(Line::from(vec![
@@ -424,7 +453,7 @@ fn draw_detail(frame: &mut Frame, app: &mut App, area: Rect) {
424 453 state.select(if detail.jobs.is_empty() {
425 454 None
426 455 } else {
427 − Some(app.job_cursor)
456 + Some(app.job_cursor + offset)
428 457 });
429 458
430 459 let title = format!(
@@ -511,11 +540,15 @@ fn pane(title: &str, focused: bool) -> Block<'_> {
511 540 })
512 541 }
513 542
543 +/// The terminal's take on [`crate::output::status_tone`], so the two never
544 +/// disagree about what's busy and what's bad.
514 545 fn tone_color(status: &str) -> Color {
515 − match status {
516 − "success" => Color::Green,
517 − "failure" | "cancelled" | "timed_out" => Color::Red,
518 − "queued" | "running" | "pending" | "blocked" => Color::Yellow,
519 − _ => Color::DarkGray,
546 + use crate::output::{Tone, status_tone};
547 + match status_tone(status) {
548 + Tone::Good => Color::Green,
549 + Tone::Bad => Color::Red,
550 + Tone::Busy => Color::Yellow,
551 + Tone::Accent => Color::Cyan,
552 + Tone::Muted | Tone::Plain => Color::DarkGray,
520 553 }
521 554 }

Parents: 30f666b