collective

Browse / github.com/storytold/craft-repo-status-app / patch

Add a Main CI column with each workflow's latest result on main

Add a Main CI column with each workflow's latest result on main ## Why The Repos table shows activity, builds and downloads, but not whether each repo's `main` is passing CI. This adds a Main CI column with the latest result of each GitHub Actions workflow on each repo's default branch. ## What changes **Column** - Main CI is the last column. It reads `✓ 4` when the latest result of each of the repo's four workflows with a result in the last 30 days passed, `✗ 1 of 4` when one failed, and `—` before the first read. It sorts by the number of failed workflows, the footer adds them up, and the cell links the first failed run (otherwise the repo's Actions

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #15, open)
Base
9eaf2b22d3630c5ca0731009a2a3e835f49ee6d0
Size
+1285 −13 in 11 files
Review
pending
Shared
2026-10-28
Id
f9fdf26d79c5afc4a4e9ac97

How it went

No other operator has reported using it yet.

Diff

raw · README.md crates/craft-core/src/lib.rs crates/craft-core/src/model.rs crates/craft-github/src/ci.rs crates/craft-github/src/lib.rs crates/craft-status-app/src/lib.rs ui/src/app.test.ts ui/src/app.ts ui/src/model.test.ts ui/src/model.ts ui/style.css

diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -35,6 +35,7 @@ sort by a column again. | Latest | last commit to `main` · newest issue · newest PR | | Activity (dropdown) | commits to `main` · PRs opened · PRs merged · issues opened · people committing to `main` | | Builds | most recent release tag · its publish date · **Unreleased** (commits on `main` since it) · its downloads · downloads across all releases |+| CI | **Main CI** (workflows whose latest result on the default branch failed) |  - **Activity** covers the window picked in that group's header: the last   10 min, 30 min, hour, 4 h (the default), 12 h, day or week, since the last@@ -58,6 +59,15 @@ sort by a column again.   rpm, flatpak, zips and tarballs. Checksum lists, signatures, `.zsync` delta   files and updater manifests are left out, because machines fetch them, not   people.+- **Main CI** reads `✓ 4` when the latest result of each of the repo's four+  GitHub Actions workflows with a result in the last 30 days passed, and+  `✗ 1 of 4` when one failed. A workflow's latest result is its newest+  completed run on the default branch that was not canceled or skipped,+  together with its other runs on the same commit and event that were created+  within a minute of it, for example Dependabot's runs for each package+  ecosystem. Scheduled runs count; pull request runs and disabled workflows+  are left out. Hover the cell for each workflow's result, and click it to+  open the failed run. - **Fix first** (`⌘2`) ranks open issues across every repo. Each issue shows   the reasons behind its score. Click a row in the Repos table to filter the   list to that repo. Click an issue to open it on GitHub.@@ -100,6 +110,12 @@ scored.   request. It never uses search, which GitHub limits far more tightly (to   about 30 a minute); PR and issue counts per window come from walking the   recent PRs and issues.+- For **Main CI**, each fetch also lists the repo's workflows, then the newest+  20 completed runs of each active workflow on the default branch, reading up+  to 10 pages of them while none holds a result. Each page is requested with+  the ETag of its previous answer, so an unchanged list comes back as 304,+  which does not count against GitHub's primary rate limit. These requests+  are skipped while fewer than 1,500 GraphQL points remain. - Each repo's icon is `assets/app-icon/hicolor/64x64/apps/*.png` on its default   branch. It is downloaded once and again only when the file changes; it's   kept in the cache. Repos without one (libraries) get a lettered tile.@@ -116,7 +132,8 @@ scored.   - `raw/<owner>@<repo>.json`: each repo's commit authors, PRs and issues     from the last week (or since the latest build). Each poll fetches only     what's new since the previous one, and refetches from scratch every six-    hours to drop deleted issues and pick up late-dated commits.+    hours to drop deleted issues and pick up late-dated commits. It also+    keeps the ETags and results of the Main CI requests.   - `snapshot.json`: what the window shows, saved after each repo. On launch     the window shows it at once, marked `STALE` if it's old. Repos whose     data is still fresh aren't refetched at launch.diff --git a/crates/craft-core/src/lib.rs b/crates/craft-core/src/lib.rs--- a/crates/craft-core/src/lib.rs+++ b/crates/craft-core/src/lib.rs@@ -11,8 +11,8 @@ pub mod urgency; pub use cache::Cache; pub use config::{Config, WindowState}; pub use model::{-    Activity, RepoEntry, RepoStats, Schedule, Snapshot, TokenStatus, UrgentIssue, ALL_TIME,-    SINCE_RELEASE, WINDOWS,+    Activity, MainCi, MainRun, MainWorkflow, RepoEntry, RepoStats, Schedule, Snapshot, TokenStatus,+    UrgentIssue, ALL_TIME, SINCE_RELEASE, WINDOWS, }; pub use releases::{summarize_releases, Asset, BuildLag, LagBasis, Release, ReleaseSummary}; pub use urgency::{rank_issues, score_issue, IssueInput, Scored};diff --git a/crates/craft-core/src/model.rs b/crates/craft-core/src/model.rs--- a/crates/craft-core/src/model.rs+++ b/crates/craft-core/src/model.rs@@ -120,6 +120,59 @@ pub struct RepoEntry {     pub icon: Option<String>,     /// The blob `icon` was downloaded from; compared with `stats.icon_oid`.     pub icon_oid: Option<String>,+    /// The latest result of each workflow on the default branch; `None`+    /// until read.+    pub main_ci: Option<MainCi>,+}++/// The latest result of each GitHub Actions workflow on a repository's+/// default branch.+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]+#[serde(default)]+pub struct MainCi {+    /// The default branch the runs were listed for.+    pub branch: String,+    /// One per active workflow with a result, by name.+    pub workflows: Vec<MainWorkflow>,+    /// When GitHub last answered, an unchanged answer included.+    pub checked_at: Option<Time>,+}++/// One workflow's latest result: its newest completed run on the default+/// branch that was not canceled or skipped, with every other such run of+/// the workflow on the same commit and event created within 60 s of it.+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]+#[serde(default)]+pub struct MainWorkflow {+    pub workflow_id: u64,+    /// The workflow's name from its definition, such as `CI` or+    /// `Dependabot Updates`.+    pub name: String,+    /// The workflow file, such as `.github/workflows/ci.yml`.+    pub path: String,+    /// Newest first.+    pub runs: Vec<MainRun>,+}++/// A completed workflow run on the default branch. Scheduled and manual runs+/// count; pull request runs do not.+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]+#[serde(default)]+pub struct MainRun {+    /// GitHub's run id, which grows with each new run; a re-run keeps it.+    pub run_id: u64,+    /// GitHub's conclusion: `success`, `failure`, `timed_out`,+    /// `startup_failure`, `action_required`, `neutral` or `stale`.+    pub conclusion: String,+    /// What started the run: `push`, `schedule`, `workflow_dispatch`…+    pub event: String,+    pub head_sha: String,+    /// The run's title, such as the commit message or a Dependabot job.+    pub title: String,+    /// When the run last changed, which for a completed run is when it+    /// finished.+    pub at: Option<Time>,+    pub url: String, }  impl RepoEntry {@@ -353,6 +406,17 @@ mod tests {         assert!(!e.is_hole());     } +    #[test]+    fn a_cache_without_main_ci_loads_and_is_not_a_hole() {+        let old = r#"{"repos":[{"repo":"o/a","fetched_at":"2026-10-18T00:00:00Z"}]}"#;+        let s: Snapshot = serde_json::from_str(old).unwrap();+        assert_eq!(s.repos[0].main_ci, None);+        assert!(+            !s.repos[0].is_hole(),+            "Main CI is read with a fetch, never for one"+        );+    }+     #[test]     fn schedule_fills_holes_then_the_stalest() {         let now = Utc::now();diff --git a/crates/craft-github/src/ci.rs b/crates/craft-github/src/ci.rsnew file mode 100644--- /dev/null+++ b/crates/craft-github/src/ci.rs@@ -0,0 +1,824 @@+//! The latest result of each GitHub Actions workflow on a repository's default+//! branch ([`GitHub::main_ci`]), for the Repos table's Main CI column.+//!+//! Each read lists the repository's workflows, then the completed runs of+//! each active workflow on the branch, [`RUNS_PER_PAGE`] at a time, until the+//! pages hold its result or reach [`KEEP_DAYS`] back, up to [`RUN_PAGES`]+//! pages. Every page read last time is requested again with the ETag GitHub+//! sent for it: when all of a list's pages come back as 304 Not Modified,+//! which does not count against GitHub's primary rate limit, the result+//! read from them stands. Otherwise the list is read again from its first+//! page and its result is worked out afresh.+//!+//! A workflow's result is its newest completed run that was not canceled or+//! skipped, together with every other such run of the workflow on the same+//! commit and event created within [`GROUP_SECS`] seconds of it. Runs started+//! by pull requests, and runs from other repositories, are left out. Only+//! active workflows get a result, labeled with the workflow's own name, and a+//! result older than [`KEEP_DAYS`] days is dropped.++use std::cmp::Reverse;+use std::collections::{BTreeMap, BTreeSet};++use chrono::{DateTime, Utc};+use craft_core::{MainCi, MainRun, MainWorkflow};+use serde::{Deserialize, Serialize};+use serde_json::Value;++use crate::{time, Conditional, GitHub, Result};++/// Completed runs one request lists.+pub const RUNS_PER_PAGE: usize = 20;+/// Pages of one workflow's runs read while none holds its result.+pub const RUN_PAGES: u32 = 10;+/// Workflows one request lists, the most GitHub allows.+const WORKFLOWS_PER_PAGE: usize = 100;+/// Pages of the workflow list read at most.+const WORKFLOW_PAGES: u32 = 10;+/// A workflow's result is dropped once its newest run is this many days old.+pub const KEEP_DAYS: i64 = 30;+/// Runs of one workflow on the same commit and event that were created within+/// this many seconds of the newest one form one result.+pub const GROUP_SECS: i64 = 60;+/// Conclusions that are not a result: the run was stopped or ran nothing.+const NOT_A_RESULT: &[&str] = &["cancelled", "skipped"];+/// Events whose runs test a pull request's branch, not the default branch,+/// even when that branch shares its name.+const PULL_REQUEST_EVENTS: &[&str] = &["pull_request", "pull_request_target"];++/// What the last read of a repository's workflow runs returned: the ETags to+/// send next time and the results they stand for.+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]+#[serde(default)]+pub struct CachedRuns {+    /// The branch the runs were listed for.+    pub branch: String,+    /// The ETag of each page of the workflow list read last time.+    pub workflows_etags: Vec<String>,+    /// The repository's active workflows.+    pub workflows: Vec<Workflow>,+    /// The ETag of each page of a workflow's runs read last time, by workflow+    /// id.+    pub runs_etags: BTreeMap<u64, Vec<String>>,+    /// Each workflow's latest result, by workflow id.+    pub results: BTreeMap<u64, MainWorkflow>,+}++/// An active workflow from the repository's workflow list.+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]+#[serde(default)]+pub struct Workflow {+    pub id: u64,+    pub name: String,+    pub path: String,+}++/// What [`parse_runs`] found in a runs list.+#[derive(Debug, Clone, Default, PartialEq)]+pub struct RunsPage {+    /// Each workflow's latest result, by workflow id.+    pub results: BTreeMap<u64, MainWorkflow>,+    /// The workflows whose result may include runs past the end of the list.+    pub cut: BTreeSet<u64>,+    /// When the oldest run in the list was created.+    pub oldest: Option<DateTime<Utc>>,+}++/// The pages of a list read afresh, with the ETag of each.+#[derive(Debug, Default)]+struct Pages {+    bodies: Vec<Value>,+    etags: Vec<String>,+}++impl GitHub {+    /// The latest result of each active workflow on `branch` of `repo`, read+    /// into `cache`. The requests carry the ETags `cache` holds for the same+    /// branch; a failed request leaves `cache` as it was.+    pub fn main_ci(+        &self,+        repo: &str,+        branch: &str,+        cache: &mut Option<CachedRuns>,+        now: DateTime<Utc>,+    ) -> Result<MainCi> {+        let held = cache.take().filter(|c| c.branch == branch);+        let mut next = held.clone().unwrap_or_else(|| CachedRuns {+            branch: branch.to_string(),+            ..Default::default()+        });+        let cutoff = now - chrono::Duration::days(KEEP_DAYS);+        if let Err(e) = self.read_runs(repo, &mut next, cutoff) {+            *cache = held;+            return Err(e);+        }+        for w in &next.workflows {+            if let Some(r) = next.results.get_mut(&w.id) {+                r.name.clone_from(&w.name);+                r.path.clone_from(&w.path);+            }+        }+        let mut workflows: Vec<MainWorkflow> = next+            .results+            .values()+            .filter(|w| newest_at(w).is_none_or(|at| at >= cutoff))+            .cloned()+            .collect();+        workflows.sort_by(|a, b| {+            (a.name.to_lowercase(), a.workflow_id).cmp(&(b.name.to_lowercase(), b.workflow_id))+        });+        *cache = Some(next);+        Ok(MainCi {+            branch: branch.to_string(),+            workflows,+            checked_at: Some(now),+        })+    }++    /// Brings `c` up to date: the workflow list, then each active workflow's+    /// runs back to `cutoff` at most.+    fn read_runs(&self, repo: &str, c: &mut CachedRuns, cutoff: DateTime<Utc>) -> Result<()> {+        let base = format!("{}/repos/{repo}/actions", self.api);+        let list = format!("{base}/workflows?per_page={WORKFLOWS_PER_PAGE}");+        let more_workflows = |bodies: &[Value]| {+            let last = bodies.last().and_then(|b| b["workflows"].as_array());+            last.is_some_and(|l| l.len() >= WORKFLOWS_PER_PAGE)+        };+        let read = self.read_pages(&list, &c.workflows_etags, WORKFLOW_PAGES, more_workflows)?;+        if let Some(pages) = read {+            c.workflows = pages.bodies.iter().flat_map(parse_workflows).collect();+            c.workflows_etags = pages.etags;+        }+        let active: BTreeSet<u64> = c.workflows.iter().map(|w| w.id).collect();+        c.results.retain(|id, _| active.contains(id));+        c.runs_etags.retain(|id, _| active.contains(id));+        let query = format!(+            "branch={}&status=completed&exclude_pull_requests=true&per_page={RUNS_PER_PAGE}",+            encode(&c.branch)+        );+        for id in active {+            let url = format!("{base}/workflows/{id}/runs?{query}");+            // Read another page while the result is missing or may go on, the+            // last page was full, and it reached no further back than `cutoff`.+            let more_runs = |bodies: &[Value]| {+                let last = bodies.last().map(|b| runs_in(std::slice::from_ref(b)));+                if last.is_none_or(|l| l.len() < RUNS_PER_PAGE) {+                    return false;+                }+                let found = parse_runs(&runs_in(bodies), repo, true);+                let done = found.results.contains_key(&id) && !found.cut.contains(&id);+                !done && found.oldest.is_some_and(|t| t >= cutoff)+            };+            let held = c.runs_etags.get(&id).map_or(&[][..], Vec::as_slice);+            let Some(pages) = self.read_pages(&url, held, RUN_PAGES, more_runs)? else {+                continue;+            };+            let listed = runs_in(&pages.bodies);+            let full = pages+                .bodies+                .last()+                .is_some_and(|b| runs_in(std::slice::from_ref(b)).len() >= RUNS_PER_PAGE);+            match parse_runs(&listed, repo, full).results.remove(&id) {+                Some(w) => c.results.insert(id, w),+                None => c.results.remove(&id),+            };+            c.runs_etags.insert(id, pages.etags);+        }+        Ok(())+    }++    /// The pages of the list at `url` (`&page=N` added after the first), read+    /// until `more` says the pages so far are enough, or `max` pages are read.+    /// `held` holds the ETags of the pages read last time: when each of those+    /// pages is unchanged, nothing is read again and the answer is `None`.+    fn read_pages(+        &self,+        url: &str,+        held: &[String],+        max: u32,+        more: impl Fn(&[Value]) -> bool,+    ) -> Result<Option<Pages>> {+        let page_url = |n: usize| match n {+            1 => url.to_string(),+            n => format!("{url}&page={n}"),+        };+        let mut first = None;+        if !held.is_empty() && held.iter().all(|e| !e.is_empty()) {+            let mut unchanged = true;+            for (i, etag) in held.iter().enumerate() {+                match self.get_conditional(&page_url(i + 1), Some(etag))? {+                    Conditional::NotModified => {}+                    answer => {+                        if i == 0 {+                            first = Some(answer);+                        }+                        unchanged = false;+                        break;+                    }+                }+            }+            if unchanged {+                return Ok(None);+            }+        }+        let mut pages = Pages::default();+        for n in 1..=max as usize {+            let answer = match first.take() {+                Some(answer) => answer,+                None => self.get_conditional(&page_url(n), None)?,+            };+            let Conditional::Fresh { etag, body } = answer else {+                break;+            };+            pages.etags.push(etag.unwrap_or_default());+            pages.bodies.push(body);+            if !more(&pages.bodies) {+                break;+            }+        }+        Ok(Some(pages))+    }+}++/// The runs of every page in `bodies` (`workflow_runs`), in order.+fn runs_in(bodies: &[Value]) -> Vec<Value> {+    bodies+        .iter()+        .filter_map(|b| b["workflow_runs"].as_array())+        .flatten()+        .cloned()+        .collect()+}++/// The active workflows in a page of the workflow list (`workflows`).+/// Disabled workflows are left out.+pub fn parse_workflows(body: &Value) -> Vec<Workflow> {+    body["workflows"]+        .as_array()+        .into_iter()+        .flatten()+        .filter(|w| w["state"].as_str() == Some("active"))+        .filter_map(|w| {+            Some(Workflow {+                id: w["id"].as_u64()?,+                name: w["name"].as_str().unwrap_or_default().to_string(),+                path: w["path"].as_str().unwrap_or_default().to_string(),+            })+        })+        .collect()+}++/// A run, and when it was created.+type CreatedRun = (MainRun, Option<DateTime<Utc>>);++/// The latest result of each workflow in a runs list (`listed`, newest+/// first): its newest run by run id, with its other runs on the same commit+/// and event created within [`GROUP_SECS`] of it, leaving out canceled and+/// skipped runs, runs started by a pull request, and runs from another+/// repository. `full` says the list may go on past its last run. Each result+/// is labeled with its newest run's name until [`GitHub::main_ci`] gives it+/// the workflow's own.+pub fn parse_runs(listed: &[Value], repo: &str, full: bool) -> RunsPage {+    let created = |run: &Value| time(&run["created_at"]);+    let mut found: BTreeMap<u64, Vec<CreatedRun>> = BTreeMap::new();+    for run in listed {+        let str_of = |k: &str| run[k].as_str().unwrap_or_default().to_string();+        let (Some(run_id), Some(id)) = (run["id"].as_u64(), run["workflow_id"].as_u64()) else {+            continue;+        };+        let Some(conclusion) = run["conclusion"].as_str() else {+            continue;+        };+        let event = str_of("event");+        let from = run["head_repository"]["full_name"].as_str();+        if NOT_A_RESULT.contains(&conclusion)+            || PULL_REQUEST_EVENTS.contains(&event.as_str())+            || from.is_some_and(|f| !f.eq_ignore_ascii_case(repo))+        {+            continue;+        }+        let found_run = MainRun {+            run_id,+            conclusion: conclusion.to_string(),+            event,+            head_sha: str_of("head_sha"),+            title: run["display_title"]+                .as_str()+                .map_or_else(|| str_of("name"), str::to_string),+            at: time(&run["updated_at"]).or_else(|| time(&run["run_started_at"])),+            url: str_of("html_url"),+        };+        found.entry(id).or_default().push((found_run, created(run)));+    }+    let oldest = listed.iter().filter_map(created).min();+    let near_end = |t: Option<DateTime<Utc>>| {+        full && oldest.is_some_and(|o| t.is_none_or(|t| (t - o).num_seconds() <= GROUP_SECS))+    };+    let mut page = RunsPage {+        oldest,+        ..Default::default()+    };+    let named = |id: u64| {+        let run = listed+            .iter()+            .find(|r| r["workflow_id"].as_u64() == Some(id));+        let str_of = |k: &str| {+            run.and_then(|r| r[k].as_str())+                .unwrap_or_default()+                .to_string()+        };+        (str_of("name"), str_of("path"))+    };+    for (id, mut runs) in found {+        runs.sort_by_key(|(r, _)| Reverse(r.run_id));+        let Some((newest, newest_created)) = runs.first().cloned() else {+            continue;+        };+        runs.retain(|(r, at)| {+            r.head_sha == newest.head_sha+                && r.event == newest.event+                && match (at, newest_created) {+                    (Some(at), Some(n)) => (n - *at).num_seconds().abs() <= GROUP_SECS,+                    _ => r.run_id == newest.run_id,+                }+        });+        if runs.last().is_some_and(|(_, at)| near_end(*at)) {+            page.cut.insert(id);+        }+        let (name, path) = named(id);+        page.results.insert(+            id,+            MainWorkflow {+                workflow_id: id,+                name,+                path,+                runs: runs.into_iter().map(|(r, _)| r).collect(),+            },+        );+    }+    page+}++fn newest_at(w: &MainWorkflow) -> Option<DateTime<Utc>> {+    w.runs.iter().filter_map(|r| r.at).max()+}++/// `s` for a URL query value: letters, digits and `-._~/` as they are, every+/// other byte percent-encoded.+fn encode(s: &str) -> String {+    s.bytes()+        .map(|b| {+            if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {+                (b as char).to_string()+            } else {+                format!("%{b:02X}")+            }+        })+        .collect()+}++#[cfg(test)]+mod tests {+    use super::*;+    use crate::tests::stub;+    use serde_json::json;+    use std::sync::mpsc::Receiver;+    use std::time::Duration;++    const NOT_MODIFIED: &str =+        "HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";+    const QUERY: &str = "?branch=main&status=completed&exclude_pull_requests=true&per_page=20";+    const LIST_URL: &str = "/repos/o/app/actions/workflows?per_page=100";++    /// `short` (`10-17T09:00`) as a time in 2026.+    fn stamp(short: &str) -> String {+        format!("2026-{short}:00Z")+    }++    fn t(short: &str) -> DateTime<Utc> {+        stamp(short).parse().unwrap()+    }++    fn ok(body: &Value, etag: &str) -> String {+        let body = body.to_string();+        format!(+            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nETag: {etag}\r\n\+             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",+            body.len()+        )+    }++    /// A workflow list holding `active` workflows, as `(id, name)`.+    fn workflows(active: &[(u64, &str)]) -> Value {+        let list: Vec<Value> = active+            .iter()+            .map(|(id, name)| {+                json!({"id": id, "name": name, "path": format!(".github/workflows/w{id}.yml"), "state": "active"})+            })+            .collect();+        json!({"total_count": list.len(), "workflows": list})+    }++    /// A run of workflow `wf` created and finished at `at` (see [`stamp`]).+    fn run(id: u64, wf: u64, conclusion: Option<&str>, event: &str, at: &str) -> Value {+        json!({+            "id": id, "workflow_id": wf, "name": format!("Workflow {wf}"), "display_title": format!("Commit {id}"),+            "path": format!(".github/workflows/w{wf}.yml"), "event": event, "status": "completed",+            "conclusion": conclusion, "head_sha": format!("sha{id}"), "created_at": stamp(at), "updated_at": stamp(at),+            "html_url": format!("https://github.com/o/app/actions/runs/{id}"),+            "head_repository": {"full_name": "o/app"},+        })+    }++    fn runs(list: Vec<Value>) -> Value {+        json!({"total_count": list.len(), "workflow_runs": list})+    }++    /// `count` runs of workflow `wf` that ended as `conclusion`, newest+    /// first, with run ids from `top` down, a minute apart from `at` back.+    fn many(count: u64, top: u64, wf: u64, conclusion: &str, at: &str) -> Vec<Value> {+        (0..count)+            .map(|i| {+                let mut r = run(top - i, wf, Some(conclusion), "push", at);+                let when = t(at) - chrono::Duration::minutes(i as i64);+                let when = when.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);+                r["created_at"] = json!(when);+                r["updated_at"] = json!(when);+                r+            })+            .collect()+    }++    /// `(workflow id, [(run id, conclusion)])` per result.+    fn summary(ci: &MainCi) -> Vec<(u64, Vec<(u64, String)>)> {+        ci.workflows+            .iter()+            .map(|w| {+                let runs = w.runs.iter().map(|r| (r.run_id, r.conclusion.clone()));+                (w.workflow_id, runs.collect())+            })+            .collect()+    }++    /// The request targets and `If-None-Match` values of the next `n`+    /// requests the stub received.+    fn requested(requests: &Receiver<String>, n: usize) -> Vec<(String, Option<String>)> {+        (0..n)+            .map(|_| {+                let head = requests.recv_timeout(Duration::from_secs(10)).unwrap();+                let target = head.split(' ').nth(1).unwrap_or_default().to_string();+                let etag = head+                    .lines()+                    .find_map(|l| l.strip_prefix("if-none-match: "))+                    .map(str::to_string);+                (target, etag)+            })+            .collect()+    }++    fn runs_url(wf: u64) -> String {+        format!("/repos/o/app/actions/workflows/{wf}/runs{QUERY}")+    }++    fn list_url() -> String {+        LIST_URL.to_string()+    }++    fn etag(e: &str) -> Option<String> {+        Some(e.to_string())+    }++    #[test]+    fn each_workflow_keeps_its_newest_real_result() {+        let mut fork = run(9, 4, Some("failure"), "push", "10-17T09:00");+        fork["head_repository"]["full_name"] = json!("someone/app");+        let listed = [+            run(7, 1, Some("cancelled"), "push", "10-17T10:00"),+            run(6, 1, Some("failure"), "push", "10-17T09:30"),+            run(5, 1, Some("success"), "push", "10-17T09:00"),+            run(4, 2, Some("skipped"), "schedule", "10-17T08:30"),+            run(3, 2, Some("success"), "schedule", "10-17T08:00"),+            run(2, 3, Some("failure"), "pull_request", "10-17T07:00"),+            run(1, 5, None, "push", "10-17T06:00"),+            fork,+            // Listed out of order, and finished before run 3: the newer run still wins.+            run(8, 2, Some("failure"), "schedule", "10-17T07:30"),+        ];+        let page = parse_runs(&listed, "O/App", false);+        let got: Vec<(u64, Vec<u64>, &str)> = page+            .results+            .values()+            .map(|w| {+                let ids = w.runs.iter().map(|r| r.run_id).collect();+                (w.workflow_id, ids, w.runs[0].conclusion.as_str())+            })+            .collect();+        assert_eq!(+            got,+            [(1, vec![6], "failure"), (2, vec![8], "failure")],+            "canceled and skipped runs, pull request runs, unfinished runs and other repositories' runs are left out"+        );+        let r = &page.results[&1].runs[0];+        assert_eq!((r.event.as_str(), r.head_sha.as_str()), ("push", "sha6"));+        assert_eq!(r.title, "Commit 6");+        assert_eq!(r.url, "https://github.com/o/app/actions/runs/6");+        assert_eq!(r.at, Some(t("10-17T09:30")));+        assert_eq!(page.results[&1].path, ".github/workflows/w1.yml");+        assert!(page.cut.is_empty());+        assert_eq!(page.oldest, Some(t("10-17T06:00")));+    }++    #[test]+    fn main_ci_sends_the_etags_and_reuses_the_results_on_304() {+        let ci_runs = runs(vec![run(2, 1, Some("failure"), "push", "10-17T09:00")]);+        let nightly = runs(vec![run(1, 2, Some("success"), "schedule", "09-01T09:00")]);+        let fixed = runs(vec![run(3, 1, Some("success"), "push", "10-17T10:00")]);+        let (gh, requests) = stub(vec![+            ok(&workflows(&[(1, "CI"), (2, "Nightly")]), "W/\"l\""),+            ok(&ci_runs, "W/\"c\""),+            ok(&nightly, "W/\"n\""),+            NOT_MODIFIED.into(),+            NOT_MODIFIED.into(),+            NOT_MODIFIED.into(),+            NOT_MODIFIED.into(),+            ok(&fixed, "W/\"c2\""),+            NOT_MODIFIED.into(),+        ]);+        let now = t("10-17T11:00");+        let mut cache = None;+        let ci = gh.main_ci("o/app", "main", &mut cache, now).unwrap();+        assert_eq!(+            summary(&ci),+            [(1, vec![(2, "failure".into())])],+            "a result older than the keep limit is not shown"+        );+        assert_eq!((ci.checked_at, ci.branch.as_str()), (Some(now), "main"));+        assert_eq!(ci.workflows[0].name, "CI");+        let again = gh.main_ci("o/app", "main", &mut cache, now).unwrap();+        assert_eq!(summary(&again), summary(&ci), "304: the held results");+        let third = gh.main_ci("o/app", "main", &mut cache, now).unwrap();+        assert_eq!(summary(&third), [(1, vec![(3, "success".into())])]);+        assert_eq!(+            requested(&requests, 9),+            [+                (list_url(), None),+                (runs_url(1), None),+                (runs_url(2), None),+                (list_url(), etag("w/\"l\"")),+                (runs_url(1), etag("w/\"c\"")),+                (runs_url(2), etag("w/\"n\"")),+                (list_url(), etag("w/\"l\"")),+                (runs_url(1), etag("w/\"c\"")),+                (runs_url(2), etag("w/\"n\"")),+            ]+        );+        assert_eq!(cache.unwrap().runs_etags[&1], ["W/\"c2\""]);+    }++    #[test]+    fn an_unchanged_repo_sends_one_request_per_list_answered_with_304() {+        let ci_runs = runs(vec![run(5, 1, Some("success"), "push", "10-17T09:00")]);+        let weekly = runs(vec![run(2, 2, Some("failure"), "schedule", "10-12T06:00")]);+        let (gh, requests) = stub(vec![+            ok(&workflows(&[(1, "CI"), (2, "Weekly")]), "W/\"l\""),+            ok(&ci_runs, "W/\"c\""),+            ok(&weekly, "W/\"w\""),+            NOT_MODIFIED.into(),+            NOT_MODIFIED.into(),+            NOT_MODIFIED.into(),+        ]);+        let now = t("10-17T11:00");+        let mut cache = None;+        let first = gh.main_ci("o/app", "main", &mut cache, now).unwrap();+        requested(&requests, 3);+        let again = gh.main_ci("o/app", "main", &mut cache, now).unwrap();+        assert_eq!(summary(&again), summary(&first));+        assert_eq!(requested(&requests, 3).len(), 3);+        assert!(+            requests.recv_timeout(Duration::from_millis(300)).is_err(),+            "nothing else"+        );+    }++    #[test]+    fn a_workflow_behind_a_run_of_canceled_runs_still_shows_its_newest_result() {+        // Pushes in quick succession canceled FreeBSD 21 times, more than a page.+        let mut freebsd = many(21, 200, 2, "cancelled", "10-18T17:30");+        freebsd.push(run(150, 2, Some("failure"), "push", "10-18T14:26"));+        let nix = runs(vec![+            run(140, 1, Some("cancelled"), "push", "10-18T17:10"),+            run(130, 1, Some("success"), "push", "10-18T17:07"),+        ]);+        let second_page = freebsd.split_off(RUNS_PER_PAGE);+        let (gh, requests) = stub(vec![+            ok(&workflows(&[(1, "Nix"), (2, "FreeBSD")]), "W/\"l\""),+            ok(&nix, "W/\"n\""),+            ok(&runs(freebsd), "W/\"f1\""),+            ok(&runs(second_page), "W/\"f2\""),+        ]);+        let mut cache = None;+        let ci = gh+            .main_ci("o/app", "main", &mut cache, t("10-18T20:00"))+            .unwrap();+        let freebsd_first = (2, vec![(150, "failure".into())]);+        let nix_second = (1, vec![(130, "success".into())]);+        assert_eq!(summary(&ci), [freebsd_first, nix_second]);+        let got = requested(&requests, 4);+        assert_eq!(got[3], (format!("{}&page=2", runs_url(2)), None));+        assert_eq!(cache.unwrap().runs_etags[&2], ["W/\"f1\"", "W/\"f2\""]);+    }++    #[test]+    fn a_workflow_that_ran_long_ago_still_gets_its_latest_run() {+        let ci_runs = runs(vec![run(90, 1, Some("success"), "push", "10-18T17:00")]);+        let freebsd = runs(vec![run(12, 3, Some("failure"), "push", "10-07T07:48")]);+        let (gh, _requests) = stub(vec![+            ok(&workflows(&[(1, "CI"), (3, "FreeBSD")]), "W/\"l\""),+            ok(&ci_runs, "W/\"c\""),+            ok(&freebsd, "W/\"f\""),+        ]);+        let mut cache = None;+        let ci = gh+            .main_ci("o/app", "main", &mut cache, t("10-18T20:00"))+            .unwrap();+        let ci_row = (1, vec![(90, "success".into())]);+        let freebsd_row = (3, vec![(12, "failure".into())]);+        assert_eq!(summary(&ci), [ci_row, freebsd_row], "an 11-day-old failure");+    }++    #[test]+    fn a_dynamic_workflow_is_labeled_by_the_workflow_name() {+        let list = json!({"total_count": 2, "workflows": [+            {"id": 9, "name": "Dependabot Updates", "path": "dynamic/dependabot/dependabot-updates", "state": "active"},+            {"id": 4, "name": "Old", "path": ".github/workflows/old.yml", "state": "disabled_manually"},+        ]});+        let title = "github_actions in /. - Update #1634566446";+        let mut update = run(70, 9, Some("success"), "dynamic", "10-18T18:32");+        update["name"] = json!(title);+        update["display_title"] = json!(title);+        update["path"] = json!("dynamic/dependabot/dependabot-updates");+        let (gh, requests) = stub(vec![+            ok(&list, "W/\"l\""),+            ok(&runs(vec![update]), "W/\"d\""),+        ]);+        let mut cache = None;+        let ci = gh+            .main_ci("o/app", "main", &mut cache, t("10-18T20:00"))+            .unwrap();+        let w = &ci.workflows[0];+        assert_eq!(+            (w.name.as_str(), w.runs[0].title.as_str()),+            ("Dependabot Updates", title)+        );+        assert_eq!(w.path, "dynamic/dependabot/dependabot-updates");+        let got = requested(&requests, 2);+        assert_eq!(+            got[1].0,+            runs_url(9),+            "a disabled workflow's runs are not read"+        );+    }++    #[test]+    fn runs_of_one_workflow_started_together_on_the_newest_commit_count_together() {+        let mut listed = vec![+            run(301, 9, Some("success"), "dynamic", "10-18T18:32"),+            run(300, 9, Some("failure"), "dynamic", "10-18T18:39"),+            run(250, 9, Some("success"), "dynamic", "10-18T12:00"),+            run(240, 9, Some("success"), "schedule", "10-18T11:00"),+            run(220, 9, Some("failure"), "dynamic", "10-18T10:00"),+        ];+        for i in [0, 1, 3, 4] {+            listed[i]["head_sha"] = json!("0d34a58b");+        }+        for i in [0, 1] {+            listed[i]["created_at"] = json!(stamp("10-18T18:32"));+        }+        let page = parse_runs(&listed, "o/app", false);+        let ids: Vec<(u64, &str)> = page.results[&9]+            .runs+            .iter()+            .map(|r| (r.run_id, r.conclusion.as_str()))+            .collect();+        assert_eq!(+            ids,+            [(301, "success"), (300, "failure")],+            "an older commit, another event, or a run started hours earlier is not part of the result"+        );+    }++    #[test]+    fn a_group_cut_by_the_end_of_a_page_is_read_to_its_end() {+        let mut first = many(19, 400, 9, "cancelled", "10-18T19:00");+        let mut newer = run(301, 9, Some("success"), "dynamic", "10-18T17:00");+        let mut older = run(300, 9, Some("failure"), "dynamic", "10-18T17:05");+        for r in [&mut newer, &mut older] {+            r["head_sha"] = json!("0d34a58b");+            r["created_at"] = json!(stamp("10-18T17:00"));+        }+        first.push(newer);+        let (gh, _requests) = stub(vec![+            ok(&workflows(&[(9, "Dependabot Updates")]), "W/\"l\""),+            ok(&runs(first), "W/\"d1\""),+            ok(&runs(vec![older]), "W/\"d2\""),+        ]);+        let mut cache = None;+        let ci = gh+            .main_ci("o/app", "main", &mut cache, t("10-18T20:00"))+            .unwrap();+        let both = vec![(301, "success".into()), (300, "failure".into())];+        assert_eq!(summary(&ci), [(9, both)]);+    }++    #[test]+    fn a_change_on_a_later_page_reads_the_list_again() {+        let page_one = many(20, 400, 1, "cancelled", "10-18T19:00");+        let page_two = vec![run(300, 1, Some("failure"), "push", "10-18T17:00")];+        // Run 300 was re-run, and its second attempt was canceled.+        let mut rerun = page_two[0].clone();+        rerun["conclusion"] = json!("cancelled");+        let older = run(290, 1, Some("success"), "push", "10-18T16:00");+        let (gh, requests) = stub(vec![+            ok(&workflows(&[(1, "CI")]), "W/\"l\""),+            ok(&runs(page_one.clone()), "W/\"p1\""),+            ok(&runs(page_two), "W/\"p2\""),+            NOT_MODIFIED.into(),+            NOT_MODIFIED.into(),+            ok(&runs(vec![rerun.clone(), older.clone()]), "W/\"p2b\""),+            ok(&runs(page_one), "W/\"p1\""),+            ok(&runs(vec![rerun, older]), "W/\"p2b\""),+        ]);+        let now = t("10-18T20:00");+        let mut cache = None;+        let ci = gh.main_ci("o/app", "main", &mut cache, now).unwrap();+        assert_eq!(summary(&ci), [(1, vec![(300, "failure".into())])]);+        let again = gh.main_ci("o/app", "main", &mut cache, now).unwrap();+        assert_eq!(+            summary(&again),+            [(1, vec![(290, "success".into())])],+            "the canceled re-run leaves the result, and the run before it takes its place"+        );+        let page_two_url = format!("{}&page=2", runs_url(1));+        assert_eq!(+            requested(&requests, 8)[3..],+            [+                (list_url(), etag("w/\"l\"")),+                (runs_url(1), etag("w/\"p1\"")),+                (page_two_url.clone(), etag("w/\"p2\"")),+                (runs_url(1), None),+                (page_two_url, None),+            ]+        );+    }++    #[test]+    fn a_workflow_list_longer_than_a_page_is_read_to_its_end() {+        let names: Vec<String> = (1..=100).map(|id| format!("W{id}")).collect();+        let first: Vec<(u64, &str)> = (1..).zip(names.iter().map(String::as_str)).collect();+        let mut list = workflows(&first);+        for w in list["workflows"].as_array_mut().unwrap() {+            w["state"] = json!("disabled_manually");+        }+        let late = runs(vec![run(5, 101, Some("failure"), "push", "10-18T17:00")]);+        let (gh, requests) = stub(vec![+            ok(&list, "W/\"l1\""),+            ok(&workflows(&[(101, "Late")]), "W/\"l2\""),+            ok(&late, "W/\"r\""),+        ]);+        let mut cache = None;+        let ci = gh+            .main_ci("o/app", "main", &mut cache, t("10-18T20:00"))+            .unwrap();+        assert_eq!(summary(&ci), [(101, vec![(5, "failure".into())])]);+        let got = requested(&requests, 3);+        assert_eq!(got[1], (format!("{LIST_URL}&page=2"), None));+    }++    #[test]+    fn a_failed_request_keeps_the_cache_and_another_branch_starts_without_its_etag() {+        let (gh, requests) = stub(vec![+            "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"+                .to_string(),+            ok(&workflows(&[(1, "CI")]), "W/\"l\""),+            ok(&runs(vec![]), "W/\"t\""),+        ]);+        let held = CachedRuns {+            branch: "main".into(),+            workflows_etags: vec!["W/\"a\"".into()],+            ..Default::default()+        };+        let mut cache = Some(held.clone());+        assert!(gh.main_ci("o/app", "main", &mut cache, Utc::now()).is_err());+        assert_eq!(cache, Some(held), "a failed request keeps what was held");+        let ci = gh+            .main_ci("o/app", "trunk", &mut cache, Utc::now())+            .unwrap();+        assert!(ci.workflows.is_empty());+        let got = requested(&requests, 3);+        assert_eq!(got[0], (list_url(), etag("w/\"a\"")));+        assert!(got[2].0.contains("?branch=trunk&"), "{got:?}");+        assert!(got[1].1.is_none() && got[2].1.is_none(), "{got:?}");+        assert_eq!(cache.unwrap().branch, "trunk");+        assert_eq!(encode("release/1.0 x"), "release/1.0%20x");+    }+}diff --git a/crates/craft-github/src/lib.rs b/crates/craft-github/src/lib.rs--- a/crates/craft-github/src/lib.rs+++ b/crates/craft-github/src/lib.rs@@ -13,7 +13,11 @@ //! after the first poll those queries fetch only what's new; //! one (usually) REST call for the contributor list, which GraphQL lacks. //! The icon itself is a REST blob fetch, made only when its blob id changes-//! (see [`GitHub::icon`]). Blocking I/O: callers run this on worker threads.+//! (see [`GitHub::icon`]). The workflow runs on the default branch are read+//! on their own ([`GitHub::main_ci`]). Blocking I/O: callers run this on+//! worker threads.++pub mod ci;  use std::collections::BTreeMap; use std::path::{Path, PathBuf};@@ -26,10 +30,12 @@ use craft_core::{     RepoStats, ALL_TIME, SINCE_RELEASE, WINDOWS, }; use reqwest::blocking::Client;-use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, USER_AGENT};+use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, ETAG, IF_NONE_MATCH, USER_AGENT}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +pub use ci::CachedRuns;+ const API: &str = "https://api.github.com"; /// How many ranked issues each repo keeps for the Urgent view. const KEEP_URGENT: usize = 20;@@ -122,6 +128,8 @@ fn env_or_config_token(configured: &str, env: impl Fn(&str) -> Option<String>) - pub struct GitHub {     http: Client,     token: String,+    /// Base URL of the API: [`API`], or a local stub in tests.+    api: String, }  /// Rate-limit bookkeeping returned alongside each repo.@@ -130,13 +138,35 @@ pub struct Budget {     pub graphql_remaining: Option<u64>, } +/// The answer to a REST GET sent with `If-None-Match`.+#[derive(Debug)]+enum Conditional {+    /// 304: the stored copy is still current.+    NotModified,+    Fresh {+        etag: Option<String>,+        body: Value,+    },+    /// 204 No Content.+    Empty,+}+ impl GitHub {     pub fn new(token: String) -> Result<GitHub> {+        Self::with_api(token, API)+    }++    /// A client that sends its requests to `api` instead of api.github.com.+    fn with_api(token: String, api: &str) -> Result<GitHub> {         let http = Client::builder()             .timeout(Duration::from_secs(30))             .connect_timeout(Duration::from_secs(10))             .build()?;-        Ok(GitHub { http, token })+        Ok(GitHub {+            http,+            token,+            api: api.to_string(),+        })     }      fn headers(&self) -> HeaderMap {@@ -194,7 +224,7 @@ impl GitHub {         let started = std::time::Instant::now();         let sent = self             .http-            .post(format!("{API}/graphql"))+            .post(format!("{}/graphql", self.api))             .headers(self.headers())             .json(&json!({ "query": query, "variables": variables }))             .send();@@ -219,6 +249,33 @@ impl GitHub {         Ok(v["data"].clone())     } +    /// GET `url` with the usual headers, sending `etag` as `If-None-Match`+    /// when there is one.+    fn get_conditional(&self, url: &str, etag: Option<&str>) -> Result<Conditional> {+        let mut req = self.http.get(url).headers(self.headers());+        if let Some(etag) = etag {+            req = req.header(IF_NONE_MATCH, etag);+        }+        let resp = req.send()?;+        // `check` treats a 304 as an error.+        if resp.status().as_u16() == 304 {+            return Ok(Conditional::NotModified);+        }+        let resp = Self::check(resp)?;+        if resp.status().as_u16() == 204 {+            return Ok(Conditional::Empty);+        }+        let etag = resp+            .headers()+            .get(ETAG)+            .and_then(|v| v.to_str().ok())+            .map(str::to_string);+        Ok(Conditional::Fresh {+            etag,+            body: resp.json()?,+        })+    }+     /// Everything the UI shows for `owner/name`, measured at `now`. `raw` is     /// the activity kept from the previous fetch; it's brought up to date     /// only when the whole fetch succeeds.@@ -244,9 +301,13 @@ impl GitHub {         if full {             fresh = RawActivity {                 full_at: Some(now),+                http: fresh.http,                 ..Default::default()             };         }+        fresh.default_branch = main["repository"]["defaultBranchRef"]["name"]+            .as_str()+            .map(str::to_string);         fresh.commits.extend(self.authors(owner, name, from)?);         self.events(owner, name, from, &mut fresh)?;         fresh.covers_from = Some(since);@@ -275,7 +336,7 @@ impl GitHub {     pub fn icon(&self, repo: &str, oid: &str) -> Result<String> {         let resp = self             .http-            .get(format!("{API}/repos/{repo}/git/blobs/{oid}"))+            .get(format!("{}/repos/{repo}/git/blobs/{oid}", self.api))             .headers(self.headers())             .send()?;         let v: Value = Self::check(resp)?.json()?;@@ -412,7 +473,8 @@ impl GitHub {             let resp = self                 .http                 .get(format!(-                    "{API}/repos/{repo}/contributors?per_page=100&anon=1&page={page}"+                    "{}/repos/{repo}/contributors?per_page=100&anon=1&page={page}",+                    self.api                 ))                 .headers(self.headers())                 .send()?;@@ -613,6 +675,21 @@ pub struct RawActivity {     pub prs_opened: BTreeMap<u64, DateTime<Utc>>,     pub prs_merged: BTreeMap<u64, DateTime<Utc>>,     pub issues_opened: BTreeMap<u64, DateTime<Utc>>,+    /// The default branch's name at the last fetch; `None` for an empty+    /// repository.+    pub default_branch: Option<String>,+    /// REST answers kept for conditional requests. A fetch from scratch+    /// keeps them.+    pub http: HttpCache,+}++/// What the last REST requests returned, kept so the next fetch can skip+/// unchanged downloads. Holds the parsed results, not the response bodies.+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]+#[serde(default)]+pub struct HttpCache {+    /// The default branch's workflow runs ([`GitHub::main_ci`]).+    pub runs: Option<CachedRuns>, }  impl RawActivity {@@ -1234,4 +1311,33 @@ mod tests {         s.recent.clear();         assert_eq!(s, RepoStats::default());     }++    /// A client of a server on 127.0.0.1 that answers one connection with+    /// each of `responses`, in order, and sends back the head of each request+    /// it read, in lowercase. Once `responses` run out, connections fail.+    pub(crate) fn stub(responses: Vec<String>) -> (GitHub, std::sync::mpsc::Receiver<String>) {+        use std::io::{Read, Write};+        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();+        let api = format!("http://{}", listener.local_addr().unwrap());+        let (tx, rx) = std::sync::mpsc::channel();+        std::thread::spawn(move || {+            for response in responses {+                let Ok((mut conn, _)) = listener.accept() else {+                    return;+                };+                conn.set_read_timeout(Some(Duration::from_secs(5))).ok();+                let mut head = Vec::new();+                let mut buf = [0; 1024];+                while !head.windows(4).any(|w| w == b"\r\n\r\n") {+                    match conn.read(&mut buf) {+                        Ok(0) | Err(_) => break,+                        Ok(n) => head.extend_from_slice(&buf[..n]),+                    }+                }+                conn.write_all(response.as_bytes()).ok();+                let _ = tx.send(String::from_utf8_lossy(&head).to_lowercase());+            }+        });+        (GitHub::with_api("t".into(), &api).unwrap(), rx)+    } }diff --git a/crates/craft-status-app/src/lib.rs b/crates/craft-status-app/src/lib.rs--- a/crates/craft-status-app/src/lib.rs+++ b/crates/craft-status-app/src/lib.rs@@ -12,7 +12,7 @@ use std::sync::{mpsc, Mutex}; use std::time::{Duration, SystemTime};  use chrono::{DateTime, Local, Timelike, Utc};-use craft_core::{Cache, Config, Schedule, Snapshot, TokenStatus, WindowState};+use craft_core::{Cache, Config, MainCi, Schedule, Snapshot, TokenStatus, WindowState}; use craft_github::{resolve_token, Error as GhError, GitHub, RawActivity}; use serde::Serialize; use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem};@@ -41,6 +41,8 @@ const BACKOFF_MAX: i64 = 60 * 60; /// Below this many GraphQL points left, pause for `LOW_BUDGET_PAUSE`. const BUDGET_FLOOR: u64 = 150; const LOW_BUDGET_PAUSE: i64 = 30 * 60;+/// Below this many GraphQL points left, skip requests nobody asked for.+const OPTIONAL_FLOOR: u64 = 1_500; /// Ignore manual refreshes this soon after the previous one. const MANUAL_DEBOUNCE: i64 = 15; @@ -258,6 +260,17 @@ fn fetch_into_snapshot(app: &AppHandle, cfg: &Config, repo: &str) -> Result<(),         .map(|d| RawActivity::load(d, repo))         .unwrap_or_default();     let result = gh.fetch_repo(repo, now, &mut raw);+    // Read before the raw cache is saved, which keeps the runs' ETags.+    let main_ci = match &result {+        Ok((_, budget)) if optional_requests_allowed(budget.graphql_remaining) => {+            Some(read_main_ci(&gh, repo, &mut raw, now))+        }+        _ => None,+    };+    let ci_limited = match &main_ci {+        Some(Err(GhError::RateLimited(until))) => Some(until.clone()),+        _ => None,+    };     if let (Ok(_), Some(dir)) = (&result, &raw_dir) {         if let Err(e) = raw.save(dir, repo) {             eprintln!("{repo}: could not save raw cache: {e}");@@ -300,6 +313,11 @@ fn fetch_into_snapshot(app: &AppHandle, cfg: &Config, repo: &str) -> Result<(),                     }                     None => {}                 }+                match main_ci {+                    Some(Ok(ci)) => entry.main_ci = ci,+                    Some(Err(e)) => eprintln!("{repo}: main CI: {e}"),+                    None => {}+                }                 entry.stats = Some(stats);                 entry.fetched_at = Some(now);                 entry.error = None;@@ -310,6 +328,7 @@ fn fetch_into_snapshot(app: &AppHandle, cfg: &Config, repo: &str) -> Result<(),                 entry.stats = None;                 entry.icon = None;                 entry.icon_oid = None;+                entry.main_ci = None;                 entry.fetched_at = Some(now);                 entry.error = None;                 entry.inaccessible = true;@@ -327,7 +346,12 @@ fn fetch_into_snapshot(app: &AppHandle, cfg: &Config, repo: &str) -> Result<(),             if left.is_some() {                 snap.rate_limit_remaining = left;             }-            Ok(())+            // The repo's numbers are stored. When GitHub rate-limited the CI+            // requests, `step` pauses the next fetch.+            match ci_limited {+                Some(until) => Err(GhError::RateLimited(until)),+                None => Ok(()),+            }         }         Err(GhError::Unauthorized) => {             if let Some(t) = snap.token.as_mut() {@@ -339,6 +363,27 @@ fn fetch_into_snapshot(app: &AppHandle, cfg: &Config, repo: &str) -> Result<(),     } } +/// Whether a fetch that left `left` GraphQL points may send optional+/// requests, such as the workflow runs on the default branch: not below+/// [`OPTIONAL_FLOOR`].+fn optional_requests_allowed(left: Option<u64>) -> bool {+    left.is_none_or(|r| r >= OPTIONAL_FLOOR)+}++/// `repo`'s workflow results on its default branch, read into the runs `raw`+/// keeps; `None` for a repository without a default branch.+fn read_main_ci(+    gh: &GitHub,+    repo: &str,+    raw: &mut RawActivity,+    now: DateTime<Utc>,+) -> Result<Option<MainCi>, GhError> {+    let Some(branch) = raw.default_branch.clone() else {+        return Ok(None);+    };+    gh.main_ci(repo, &branch, &mut raw.http.runs, now).map(Some)+}+ /// When a rate limit GitHub reported (`RateLimited`'s text) lifts. fn rate_limit_end(until: &str, now: DateTime<Utc>) -> Option<DateTime<Utc>> {     if let Ok(t) = DateTime::parse_from_rfc3339(until) {@@ -864,6 +909,14 @@ mod tests {         assert!(rate_limit_end("later", now).is_none());     } +    #[test]+    fn optional_requests_wait_while_the_budget_is_low() {+        assert!(!optional_requests_allowed(Some(OPTIONAL_FLOOR - 1)));+        assert!(!optional_requests_allowed(Some(0)));+        assert!(optional_requests_allowed(Some(OPTIONAL_FLOOR)));+        assert!(optional_requests_allowed(None), "GitHub sent no count");+    }+     #[test]     fn tooltip_reports_staleness() {         let now = Utc::now();diff --git a/ui/src/app.test.ts b/ui/src/app.test.ts--- a/ui/src/app.test.ts+++ b/ui/src/app.test.ts@@ -265,6 +265,54 @@ test('a row’s refresh button refetches only that repo, and spins while it wait   assert.ok(!h.doc.querySelector('.row-refresh.spinning'), 'done'); }); +const ciRun = (id: number, conclusion: string) => ({+  run_id: 100 + id, conclusion, event: 'push', head_sha: 'abc1234def', title: `Commit ${id}`, at: NOW,+  url: `https://github.com/o/alpha/actions/runs/${100 + id}`,+});+const ciWorkflow = (id: number, ...runs: ReturnType<typeof ciRun>[]) => ({ workflow_id: id, name: `W${id}`, path: `.github/workflows/w${id}.yml`, runs });++test('the Main CI column shows failing workflows, links the failed run, explains a dash and adds up failures', async () => {+  const h = await setup({+    snap: snapshot([+      repo('alpha', { urgency: 100 }, {+        main_ci: { branch: 'main', workflows: [ciWorkflow(1, ciRun(1, 'success')), ciWorkflow(2, ciRun(3, 'success'), ciRun(2, 'failure'))], checked_at: NOW },+      }),+      repo('beta', { urgency: 200 }, { main_ci: { branch: 'main', workflows: [ciWorkflow(3, ciRun(4, 'success'))], checked_at: NOW } }),+      repo('gamma', { urgency: 300 }),+    ]),+  });+  const i = COLUMNS.findIndex((c) => c.key === 'main_ci');+  assert.equal(h.doc.querySelectorAll('thead tr.cols th')[i]!.textContent, 'Main CI');+  const td = (name: string) => h.row(name).cells[i]!;+  assert.equal(td('alpha').textContent, '✗ 1 of 2');+  assert.match(td('alpha').querySelector<HTMLElement>('.ci-fail')!.title,+    /\n✗ W2: 1 of 2 runs failed \(push; abc1234\)\n {4}✓ success: Commit 3 \([^)]+\)\n {4}✗ failure: Commit 2 \([^)]+\)\n✓ W1: success/);+  td('alpha').querySelector<HTMLElement>('.link')!.click();+  assert.deepEqual(h.calls.at(-1), { cmd: 'open_url', args: { url: 'https://github.com/o/alpha/actions/runs/102' } });+  assert.equal(h.view(), 'repos', 'the link does not open the urgent view');+  assert.equal(td('beta').textContent, '✓ 1');+  assert.ok(td('beta').querySelector('.ci-ok'));+  assert.equal(td('gamma').textContent, '—');+  assert.match(td('gamma').querySelector<HTMLElement>('.muted')!.title, /not been read yet/);+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha'], 'by urgency');+  header(h, 'Main CI').click();+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma'], 'most failures first, no result last');+  assert.equal(h.doc.querySelector('tfoot tr')!.children[i]!.textContent, '1');+});++test('the Main CI hover gives times rather than ages and does not change as the clock ticks', async (t) => {+  // setup()'s tick() needs the real setTimeout; the 15 s clock runs on the mocked setInterval.+  t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.parse(NOW) + 25_000 });+  const h = await setup({+    snap: snapshot([repo('alpha', { urgency: 100 }, { main_ci: { branch: 'main', workflows: [ciWorkflow(1, ciRun(1, 'failure'))], checked_at: NOW } })]),+  });+  const i = COLUMNS.findIndex((c) => c.key === 'main_ci');+  const title = () => h.row('alpha').cells[i]!.querySelector<HTMLElement>('.ci-fail')!.title;+  const before = title();+  for (let n = 0; n < 8; n++) t.mock.timers.tick(15_000);+  assert.equal(title(), before, 'two minutes of clock ticks');+});+ test('the window picker switches the activity columns and remembers', async () => {   const h = await setup({ snap: snapshot([repo('alpha', { recent: { '7d': { commits: 7, prs_opened: 0, prs_merged: 0, issues_opened: 0, people: 0 } } })]) });   const commits = () => h.row('alpha').cells[COLUMNS.findIndex((c) => c.key === 'recent_commits')]!.textContent;diff --git a/ui/src/app.ts b/ui/src/app.ts--- a/ui/src/app.ts+++ b/ui/src/app.ts@@ -3,7 +3,7 @@  import {   COLUMNS, RECENT, WINDOWS, MANUAL, windowById, value, orderedEntries, nextSort, dropIndex,-  freshness, ago, totals, urgentAcross, tokenStatus, isRefreshing,+  freshness, ago, totals, urgentAcross, tokenStatus, isRefreshing, mainCiFailing, mainCiText, mainCiTip, mainCiUrl, } from './model.ts'; import type { Column, Meta, RepoEntry, Snapshot, Sort } from './model.ts'; @@ -122,6 +122,13 @@ export function start(api: Api): () => void {       const age = v ? el('span', { title: `Fetched ${localTime(String(v))}` }, ago(String(v))) : el('span', { class: 'muted', title: 'Not fetched yet' }, '—');       return el('span', { class: 'fetched' }, age, rowRefresh(entry));     }+    if (col.key === 'main_ci' && !entry.inaccessible) {+      const ci = entry.main_ci;+      const failing = mainCiFailing(ci);+      const title = mainCiTip(ci, localTime);+      if (failing === null) return el('span', { class: 'muted', title }, mainCiText(ci));+      return el('span', { class: failing ? 'ci-fail' : 'ci-ok', title }, link(mainCiText(ci), mainCiUrl(entry.repo, ci)));+    }     if (!s) return entry.inaccessible ? document.createTextNode('') : el('span', { class: 'muted' }, '—');     switch (col.key) {       case 'urgency': {diff --git a/ui/src/model.test.ts b/ui/src/model.test.ts--- a/ui/src/model.test.ts+++ b/ui/src/model.test.ts@@ -3,8 +3,9 @@ import assert from 'node:assert/strict'; import {   sortEntries, manualOrder, orderedEntries, nextSort, dropIndex, MANUAL, versionKey, freshness, isStale, ago, compact, tokenStatus,   totals, urgentAcross, isRefreshing, defaultDir, value, windowById, WINDOWS, COLUMNS, COLUMN,+  mainCiFailing, mainCiText, mainCiTip, mainCiUrl, } from './model.ts';-import type { Activity, RepoEntry, RepoStats, UrgentIssue } from './model.ts';+import type { Activity, MainCi, MainRun, MainWorkflow, RepoEntry, RepoStats, UrgentIssue } from './model.ts';  const entry = (repo: string, stats: Partial<RepoStats> | null, fetched_at: string | null = '2026-10-08T12:00:00Z'): RepoEntry =>   ({ repo: `o/${repo}`, stats: stats as RepoStats | null, fetched_at });@@ -199,6 +200,56 @@ test('recent-activity columns follow the selected window', () => {   assert.deepEqual(WINDOWS.map((w) => w.id), ['10m', '30m', '1h', '4h', '12h', '1d', '7d', 'release', 'all']); }); +const mrun = (id: number, conclusion: string, r: Partial<MainRun> = {}): MainRun => ({+  run_id: 100 + id, conclusion, event: 'push', head_sha: `abcdef${id}0`, title: `Commit ${id}`, at: '2026-10-08T10:00:00Z',+  url: `https://github.com/o/a/actions/runs/${100 + id}`, ...r,+});+const wf = (id: number, ...runs: MainRun[]): MainWorkflow => ({ workflow_id: id, name: `W${id}`, path: `.github/workflows/w${id}.yml`, runs });+const ci = (workflows: MainWorkflow[]): MainCi => ({ branch: 'main', workflows, checked_at: '2026-10-08T12:00:00Z' });++test('the Main CI column counts the workflows whose latest result failed, sorts them first and adds them up', () => {+  assert.equal(mainCiFailing(null), null);+  assert.equal(mainCiFailing(ci([])), null, 'no result is not a pass');+  assert.equal(mainCiText(undefined), '—');+  assert.equal(mainCiText(ci([wf(1, mrun(1, 'success')), wf(2, mrun(2, 'neutral'))])), '✓ 2');+  const red = ci([wf(1, mrun(1, 'success')), wf(2, mrun(2, 'timed_out')), wf(3, mrun(3, 'startup_failure', { event: 'schedule' }))]);+  assert.equal(mainCiText(red), '✗ 2 of 3');+  assert.equal(mainCiUrl('o/a', red), 'https://github.com/o/a/actions/runs/102');+  assert.equal(mainCiUrl('o/a', { ...ci([wf(1, mrun(1, 'success'))]), branch: 'trunk' }), 'https://github.com/o/a/actions?query=branch%3Atrunk');+  const withCi = (name: string, c: MainCi | null): RepoEntry => ({ ...entry(name, { urgency: 1 }), main_ci: c });+  const list = [withCi('green', ci([wf(1, mrun(1, 'success'))])), withCi('none', null), withCi('red', red), withCi('empty', ci([])), entry('old', { urgency: 1 })];+  assert.equal(defaultDir('main_ci'), 'desc');+  assert.deepEqual(sortEntries(list, 'main_ci', 'desc').map((e) => e.repo), ['o/red', 'o/green', 'o/empty', 'o/none', 'o/old'],+    'most failures first; no result, or a snapshot from before the column, last');+  assert.equal(value(list[2]!, 'main_ci'), 2);+  assert.equal(totals(list).main_ci, 2);+  assert.equal(COLUMNS.at(-1)!.key, 'main_ci', 'at the right end of the table');+});++test('the Main CI hover lists each workflow, failures first, and each run of one that ran twice on a commit', () => {+  const at = (iso: string) => `at ${iso.slice(11, 16)}`;+  const dependabot: MainWorkflow = {+    workflow_id: 9, name: 'Dependabot Updates', path: 'dynamic/dependabot/dependabot-updates', runs: [+      mrun(9, 'success', { event: 'dynamic', head_sha: '0d34a58bee', title: 'github_actions in /. - Update #2', at: '2026-10-08T10:32:00Z' }),+      mrun(8, 'failure', { event: 'dynamic', head_sha: '0d34a58bee', title: 'cargo in /. - Update #1', at: '2026-10-08T10:39:00Z' }),+    ],+  };+  const both = ci([wf(1, mrun(1, 'success')), dependabot]);+  assert.equal(mainCiText(both), '✗ 1 of 2', 'one failed run fails its workflow');+  assert.deepEqual(mainCiTip(both, at).split('\n'), [+    'Latest result of each workflow on main; canceled and skipped runs are left out.',+    '✗ Dependabot Updates: 1 of 2 runs failed (dynamic; 0d34a58)',+    '    ✓ success: github_actions in /. - Update #2 (at 10:32)',+    '    ✗ failure: cargo in /. - Update #1 (at 10:39)',+    '✓ W1: success (push; at 10:00; abcdef1)',+    'Checked at 12:00.',+  ]);+  assert.equal(mainCiUrl('o/a', both), 'https://github.com/o/a/actions/runs/108', 'the failed run');+  assert.equal(mainCiTip(ci([wf(2, mrun(2, 'timed_out', { at: null }))]), at).split('\n')[1], '✗ W2: timed out (push; time unknown; abcdef2)');+  assert.match(mainCiTip(ci([]), at), /No completed workflow run on main in the last 30 days/);+  assert.match(mainCiTip(null, at), /not been read yet/);+});+ test('a row is refreshing while its repo is being fetched or queued', () => {   assert.equal(isRefreshing({ fetching: 'o/a', requested: ['o/b'] }, 'o/a'), true);   assert.equal(isRefreshing({ fetching: 'o/a', requested: ['o/b'] }, 'o/b'), true);diff --git a/ui/src/model.ts b/ui/src/model.ts--- a/ui/src/model.ts+++ b/ui/src/model.ts@@ -65,6 +65,46 @@ export interface RepoEntry {   inaccessible?: boolean;   icon?: string | null;   icon_oid?: string | null;+  /** The latest result of each workflow on the default branch; null (or absent, from an older build) until read. */+  main_ci?: MainCi | null;+}++/** The latest result of each GitHub Actions workflow on a repo's default branch: `MainCi` in model.rs. */+export interface MainCi {+  branch: string;+  /** One per active workflow with a result, by name. */+  workflows: MainWorkflow[];+  /** When GitHub last answered, an unchanged answer included. */+  checked_at: Time | null;+}++/**+ * One workflow's latest result: its newest completed run on the default branch+ * that was not canceled or skipped, with its other such runs on the same+ * commit and event created within 60 s of it (`MainWorkflow` in model.rs).+ */+export interface MainWorkflow {+  workflow_id: number;+  /** The workflow's own name, such as `CI` or `Dependabot Updates`. */+  name: string;+  path: string;+  /** Newest first. */+  runs: MainRun[];+}++/** A completed workflow run on the default branch: `MainRun` in model.rs. */+export interface MainRun {+  run_id: number;+  /** success, failure, timed_out, startup_failure, action_required, neutral or stale. */+  conclusion: string;+  /** push, schedule, workflow_dispatch… */+  event: string;+  head_sha: string;+  /** The run's title, such as the commit message or a Dependabot job. */+  title: string;+  /** When the run finished. */+  at: Time | null;+  url: string; }  export interface Snapshot {@@ -180,6 +220,7 @@ export const COLUMNS: readonly Column[] = [   { key: 'commits_since_build', label: 'Unreleased', kind: 'num', group: 'Builds', title: 'Commits on main since the most recent release (~ means estimated: the release’s commit isn’t on main)', get: (e) => e.stats?.since_build?.commits ?? null },   { key: 'latest_build_downloads', label: 'Build DLs', kind: 'num', group: 'Builds', title: 'Downloads of the most recent release, all OSes and package types (checksums and update deltas excluded)' },   { key: 'downloads_total', label: 'Total DLs', kind: 'num', group: 'Builds', title: 'Downloads across every published release' },+  { key: 'main_ci', label: 'Main CI', kind: 'num', group: 'CI', title: 'Workflows whose latest result on the default branch failed, out of those with a result in the last 30 days (canceled and skipped runs are left out; scheduled runs count)', get: (e) => mainCiFailing(e.main_ci) }, ];  export const COLUMN: Readonly<Record<string, Column>> = Object.fromEntries(COLUMNS.map((c) => [c.key, c]));@@ -281,6 +322,65 @@ export function dropIndex(mids: readonly number[], y: number): number {   return i === -1 ? mids.length : i; } +// ───────────────────────────── main CI ─────────────────────────────++/** Whether a run failed: `failure`, `timed_out` or `startup_failure`. */+export const runFailed = (r: MainRun): boolean =>+  r.conclusion === 'failure' || r.conclusion === 'timed_out' || r.conclusion === 'startup_failure';++/** Whether a workflow's latest result failed: any of its runs did. */+export const workflowFailed = (w: MainWorkflow): boolean => w.runs.some(runFailed);++/** How many workflows' latest results failed; null with no result to count. */+export function mainCiFailing(ci: MainCi | null | undefined): number | null {+  return ci?.workflows.length ? ci.workflows.filter(workflowFailed).length : null;+}++/** The Main CI cell: `✓ 4` when no workflow failed, `✗ 2 of 4` when some did, `—` with no result. */+export function mainCiText(ci: MainCi | null | undefined): string {+  const failing = mainCiFailing(ci);+  if (failing === null || !ci) return '—';+  const n = ci.workflows.length;+  return failing ? `✗ ${failing} of ${n}` : `✓ ${n}`;+}++const conclusionText = (r: MainRun): string => r.conclusion.replaceAll('_', ' ');++/**+ * The Main CI hover: one line per workflow, failures first, with what started+ * its run, when it finished and its commit; a workflow with several runs on+ * that commit lists each. `time` writes each time as a date.+ */+export function mainCiTip(ci: MainCi | null | undefined, time: (iso: Time) => string): string {+  if (!ci) return 'The workflow runs on the default branch have not been read yet.';+  const branch = ci.branch || 'main';+  if (!ci.workflows.length) return `No completed workflow run on ${branch} in the last 30 days, canceled and skipped runs left out.`;+  const when = (r: MainRun) => (r.at ? time(r.at) : 'time unknown');+  const mark = (failed: boolean) => (failed ? '✗' : '✓');+  const workflows = [...ci.workflows].sort((a, b) => Number(workflowFailed(b)) - Number(workflowFailed(a)));+  const lines = workflows.flatMap((w) => {+    const newest = w.runs[0];+    if (!newest) return [];+    const head = `${mark(workflowFailed(w))} ${w.name || w.path}`;+    const sha = newest.head_sha.slice(0, 7);+    if (w.runs.length === 1) return [`${head}: ${conclusionText(newest)} (${newest.event}; ${when(newest)}; ${sha})`];+    const failed = w.runs.filter(runFailed).length;+    const summary = failed ? `${failed} of ${w.runs.length} runs failed` : `${w.runs.length} runs, none failed`;+    return [`${head}: ${summary} (${newest.event}; ${sha})`,+      ...w.runs.map((r) => `    ${mark(runFailed(r))} ${conclusionText(r)}: ${r.title} (${when(r)})`)];+  });+  return [`Latest result of each workflow on ${branch}; canceled and skipped runs are left out.`, ...lines,+    ci.checked_at ? `Checked ${time(ci.checked_at)}.` : ''].filter(Boolean).join('\n');+}++/** Where the Main CI cell links: the first failed run, otherwise the repo's runs on the default branch. */+export function mainCiUrl(repo: string, ci: MainCi | null | undefined): string {+  const failed = ci?.workflows.flatMap((w) => w.runs).find(runFailed);+  if (failed?.url) return failed.url;+  const query = encodeURIComponent(`branch:${ci?.branch || 'main'}`);+  return `https://github.com/${repo}/actions?query=${query}`;+}+ // ───────────────────────────── freshness ─────────────────────────────  export function isStale(entry: RepoEntry, staleAfterSecs: number, now: number = Date.now()): boolean {diff --git a/ui/style.css b/ui/style.css--- a/ui/style.css+++ b/ui/style.css@@ -117,6 +117,8 @@ tr.stale td:not(:first-child) { opacity: .55; } .link:hover { text-decoration: underline; color: var(--accent); } .urgency { display: inline-block; min-width: 40px; padding: 0 6px; border-radius: 4px; font-weight: 600; } .crit { color: var(--bad); font-weight: 700; }+.ci-ok { color: var(--good); }+.ci-fail { color: var(--bad); font-weight: 600; } .pre { font-size: 9px; color: var(--warn); margin-left: 3px; vertical-align: 1px; } .old { color: var(--warn); } tfoot td { color: var(--muted); font-weight: 600; border-bottom: 0; position: sticky; bottom: 0; background: rgb(var(--bg)); box-shadow: inset 0 1px 0 var(--border); }