diff --git a/docs/digest.md b/docs/digest.md index e8383ab..b1ea66c 100644 --- a/docs/digest.md +++ b/docs/digest.md @@ -158,6 +158,27 @@ pass checks the base's CI on what landed, until it can say how it went: the watchman's [declared]. Merged: DSM-214 (batch 54), DSM-209", naming the batch each landed in where the watchman recorded one (`rally batch`). The line waits for a digest that's going anyway, and isn't lost if none goes that pass. +- **A story with no evidence when its merge is taken in** (claimed again, in parts, or merged before its closing + submit, as `dsm-record-merges` records every DiscoSim car) takes its repository from its prefix's project in the + projects list, and its line says so: "(its repository from the projects list [declared])" (RAL-180). Only a + story whose prefix no project has still says "no repository in the evidence". +- **A project gated on this machine** (RAL-180): `rally digest --gate DSM=~/.local/state/dsm-gates,~/Documents/GitHub/Discoteque` + (repeatable, with `,running=` for how long a gate may run, 2h by default) names the gate's logs and + the project's checkout. Its merges are told from them, never from GitHub, one line for each gated tip, naming + the stories it holds, and each line says what was read [observed]: + - **green** at the earliest tip T that holds the merge, has its `.ok`, has a `.log` naming T with no + failure, and is on the checkout's `origin/main`. A train that failed and was rebuilt with the same commit, or + one gating on top, doesn't turn a landed, gated merge red. Told once, held until a digest carries it. + - **green at T, but T isn't on origin/main**: a tip that never landed vouches for nothing on main. Urgent. + - **RED at T**, with the failures: no green tip holds it, and the newest that does failed, or has had no `.ok` + for longer than the running limit. Urgent. + - **gate running at T**: within the limit. Checked again each pass. + - **no gate log**: none of the newest 200 logs holds it; it landed without a gate. Urgent. + - **can't be told yet**: the checkout hasn't fetched the merge, or has no `origin/main`. Told once, tried again, + and given up after 2 hours. + Paths are said as given. The flag needs no projects-list change and no service restart: the digest loop is the + CLI, and `--gate` is kept in its pid file, so `rally digest --restart --gate …` adds it to the running loop's + settings. What's pending is kept in the state file beside each watchman's head (`ci`, `ci_checked_to`), so each merge is taken in once. Something urgent that couldn't be sent is told again on the next pass. It needs `gh` signed in on diff --git a/docs/roadmap.md b/docs/roadmap.md index 101fa59..f082119 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1405,6 +1405,14 @@ principles named were durable, accurate and reactive. Found in rally 0x7 by plus roadmap with the default driver and named it a conflict resolved, where a checkout with the attributes named none. Done when: each merge is remerged with its first parent's `.gitattributes` (`--attr-source`), the output says which attributes it used, and a test checks a checkout without the attributes gets the head's answer. +- [ ] **RAL-180: The digest tells a locally gated project's merges from its gate, observed.** From the watchman + (2026-11-14): every DiscoSim landing showed as "the base's CI couldn't be checked: no repository in the + evidence", five lines a train, noise that would hide a real red. Done when: a merge whose story has no evidence + takes its repository from its prefix's project, said declared; `rally digest --gate =,` + tells each of the project's merges from the gate's `.log`/`.ok` and git, one line a tip, observed: + green at the earliest green tip that landed (a red or running tip beside it doesn't turn it red), green but + not on main, red, running (limit in the flag, 2h default) or no gate log; paths as given; kept by `--restart`; + no projects.json change, no service restart; with tests (docs/digest.md). - [ ] **RAL-179: A Chrome segfault at start gets the one stated retry; a crash at teardown is said.** From the lamplighter's third crash on Murph (2026-11-14 05:29:59, SIGSEGV in `/app/extra/chrome` through zypak's libsystemd bus call, during ci-local's check of 90de3d3, which passed and logged nothing of it), assigned by the watchman. diff --git a/src/digest.rs b/src/digest.rs index 3ee9121..235cc2f 100644 --- a/src/digest.rs +++ b/src/digest.rs @@ -76,6 +76,10 @@ pub struct CiPending { /// Whether a failure to check it has been told already, so it's said once and then retried. #[serde(default)] pub error_told: bool, + /// Whether `repo` came from the projects list, by the story's prefix, because the story had + /// no evidence when its merge was taken in (RAL-180): declared, and said so. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub repo_from_projects: bool, } /// What the base's CI says about a commit that landed (RAL-102). @@ -137,6 +141,20 @@ pub fn ci_from_runs(runs: &Value) -> Ci { /// since a red base matters whoever merged (RAL-102). `stories` is `/v1/stories`, for each /// story's repository. pub fn take_in_merges(kept: &mut Kept, recs: &[Rec], stories: &Value, now_ms: i64) { + take_in_merges_with(kept, recs, stories, now_ms, &[]); +} + +/// [`take_in_merges`], with each project's prefix and repository (RAL-180): a story claimed when +/// its merge is recorded (in parts, claimed again, or merged before its closing submit) has no +/// evidence in the story list, so its repository is taken from its prefix's project instead of +/// being "no repository in the evidence". +pub fn take_in_merges_with( + kept: &mut Kept, + recs: &[Rec], + stories: &Value, + now_ms: i64, + projects: &[(String, String)], +) { let after = kept.ci_checked_to; for rec in recs .iter() @@ -149,14 +167,30 @@ pub fn take_in_merges(kept: &mut Kept, recs: &[Rec], stories: &Value, now_ms: i6 .as_str() .or_else(|| row["part_evidence"]["text"].as_str()) .unwrap_or(""); + let from_evidence = crate::evidence::parse(evidence).ok().map(|(repo, _)| repo); + let from_projects = || { + let prefix = story.split_once('-')?.0; + projects + .iter() + .find(|(p, _)| p.eq_ignore_ascii_case(prefix)) + .map(|(_, repo)| repo.clone()) + }; + let (repo, repo_from_projects) = match from_evidence { + Some(repo) => (Some(repo), false), + None => match from_projects() { + Some(repo) => (Some(repo), true), + None => (None, false), + }, + }; kept.ci.push(CiPending { seq: rec.seq, - repo: crate::evidence::parse(evidence).ok().map(|(repo, _)| repo), + repo, story, commit: rec.text("commit"), landed: None, since_ms: now_ms, error_told: false, + repo_from_projects, }); kept.ci_checked_to = kept.ci_checked_to.max(rec.seq); } @@ -203,10 +237,175 @@ pub fn check_ci_with( local: &[LocalGate], stories: &Value, ) -> CiReport { + check_ci_gated(kept, landed, ci, now_ms, local, stories, &[], &|_, _| { + crate::gate_check::Verdict::CantTell("no gate".into()) + }) +} + +/// What a gate on this machine says about one merged commit (RAL-180), for tests to stand in for. +pub type GateRead<'a> = &'a dyn Fn(&crate::gate_check::Gate, &str) -> crate::gate_check::Verdict; + +/// [`check_ci_with`], with the projects gated on this machine (`rally digest --gate`, RAL-180): +/// each of their merges is looked for in the gate's own logs and the project's checkout, not on +/// GitHub, and said, observed, as green, red, running or no gate log, one line for each tip. +#[allow(clippy::too_many_arguments)] +pub fn check_ci_gated( + kept: &mut Kept, + landed: Landed, + ci: CiRead, + now_ms: i64, + local: &[LocalGate], + stories: &Value, + gates: &[crate::gate_check::Gate], + gate_read: GateRead, +) -> CiReport { + use crate::gate_check::Verdict; let mut report = CiReport::default(); let mut still = Vec::new(); let mut gated: std::collections::BTreeMap> = Default::default(); + // Each tip's line, in the order first met: its words, the stories it holds, and whether it's + // urgent; keyed by what's said and the tip, so a train's cars share one line. + let mut by_tip: Vec<(String, String, Vec, bool)> = Vec::new(); + let mut tell = |key: String, words: String, story: &str, urgent: bool| { + let story = clean(story); + match by_tip.iter_mut().find(|(k, ..)| *k == key) { + Some((_, _, stories, _)) => { + if !stories.contains(&story) { + stories.push(story); + } + } + None => by_tip.push((key, words, vec![story], urgent)), + } + }; for mut pending in std::mem::take(&mut kept.ci) { + if let Some(gate) = gates.iter().find(|g| g.covers(&pending.story)) { + let tip7 = |tip: &str| short(tip); + match gate_read(gate, &pending.commit) { + Verdict::Green { + tip, + on_main: true, + summary, + } => { + report.held = true; + tell( + format!("green {tip}"), + format!( + "landed in {t}: gated locally, green at {t} [observed: {log} names \ + {t} ({summary}), its .ok is there, and git shows {t} on \ + origin/main in {checkout} and holding each]", + t = tip7(&tip), + log = gate.log_shown(&tip), + checkout = gate.checkout_given, + ), + &pending.story, + false, + ); + } + Verdict::Green { + tip, + on_main: false, + .. + } => { + report.urgent = true; + tell( + format!("off-main {tip}"), + format!( + "gated green at {t}, but {t} isn't on origin/main [observed: {log}, \ + its .ok, git in {checkout}]: a tip that never landed vouches for \ + nothing on main", + t = tip7(&tip), + log = gate.log_shown(&tip), + checkout = gate.checkout_given, + ), + &pending.story, + true, + ); + } + Verdict::Red { tip, why } => { + report.urgent = true; + tell( + format!("red {tip}"), + format!( + "gated locally, RED at {t}: {why} [observed: {log}, no green gated \ + tip holds it, by git in {checkout}]", + t = tip7(&tip), + why = clean(&why), + log = gate.log_shown(&tip), + checkout = gate.checkout_given, + ), + &pending.story, + true, + ); + } + Verdict::Running { tip, age_ms } => { + tell( + format!("running {tip}"), + format!( + "gate running at {t} for {m} min [observed: {log}, no .ok yet]; \ + checked again each pass", + t = tip7(&tip), + m = age_ms / 60_000, + log = gate.log_shown(&tip), + ), + &pending.story, + false, + ); + still.push(pending); + } + Verdict::NoLog { read } => { + report.urgent = true; + tell( + format!("none {}", pending.commit), + format!( + "merged at {c}: no gate log for {c} [observed: none of the newest \ + {read} logs in {logs} holds it, by git in {checkout}]: it landed \ + without a gate", + c = short(&pending.commit), + logs = gate.logs_given, + checkout = gate.checkout_given, + ), + &pending.story, + true, + ); + } + Verdict::CantTell(why) => { + let waited = now_ms - pending.since_ms; + if waited >= CI_GIVE_UP_MS { + report.urgent = true; + tell( + format!("gave-up {}", pending.commit), + format!( + "merged at {}: its gate still couldn't be told after {}: {} \ + [observed]; no longer checked", + short(&pending.commit), + age(pending.since_ms, now_ms).trim_end_matches(" ago"), + clean(&why) + ), + &pending.story, + true, + ); + } else { + if !pending.error_told { + report.urgent = true; + pending.error_told = true; + tell( + format!("cant {}", pending.commit), + format!( + "merged at {}: its gate can't be told yet: {} [observed]; \ + tried again each pass", + short(&pending.commit), + clean(&why) + ), + &pending.story, + true, + ); + } + still.push(pending); + } + } + } + continue; + } if let Some(index) = pending .repo .as_deref() @@ -220,9 +419,14 @@ pub fn check_ci_with( continue; } let head = format!( - "- {} merged at {}", + "- {} merged at {}{}", clean(&pending.story), - short(&pending.commit) + short(&pending.commit), + if pending.repo_from_projects { + " (its repository from the projects list [declared])" + } else { + "" + } ); let waited = now_ms - pending.since_ms; let Some(repo) = pending.repo.clone() else { @@ -300,6 +504,12 @@ pub fn check_ci_with( } } kept.ci = still; + // One line for each gated tip, naming the stories it holds (RAL-180). + for (_, words, stories, _) in by_tip { + report + .lines + .push(format!("- {}: {words}", stories.join(", "))); + } // One honest line for each project whose gate is local, not one per merge (RAL-161). for (index, merged) in gated { let gate = &local[index]; @@ -1250,6 +1460,131 @@ mod tests { }}) } + /// RAL-180: a story with no evidence when its merge is taken in (claimed, in parts, merged + /// before its closing submit) takes its repository from its prefix's project, said declared; + /// one with no project still says "no repository in the evidence". + #[test] + fn a_merge_without_evidence_takes_its_projects_repository() { + let mut kept = start_at(0); + let stories = json!({"stories": {"DSM-5": {"state": "claimed"}, "ZZZ-1": {}}}); + let projects = [("DSM".to_owned(), "Castor-o7/DiscoSim".to_owned())]; + take_in_merges_with( + &mut kept, + &[ + merge_rec(1, "w", "DSM-5", &"e".repeat(40)), + merge_rec(2, "w", "ZZZ-1", &"f".repeat(40)), + ], + &stories, + 0, + &projects, + ); + assert_eq!(kept.ci[0].repo.as_deref(), Some("Castor-o7/DiscoSim")); + assert!(kept.ci[0].repo_from_projects); + assert_eq!(kept.ci[1].repo, None); + let landed = |_: &str, _: &str| -> Result, String> { Ok(None) }; + let failed = |_: &str, _: &str| -> Result { + Ok(Ci::Failed { + workflow: "CI".into(), + url: "u".into(), + }) + }; + let report = check_ci(&mut kept, &landed, &failed, 1); + assert!( + report.lines[0].starts_with( + "- DSM-5 merged at eeeeeee (its repository from the projects list [declared]): \ + left the base red" + ), + "{report:?}" + ); + assert!( + report.lines[1].contains("no repository in the evidence"), + "{report:?}" + ); + } + + /// RAL-180: a gated project's merges are told from the gate, one line a tip, each saying what + /// was read; green holds the line until it's sent, running is kept, the rest are urgent. + #[test] + fn a_gated_projects_merges_are_told_one_line_a_tip() { + use crate::gate_check::{Gate, Verdict}; + let gate = + Gate::parse("DSM=~/.local/state/dsm-gates,~/Documents/GitHub/Discoteque").unwrap(); + let tip = "1".repeat(40); + let mut kept = start_at(0); + let stories = json!({"stories": {}}); + let commits = ["a", "b", "c", "d", "e", "f"].map(|c| c.repeat(40)); + let recs: Vec = ["DSM-1", "DSM-2", "DSM-3", "DSM-4", "DSM-5", "DSM-6"] + .iter() + .zip(&commits) + .enumerate() + .map(|(i, (story, commit))| merge_rec(i as u64 + 1, "w", story, commit)) + .collect(); + take_in_merges(&mut kept, &recs, &stories, 0); + let read = |_: &Gate, commit: &str| match &commit[..1] { + "a" | "b" => Verdict::Green { + tip: tip.clone(), + on_main: true, + summary: "QUICK OK, 3 scenes, 0 failed".into(), + }, + "c" => Verdict::Running { + tip: "2".repeat(40), + age_ms: 50 * 60_000, + }, + "d" => Verdict::Red { + tip: "3".repeat(40), + why: "2 failed".into(), + }, + "e" => Verdict::NoLog { read: 200 }, + _ => Verdict::Green { + tip: "4".repeat(40), + on_main: false, + summary: String::new(), + }, + }; + let github = |_: &str, _: &str| -> Result { + panic!("a gated merge isn't looked for on GitHub") + }; + let landed = |_: &str, _: &str| -> Result, String> { Ok(None) }; + let report = check_ci_gated( + &mut kept, + &landed, + &github, + 1, + &[], + &stories, + &[gate], + &read, + ); + assert_eq!( + report.lines, + [ + "- DSM-1, DSM-2: landed in 1111111: gated locally, green at 1111111 [observed: \ + ~/.local/state/dsm-gates/1111111111111111111111111111111111111111.log names \ + 1111111 (QUICK OK, 3 scenes, 0 failed), its .ok is there, and git shows 1111111 \ + on origin/main in ~/Documents/GitHub/Discoteque and holding each]", + "- DSM-3: gate running at 2222222 for 50 min [observed: \ + ~/.local/state/dsm-gates/2222222222222222222222222222222222222222.log, no .ok \ + yet]; checked again each pass", + "- DSM-4: gated locally, RED at 3333333: 2 failed [observed: \ + ~/.local/state/dsm-gates/3333333333333333333333333333333333333333.log, no green \ + gated tip holds it, by git in ~/Documents/GitHub/Discoteque]", + "- DSM-5: merged at eeeeeee: no gate log for eeeeeee [observed: none of the newest \ + 200 logs in ~/.local/state/dsm-gates holds it, by git in \ + ~/Documents/GitHub/Discoteque]: it landed without a gate", + "- DSM-6: gated green at 4444444, but 4444444 isn't on origin/main [observed: \ + ~/.local/state/dsm-gates/4444444444444444444444444444444444444444.log, its .ok, \ + git in ~/Documents/GitHub/Discoteque]: a tip that never landed vouches for \ + nothing on main", + ] + ); + assert!(report.urgent && report.held); + // Only the running one is kept, to be checked again. + assert_eq!( + kept.ci.iter().map(|p| p.story.as_str()).collect::>(), + ["DSM-3"] + ); + } + /// RAL-102: only push runs count, and a failure wins over one still going. #[test] fn ci_runs_read_as_failed_running_passed_or_none() { diff --git a/src/digest_loop.rs b/src/digest_loop.rs index 9d2fb2a..ad27f46 100644 --- a/src/digest_loop.rs +++ b/src/digest_loop.rs @@ -23,6 +23,9 @@ pub struct Settings { pub watchman: Option, pub state_file: PathBuf, pub config: PathBuf, + /// Each `--gate` as given (RAL-180), so a restart passes it again with its paths unexpanded. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gates: Vec, } impl Settings { @@ -47,6 +50,9 @@ impl Settings { if let Some(watchman) = &self.watchman { args.extend(["--watchman".to_owned(), watchman.clone()]); } + for gate in &self.gates { + args.extend(["--gate".to_owned(), gate.clone()]); + } args } } @@ -333,6 +339,7 @@ mod tests { watchman: Some("mellow-weevil".into()), state_file: dir.join("digest.json"), config: dir.join("config.json"), + gates: vec!["DSM=~/.local/state/dsm-gates,~/Documents/GitHub/Discoteque".into()], } } @@ -355,8 +362,15 @@ mod tests { assert_eq!( args.join(" "), "--config /d/config.json digest --quiet 120s --max 600s --every 30s --state-file \ - /d/digest.json --pid-file /d/digest.pid --watchman mellow-weevil" + /d/digest.json --pid-file /d/digest.pid --watchman mellow-weevil --gate \ + DSM=~/.local/state/dsm-gates,~/Documents/GitHub/Discoteque" ); + // A pid file from before RAL-180 has no gates, and still reads. + let old: Settings = serde_json::from_str( + r#"{"quiet_ms":1,"max_ms":2,"every_ms":3,"state_file":"/s","config":"/c"}"#, + ) + .unwrap(); + assert!(old.gates.is_empty()); } /// A file naming a process that isn't a digest, or a dead one, holds nothing; one naming this diff --git a/src/gate_check.rs b/src/gate_check.rs new file mode 100644 index 0000000..0203ec2 --- /dev/null +++ b/src/gate_check.rs @@ -0,0 +1,491 @@ +//! A project gated on this machine, for the digest's "Base CI after merges" section (RAL-180). +//! +//! DiscoSim has no CI on GitHub: its trains are gated locally by `dsm-gate`, which writes +//! `/.log` (its first line names the tip and tree) and, only on green, `.ok`. Every +//! DiscoSim landing the watchman recorded showed in the digest as "the base's CI couldn't be +//! checked", five lines a train, noise that would hide a real red. With `rally digest --gate +//! DSM=,`, each merged commit C of the project is looked for in the gate's own +//! files and the project's git checkout instead, and everything said is observed from them: +//! +//! - **green**: the earliest gated tip T that contains C, has its `.ok`, and whose log names T +//! with no failure, and T is on the checkout's `origin/main`. A train that failed and was rebuilt +//! with the same commit, or a stacked train still gating on top, can't turn a landed, gated +//! merge red, since any green tip that landed wins. +//! - **green, not on main**: a green tip contains C, but none of them landed, so it vouches for +//! nothing on main: said as its own line. +//! - **red**, **running**: no green tip contains C; the newest that does has failures, or no +//! `.ok` after the running limit (red), or under it (running). +//! - **no gate log**: no gated tip contains C: it landed without a gate. +//! - **not in the checkout**: the checkout hasn't fetched C, so it can't be told yet. +//! +//! Paths are said as given (`~/…`), never expanded, and nothing here writes anything. + +use std::path::{Path, PathBuf}; + +use crate::gate_log::GateLog; + +/// The most gate logs read, newest first, for each commit. +pub const MAX_LOGS: usize = 200; +/// How long a gate can run, by default, before a tip with a log and no `.ok` counts as red +/// (tonight's gates ran 50 to 65 minutes under load). +pub const DEFAULT_RUNNING_MS: u64 = 2 * 3_600_000; + +/// One `--gate` value: `=,[,running=]`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Gate { + /// The flag as given, kept so a restart passes it again unchanged. + pub given: String, + pub prefix: String, + /// The logs directory and checkout, as given (for saying) and expanded (for reading). + pub logs_given: String, + pub logs: PathBuf, + pub checkout_given: String, + pub checkout: PathBuf, + pub running_ms: u64, +} + +fn expand(path: &str) -> PathBuf { + match path.strip_prefix("~/") { + Some(rest) => std::env::var_os("HOME") + .map(|home| PathBuf::from(home).join(rest)) + .unwrap_or_else(|| PathBuf::from(path)), + None => PathBuf::from(path), + } +} + +/// A window such as `90m`, `2h` or `3600s`, in milliseconds. +fn window(text: &str) -> Option { + let (number, unit) = text.split_at(text.find(|c: char| !c.is_ascii_digit())?); + let n: u64 = number.parse().ok().filter(|n| *n > 0)?; + Some(match unit { + "s" => n * 1000, + "m" => n * 60_000, + "h" => n * 3_600_000, + _ => return None, + }) +} + +impl Gate { + /// Reads a `--gate` value. + pub fn parse(given: &str) -> Result { + let usage = "--gate takes =,[,running=], such as \ + DSM=~/.local/state/dsm-gates,~/Documents/GitHub/Discoteque"; + let (prefix, rest) = given.split_once('=').ok_or(usage)?; + let parts: Vec<&str> = rest.split(',').map(str::trim).collect(); + let (logs, checkout) = match parts.as_slice() { + [logs, checkout, ..] if !logs.is_empty() && !checkout.is_empty() => (*logs, *checkout), + _ => return Err(usage.to_owned()), + }; + let mut running_ms = DEFAULT_RUNNING_MS; + for extra in &parts[2..] { + match extra.strip_prefix("running=").and_then(window) { + Some(ms) => running_ms = ms, + None => return Err(format!("{usage}: `{extra}` isn't running=")), + } + } + let prefix = prefix.trim().to_ascii_uppercase(); + if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(usage.to_owned()); + } + Ok(Self { + given: given.to_owned(), + prefix, + logs_given: logs.to_owned(), + logs: expand(logs), + checkout_given: checkout.to_owned(), + checkout: expand(checkout), + running_ms, + }) + } + + /// Whether `story` is this project's, by its prefix. + pub fn covers(&self, story: &str) -> bool { + story + .split_once('-') + .is_some_and(|(prefix, _)| prefix.eq_ignore_ascii_case(&self.prefix)) + } + + /// A tip's log, as given, for saying. + pub fn log_shown(&self, tip: &str) -> String { + format!("{}/{tip}.log", self.logs_given.trim_end_matches('/')) + } +} + +/// What the gate's files and git say about one merged commit. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Verdict { + /// Gated green at `tip`, which contains the commit; `on_main` whether `tip` landed. + Green { + tip: String, + on_main: bool, + summary: String, + }, + /// Gated red at `tip`, the newest gated tip containing the commit, with why. + Red { tip: String, why: String }, + /// A gate at `tip` is still within the running limit. + Running { tip: String, age_ms: u64 }, + /// No gated tip contains the commit. + NoLog { read: usize }, + /// The checkout can't answer: the commit or `origin/main` isn't there. + CantTell(String), +} + +fn git(dir: &Path, args: &[&str]) -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +fn contains(dir: &Path, tip: &str, commit: &str) -> bool { + tip.eq_ignore_ascii_case(commit) + || git(dir, &["merge-base", "--is-ancestor", commit, tip]).is_some() +} + +fn is_sha(text: &str) -> bool { + text.len() == 40 && text.bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn mtime_ms(path: &Path) -> Option { + let modified = std::fs::metadata(path).ok()?.modified().ok()?; + Some( + modified + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_millis() as u64, + ) +} + +/// Whether a log says green for `tip`: it names `tip` and no scene failed or gave no result. +fn green(log: &GateLog, tip: &str) -> Result { + if !log.commit.eq_ignore_ascii_case(tip) { + return Err(format!("its log names {}, not the tip", &log.commit[..7])); + } + match log.failures() { + (0, 0) => Ok(format!( + "{}{} scenes, 0 failed", + log.quick + .as_deref() + .map(|q| format!("{q}, ")) + .unwrap_or_default(), + log.scenes.len() + )), + (failed, 0) => Err(format!("{failed} failed")), + (failed, silent) => Err(format!("{failed} failed, {silent} gave no result")), + } +} + +/// What `gate`'s files and checkout say about `commit`, at `now_ms`. +pub fn verdict(gate: &Gate, commit: &str, now_ms: u64) -> Verdict { + let dir = &gate.checkout; + if git(dir, &["cat-file", "-e", &format!("{commit}^{{commit}}")]).is_none() { + return Verdict::CantTell(format!( + "{} doesn't have {}: fetch it there", + gate.checkout_given, + &commit[..commit.len().min(7)] + )); + } + let Some(main) = git(dir, &["rev-parse", "--verify", "origin/main^{commit}"]) else { + return Verdict::CantTell(format!("{} has no origin/main", gate.checkout_given)); + }; + // The newest logs, each with its tip and time. + let mut logs: Vec<(String, u64)> = std::fs::read_dir(&gate.logs) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + let tip = name.strip_suffix(".log").filter(|t| is_sha(t))?.to_owned(); + Some((tip, mtime_ms(&entry.path())?)) + }) + .collect(); + logs.sort_by_key(|(_, ms)| std::cmp::Reverse(*ms)); + logs.truncate(MAX_LOGS); + let read = logs.len(); + let holding: Vec<(String, u64)> = logs + .into_iter() + .filter(|(tip, _)| contains(dir, tip, commit)) + .collect(); + // The earliest green tip that landed wins; failing that, the earliest green one at all. + let mut greens: Vec<(String, u64)> = holding + .iter() + .filter_map(|(tip, _)| Some((tip.clone(), mtime_ms(&gate.logs.join(format!("{tip}.ok")))?))) + .collect(); + greens.sort_by_key(|(_, ok_ms)| *ok_ms); + let mut off_main = None; + for (tip, _) in &greens { + let Ok(summary) = crate::gate_log::read(&gate.logs.join(format!("{tip}.log"))) + .and_then(|log| green(&log, tip)) + else { + continue; + }; + if contains(dir, &main, tip) { + return Verdict::Green { + tip: tip.clone(), + on_main: true, + summary, + }; + } + off_main.get_or_insert(Verdict::Green { + tip: tip.clone(), + on_main: false, + summary, + }); + } + if let Some(found) = off_main { + return found; + } + // No green: the newest tip that holds it says red or running. + let Some((tip, log_ms)) = holding.first() else { + return Verdict::NoLog { read }; + }; + let log = crate::gate_log::read(&gate.logs.join(format!("{tip}.log"))); + let has_ok = gate.logs.join(format!("{tip}.ok")).exists(); + let age_ms = now_ms.saturating_sub(*log_ms); + match log.as_ref().map(|l| green(l, tip)) { + Ok(Err(why)) => Verdict::Red { + tip: tip.clone(), + why, + }, + Err(why) if has_ok => Verdict::Red { + tip: tip.clone(), + why: format!("its .ok is there but its log can't be read: {why}"), + }, + _ if has_ok => Verdict::Red { + tip: tip.clone(), + why: "its .ok is there but its log doesn't say green".to_owned(), + }, + _ if age_ms >= gate.running_ms => Verdict::Red { + tip: tip.clone(), + why: format!( + "no .ok {} min after its log was last written: red, or it never finished", + age_ms / 60_000 + ), + }, + _ => Verdict::Running { + tip: tip.clone(), + age_ms, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(dir: &Path, args: &[&str]) -> String { + git(dir, args).unwrap_or_else(|| panic!("git {args:?} failed")) + } + + /// A checkout with `origin/main`, a gate logs directory, and commits to gate. + struct Fixture { + _dir: tempfile::TempDir, + repo: PathBuf, + logs: PathBuf, + } + + impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path().join("repo"); + let logs = dir.path().join("gates"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::create_dir_all(&logs).unwrap(); + run(&repo, &["init", "-q", "-b", "main"]); + run(&repo, &["config", "user.email", "t@t"]); + run(&repo, &["config", "user.name", "t"]); + let f = Self { + _dir: dir, + repo, + logs, + }; + f.commit("start"); + f + } + fn commit(&self, message: &str) -> String { + run( + &self.repo, + &["commit", "-q", "--allow-empty", "-m", message], + ); + run(&self.repo, &["rev-parse", "HEAD"]) + } + /// Moves `origin/main` to `sha`, as a fetch after a landing would. + fn land(&self, sha: &str) { + run(&self.repo, &["update-ref", "refs/remotes/origin/main", sha]); + } + /// A gate log for `tip`, with `failed` failures, and its `.ok` when `ok`, the log written + /// `age_ms` ago. + fn gate(&self, tip: &str, failed: u64, ok: bool, age_ms: u64) { + let log = self.logs.join(format!("{tip}.log")); + std::fs::write( + &log, + format!( + "# dsm-gate: commit {tip} tree {} scenes: x started t script sha256 0\nQUICK OK\n\ + == res://tests/test_ui_flow.tscn ==\n=== 12 passed, {failed} failed ===\n", + "b".repeat(40) + ), + ) + .unwrap(); + let when = std::time::SystemTime::now() - std::time::Duration::from_millis(age_ms); + std::fs::File::options() + .write(true) + .open(&log) + .unwrap() + .set_modified(when) + .unwrap(); + if ok { + let ok_path = self.logs.join(format!("{tip}.ok")); + std::fs::write(&ok_path, "2026-11-14T06:00:00-05:00\n").unwrap(); + std::fs::File::options() + .write(true) + .open(&ok_path) + .unwrap() + .set_modified(when) + .unwrap(); + } + } + fn gate_flag(&self, extra: &str) -> Gate { + Gate::parse(&format!( + "DSM={},{}{extra}", + self.logs.display(), + self.repo.display() + )) + .unwrap() + } + } + + fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + const MIN: u64 = 60_000; + + #[test] + fn the_flag_is_read_and_kept_as_given() { + let g = + Gate::parse("dsm=~/.local/state/dsm-gates,~/Documents/GitHub/Discoteque,running=90m") + .unwrap(); + assert_eq!(g.prefix, "DSM"); + assert_eq!(g.running_ms, 90 * MIN); + assert_eq!(g.logs_given, "~/.local/state/dsm-gates"); + assert!(!g.logs.starts_with("~"), "expanded for reading"); + assert_eq!(g.log_shown("abc"), "~/.local/state/dsm-gates/abc.log"); + assert!(g.covers("DSM-211") && !g.covers("RAL-1") && !g.covers("DSMX-1")); + let default = Gate::parse("DSM=/a,/b").unwrap(); + assert_eq!(default.running_ms, DEFAULT_RUNNING_MS); + assert!(Gate::parse("DSM=/a").is_err()); + assert!(Gate::parse("DSM=/a,/b,running=soon").is_err()); + } + + #[test] + fn a_merge_in_a_landed_green_tip_is_green() { + let f = Fixture::new(); + let car = f.commit("DSM-1: a car"); + let tip = f.commit("DSM-2: the train's tip"); + f.gate(&tip, 0, true, 30 * MIN); + f.land(&tip); + let v = verdict(&f.gate_flag(""), &car, now()); + assert_eq!( + v, + Verdict::Green { + tip: tip.clone(), + on_main: true, + summary: "QUICK OK, 1 scenes, 0 failed".into() + } + ); + } + + /// The watchman's first condition: a failed train rebuilt with the same commit, or a stacked + /// train gating on top, can't turn a landed, gated merge red. + #[test] + fn a_red_or_running_tip_beside_a_landed_green_one_doesnt_turn_it_red() { + let f = Fixture::new(); + let car = f.commit("DSM-1: a car"); + let failed = f.commit("DSM-2: the first train, red"); + f.gate(&failed, 3, false, 90 * MIN); + f.land(&car); + run(&f.repo, &["reset", "-q", "--hard", &car]); + let rebuilt = f.commit("DSM-2: rebuilt, green"); + f.gate(&rebuilt, 0, true, 40 * MIN); + f.land(&rebuilt); + let stacked = f.commit("DSM-3: a stacked train, gating now"); + f.gate(&stacked, 0, false, 5 * MIN); + match verdict(&f.gate_flag(""), &car, now()) { + Verdict::Green { + tip, on_main: true, .. + } => assert_eq!(tip, rebuilt), + other => panic!("{other:?}"), + } + } + + /// The second condition: a green log for a tip that never landed vouches for nothing on main. + #[test] + fn a_green_tip_not_on_main_is_said_as_such() { + let f = Fixture::new(); + let car = f.commit("DSM-1: a car"); + let tip = f.commit("DSM-2: gated, never landed"); + f.gate(&tip, 0, true, 10 * MIN); + f.land(&car); + match verdict(&f.gate_flag(""), &car, now()) { + Verdict::Green { + tip: t, + on_main: false, + .. + } => assert_eq!(t, tip), + other => panic!("{other:?}"), + } + } + + #[test] + fn no_green_tip_says_red_running_or_no_log() { + let f = Fixture::new(); + let car = f.commit("DSM-1: a car"); + f.land(&car); + let g = f.gate_flag(",running=60m"); + // No log at all. + assert_eq!(verdict(&g, &car, now()), Verdict::NoLog { read: 0 }); + // A log with failures. + let tip = f.commit("DSM-2: red"); + f.gate(&tip, 2, false, 10 * MIN); + assert_eq!( + verdict(&g, &car, now()), + Verdict::Red { + tip: tip.clone(), + why: "2 failed".into() + } + ); + // A log still within the running limit, then past it. + let next = f.commit("DSM-3: gating"); + f.gate(&next, 0, false, 10 * MIN); + assert!(matches!(verdict(&g, &car, now()), Verdict::Running { tip, .. } if tip == next)); + // Past it, with the red log gone, so this one is the newest. + std::fs::remove_file(f.logs.join(format!("{tip}.log"))).unwrap(); + f.gate(&next, 0, false, 61 * MIN); + match verdict(&g, &car, now()) { + Verdict::Red { tip, why } => { + assert_eq!(tip, next); + assert!(why.starts_with("no .ok 61 min"), "{why}"); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn a_commit_the_checkout_hasnt_fetched_cant_be_told() { + let f = Fixture::new(); + let tip = f.commit("x"); + f.land(&tip); + match verdict(&f.gate_flag(""), &"c".repeat(40), now()) { + Verdict::CantTell(why) => assert!(why.contains("doesn't have ccccccc"), "{why}"), + other => panic!("{other:?}"), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 2576c30..397e9b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub mod digest_loop; pub mod entity; pub mod evidence; pub mod gate; +pub mod gate_check; pub mod gate_log; pub mod inbox; pub mod init; diff --git a/src/main.rs b/src/main.rs index c58941f..a48cd66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -395,6 +395,14 @@ enum Command { /// previous one ended if it wasn't asked to. #[arg(long, conflicts_with = "report_only")] restart: bool, + /// A project gated on this machine, not by CI on GitHub (RAL-180): + /// `=,[,running=]`, such as + /// `DSM=~/.local/state/dsm-gates,~/Documents/GitHub/Discoteque`. Its merges are told from + /// the gate's `.log` and `.ok` files and the checkout's git: green at the + /// earliest landed tip that holds them, or red, running (2h by default) or no gate log. + /// Repeatable; kept in the pid file, so a restart keeps it. + #[arg(long = "gate")] + gates: Vec, }, /// For the operator: show the service's GitHub token can't merge (RAL-18). Reads pull request /// --pull, which must be already merged or closed, then tries to merge it naming a head no @@ -2680,6 +2688,7 @@ fn run_every( fn digest_control( pid_file: &Path, given: Option, + gates_only: Option>, stop: bool, restart: bool, ) -> Result> { @@ -2706,10 +2715,13 @@ fn digest_control( ) .into()); } - let settings = given.or(remembered).ok_or( + let mut settings = given.or(remembered).ok_or( "no settings to start the digest with: give --every, --quiet and --max (the pid file \ remembers them after that)", )?; + if let Some(gates) = gates_only { + settings.gates = gates; + } let exe = std::env::current_exe()?; let pid = dl::start(&exe, &settings, pid_file, &log)?; out["started"] = json!({"pid": pid, "args": settings.args(pid_file)}); @@ -2728,6 +2740,7 @@ fn digest_pass( times: rally::digest::Times, named: Option<&str>, report_only: bool, + gates: &[rally::gate_check::Gate], ) -> Result> { use rally::digest::{Decision, Kept, Watchman}; let backend = Backend::choose(location, config, false)?; @@ -2771,13 +2784,20 @@ fn digest_pass( }; // The projects whose merges pass a gate that isn't CI on GitHub (RAL-161), from the projects // list beside the pairing, or the built-in one. - let local_gates: Vec = { + let projects_read = { let file = default_projects_file(config) .ok() .flatten() .map(|path| std::fs::read_to_string(&path).map_err(|error| error.to_string())); - rally::brief::brief_projects(file) - .0 + rally::brief::brief_projects(file).0 + }; + // Each project's prefix and repository, for a merge whose story has no evidence (RAL-180). + let prefix_repos: Vec<(String, String)> = projects_read + .iter() + .map(|p| (p.prefix.clone(), p.repo.clone())) + .collect(); + let local_gates: Vec = { + projects_read .into_iter() .filter_map(|p| { Some(rally::digest::LocalGate { @@ -2862,7 +2882,13 @@ fn digest_pass( .any(|r| r.seq > kept.ci_checked_to && r.body["change"] == "merge") { let stories = all_stories()?; - rally::digest::take_in_merges(&mut kept, &recs, &stories, now_ms); + rally::digest::take_in_merges_with( + &mut kept, + &recs, + &stories, + now_ms, + &prefix_repos, + ); stories_read = Some(stories); } // A local gate's merges held from an earlier pass are named with their batch, so the @@ -2880,13 +2906,19 @@ fn digest_pass( } let before_ci = kept.ci.clone(); // Projects whose gate isn't CI on GitHub are told once, not looked for (RAL-161). - let ci = rally::digest::check_ci_with( + // Projects gated on this machine are told from the gate's files and git (RAL-180). + let gate_read = |gate: &rally::gate_check::Gate, commit: &str| { + rally::gate_check::verdict(gate, commit, now_ms.max(0) as u64) + }; + let ci = rally::digest::check_ci_gated( &mut kept, &web::pr_head, &web::ci_state, now_ms, &local_gates, stories_read.as_ref().unwrap_or(&Value::Null), + gates, + &gate_read, ); if !ci.lines.is_empty() { row["ci"] = json!(ci.lines); @@ -4939,8 +4971,14 @@ fn run(cli: Cli) -> Result> { background, stop, restart, + gates, } => { let config = config_path(cli.config)?; + // Read now, so a wrong --gate fails before anything starts. + let gate_list = gates + .iter() + .map(|g| rally::gate_check::Gate::parse(g)) + .collect::, _>>()?; let state_file = state_file.unwrap_or_else(|| config.with_file_name("digest.json")); let pid_file = pid_file.unwrap_or_else(|| config.with_file_name("digest.pid")); // RAL-154: stop, start and restart the loop by its pid file. @@ -4961,12 +4999,15 @@ fn run(cli: Cli) -> Result> { watchman: watchman.clone(), state_file: state_file.clone(), config: config.clone(), + gates: gates.clone(), }) } (Some(_), _, _) => return Err("--every needs --quiet and --max".into()), _ => None, }; - return digest_control(&pid_file, given, stop, restart); + // `--restart --gate …` alone keeps the remembered settings with these gates. + let gates_only = (given.is_none() && !gates.is_empty()).then(|| gates.clone()); + return digest_control(&pid_file, given, gates_only, stop, restart); } let (Some(quiet), Some(max)) = (quiet, max) else { return Err("a digest needs --quiet and --max".into()); @@ -4983,6 +5024,7 @@ fn run(cli: Cli) -> Result> { times, watchman.as_deref(), report_only, + &gate_list, ) }; let Some(every) = every else { @@ -5000,6 +5042,7 @@ fn run(cli: Cli) -> Result> { watchman: watchman.clone(), state_file: state_file.clone(), config: config.clone(), + gates: gates.clone(), }; rally::digest_loop::hold(&pid_file, &settings)?; let error = run_every(every, preflight, pass);