Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
596 linesCodeBlameRaw
1//! Statuses on commits: what workflow runs (and other tools, such as
2//! deployments) say about a pull request's head. These are its checks.
3//!
4//! The rules of the branch a pull request merges into name the checks that
5//! must pass (rulesets.rs; `RepoSettings::required_checks` as they stack):
6//! a required check that failed, is still running or has not reported
7//! refuses the merge, for everyone and for the merge queue (`g1t_rules`
8//! says so).
9
10use g1t_contracts::actions::{Job, WorkflowRun};
11use g1t_contracts::checks::StatusEvent;
12use g1t_contracts::events::ChecksEvent;
13use g1t_contracts::time::rfc3339;
14use g1t_contracts::work::{
15 CommitStatus, RequiredCheck, RequiredState, SeenCheck, SeenChecksArgs, SetCommitStatusArgs, check_name,
16 required_checks,
17};
18use g1t_contracts::{FailureCode, Outcome};
19use g1t_kit::now_ms;
20use serde::Deserialize;
21use worker::Result;
22
23use crate::Work;
24
25#[derive(Deserialize)]
26struct StatusRow {
27 context: String,
28 state: String,
29 description: Option<String>,
30 target_url: Option<String>,
31 updated_at: String,
32 #[serde(default)]
33 source: Option<String>,
34 #[serde(default)]
35 check_run_id: Option<String>,
36}
37
38impl From<StatusRow> for CommitStatus {
39 fn from(row: StatusRow) -> Self {
40 CommitStatus {
41 context: row.context,
42 state: row.state,
43 description: row.description,
44 target_url: row.target_url,
45 updated_at: row.updated_at,
46 source: row.source,
47 check_run_id: row.check_run_id,
48 }
49 }
50}
51
52#[derive(Deserialize)]
53struct HeadRow {
54 id: String,
55 number: u32,
56}
57
58/// What a commit's checks say: every status still running and every one
59/// that failed, by context, and where each required check stands.
60#[derive(Clone, Debug, Default, PartialEq)]
61pub(crate) struct WorkflowFacts {
62 pub(crate) pending: Vec<String>,
63 pub(crate) failed: Vec<String>,
64 pub(crate) required: Vec<RequiredCheck>,
65}
66
67impl WorkflowFacts {
68 /// Missing and pending required checks are not evidence of a passing build.
69 pub(crate) fn passed(&self) -> bool {
70 self.pending.is_empty() && self.failed.is_empty()
71 && self.required.iter().all(|check| check.state == RequiredState::Success)
72 }
73
74 /// `required` names the checks the default branch's protection requires.
75 pub(crate) fn of(statuses: &[CommitStatus], required: &[String]) -> WorkflowFacts {
76 WorkflowFacts {
77 pending: statuses.iter().filter(|s| s.state == "pending").map(|s| s.context.clone()).collect(),
78 failed: statuses.iter().filter(|s| s.state == "failure" || s.state == "error").map(|s| s.context.clone()).collect(),
79 required: required_checks(required, statuses),
80 }
81 }
82
83 fn required_in(&self, state: RequiredState) -> Vec<String> {
84 self.required.iter().filter(|check| check.state == state).map(|check| check.name.clone()).collect()
85 }
86
87 /// The required checks that failed, by name.
88 pub(crate) fn required_failed(&self) -> Vec<String> {
89 self.required_in(RequiredState::Failure)
90 }
91
92 /// The required checks nothing has reported on the commit yet.
93 pub(crate) fn expected(&self) -> Vec<String> {
94 self.required_in(RequiredState::Expected)
95 }
96}
97
98/// One merge-queue attempt (or probe) whose evidence is being read: the
99/// commit it built, the branch its `merge_group` runs were started on, when
100/// it was claimed, and how many runs its dispatch registered.
101pub(crate) struct Attempt<'a> {
102 pub(crate) sha: &'a str,
103 pub(crate) head_ref: String,
104 pub(crate) since: Option<&'a str>,
105 pub(crate) runs: u32,
106}
107
108/// What reported on the commit for this attempt, and only for it.
109pub(crate) struct AttemptEvidence {
110 /// Workflow-level statuses of this attempt's runs, and other tools'
111 /// statuses set since the attempt was claimed.
112 pub(crate) statuses: Vec<CommitStatus>,
113 /// This attempt's runs' jobs, as statuses (required checks named for jobs).
114 pub(crate) jobs: Vec<CommitStatus>,
115 /// Fewer of its runs were found than its dispatch registered.
116 pub(crate) runs_missing: bool,
117}
118
119/// The same commit can be built by several attempts (a fast-forward merge
120/// is the pull request's own head), and statuses are kept per commit and
121/// context: an earlier attempt's result, or the pull request's own checks,
122/// would otherwise decide this one. Actions' statuses are replaced by this
123/// attempt's own runs (started on its own branch); anything else counts
124/// only once set after the attempt was claimed.
125pub(crate) fn attempt_evidence(
126 statuses: &[CommitStatus],
127 runs: &[(WorkflowRun, Vec<Job>)],
128 attempt: &Attempt<'_>,
129) -> AttemptEvidence {
130 let mut kept: Vec<CommitStatus> = statuses
131 .iter()
132 .filter(|status| status.source.as_deref() != Some("actions"))
133 .filter(|status| attempt.since.is_none_or(|since| status.updated_at.as_str() >= since))
134 .cloned()
135 .collect();
136 let ours: Vec<(WorkflowRun, Vec<Job>)> = runs
137 .iter()
138 .filter(|(run, _)| run.sha == attempt.sha && run.git_ref == attempt.head_ref)
139 .cloned()
140 .collect();
141 let latest = g1t_contracts::checks::latest_runs(&ours);
142 for (run, _) in &latest {
143 let state = match (run.status.as_str(), run.conclusion.as_deref()) {
144 ("completed", Some("success" | "skipped" | "neutral")) => "success",
145 ("completed", _) => "failure",
146 _ => "pending",
147 };
148 kept.push(CommitStatus {
149 context: format!("{} / {}", run.name, run.event),
150 state: state.to_owned(),
151 description: run.error.clone(),
152 target_url: None,
153 updated_at: run.finished_at.clone().unwrap_or_else(|| run.created_at.clone()),
154 source: Some("actions".to_owned()),
155 check_run_id: None,
156 });
157 }
158 AttemptEvidence {
159 statuses: kept,
160 jobs: g1t_contracts::checks::job_statuses(&ours, attempt.sha),
161 runs_missing: (latest.len() as u32) < attempt.runs,
162 }
163}
164
165impl AttemptEvidence {
166 /// Where the attempt stands against the required checks. Runs its
167 /// dispatch registered but that cannot be found are still to come.
168 pub(crate) fn facts(&self, required: &[String]) -> WorkflowFacts {
169 let mut facts = WorkflowFacts::of(&self.statuses, required);
170 if !required.is_empty() {
171 let mut all = self.statuses.clone();
172 all.extend(self.jobs.iter().cloned());
173 facts.required = required_checks(required, &all);
174 }
175 if self.runs_missing {
176 facts.pending.push("merge_group workflows".to_owned());
177 }
178 facts
179 }
180
181 /// Everything the attempt's rules are judged against.
182 pub(crate) fn all(&self) -> Vec<CommitStatus> {
183 let mut all = self.statuses.clone();
184 all.extend(self.jobs.iter().cloned());
185 all
186 }
187}
188
189/// The check names in `(context, last reported)` rows, most recent first:
190/// each name once, with the events it was reported for.
191pub(crate) fn seen(rows: Vec<(String, String)>) -> Vec<SeenCheck> {
192 let mut rows = rows;
193 rows.sort_by(|a, b| b.1.cmp(&a.1));
194 let mut out: Vec<SeenCheck> = Vec::new();
195 for (context, at) in rows {
196 let (name, event) = check_name(&context);
197 match out.iter_mut().find(|seen| seen.name.eq_ignore_ascii_case(name)) {
198 Some(seen) => {
199 if let Some(event) = event
200 && !seen.events.iter().any(|known| known == event)
201 {
202 seen.events.push(event.to_owned());
203 }
204 }
205 None => out.push(SeenCheck {
206 name: name.to_owned(),
207 events: event.map(|event| vec![event.to_owned()]).unwrap_or_default(),
208 last_seen: at,
209 }),
210 }
211 }
212 out
213}
214
215/// How far back `seen_checks` looks, and the most contexts it reads.
216const SEEN_DAYS: u64 = 30;
217const SEEN_LIMIT: u32 = 200;
218
219#[derive(Deserialize)]
220struct SeenRow {
221 context: String,
222 at: String,
223}
224
225pub(crate) fn list(names: &[String]) -> String {
226 match names {
227 [] => String::new(),
228 [one] => one.clone(),
229 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
230 }
231}
232
233impl Work {
234 /// Where a commit's checks stand, against the repository's required ones.
235 pub(crate) async fn facts(&self, repo_id: &str, sha: Option<&str>) -> Result<WorkflowFacts> {
236 let (statuses, settings) =
237 futures_util::future::try_join(self.statuses(repo_id, sha), self.settings_by_id(repo_id)).await?;
238 self.workflow_facts(repo_id, sha, &statuses, &settings.required_checks).await
239 }
240
241 /// What `statuses`, a commit's, say, with each required check also met
242 /// by the g1t Actions jobs of its name ([`Work::with_job_checks`]). Jobs
243 /// count only toward required checks: whether workflows are running or
244 /// failed is their runs' statuses' to say.
245 pub(crate) async fn workflow_facts(
246 &self,
247 repo_id: &str,
248 sha: Option<&str>,
249 statuses: &[CommitStatus],
250 required: &[String],
251 ) -> Result<WorkflowFacts> {
252 let mut facts = WorkflowFacts::of(statuses, required);
253 if !required.is_empty() {
254 facts.required = required_checks(required, &self.with_job_checks(repo_id, sha, statuses.to_vec()).await?);
255 }
256 Ok(facts)
257 }
258
259 /// This attempt's evidence on its commit ([`attempt_evidence`]).
260 pub(crate) async fn attempt_statuses(&self, repo_id: &str, attempt: &Attempt<'_>) -> Result<AttemptEvidence> {
261 let (statuses, runs) = futures_util::future::try_join(
262 self.statuses(repo_id, Some(attempt.sha)),
263 self.actions_runs(g1t_contracts::checks::ActionsChecksArgs {
264 repo_id: repo_id.to_owned(),
265 shas: vec![attempt.sha.to_owned()],
266 ..Default::default()
267 }),
268 )
269 .await?;
270 let runs: Vec<_> = runs.into_iter().map(|detail| (detail.run, detail.jobs)).collect();
271 Ok(attempt_evidence(&statuses, &runs, attempt))
272 }
273
274 /// Where one attempt stands against the default branch's required checks.
275 pub(crate) async fn attempt_facts(&self, repo_id: &str, attempt: &Attempt<'_>) -> Result<WorkflowFacts> {
276 let (evidence, settings) =
277 futures_util::future::try_join(self.attempt_statuses(repo_id, attempt), self.settings_by_id(repo_id)).await?;
278 Ok(evidence.facts(&settings.required_checks))
279 }
280
281 /// `statuses` and, for each g1t Actions job on `sha`, the status its
282 /// check run stands as (`g1t_contracts::checks::job_statuses`), so a
283 /// required check named for a job, as GitHub's are, is met by it.
284 pub(crate) async fn with_job_checks(&self, repo_id: &str, sha: Option<&str>, mut statuses: Vec<CommitStatus>) -> Result<Vec<CommitStatus>> {
285 let Some(sha) = sha else { return Ok(statuses) };
286 let details = self
287 .actions_runs(g1t_contracts::checks::ActionsChecksArgs { repo_id: repo_id.to_owned(), shas: vec![sha.to_owned()], ..Default::default() })
288 .await?;
289 let runs: Vec<_> = details.into_iter().map(|detail| (detail.run, detail.jobs)).collect();
290 statuses.extend(g1t_contracts::checks::job_statuses(&runs, sha));
291 Ok(statuses)
292 }
293
294 /// The check names reported on a repository's commits lately, for
295 /// choosing which to require.
296 pub(crate) async fn seen_checks(&self, a: SeenChecksArgs) -> Result<Outcome<Vec<SeenCheck>>> {
297 let repo = match self.repo(&a.repo, &a.viewer).await? {
298 Outcome::Ok(repo) => repo,
299 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
300 };
301 let since = rfc3339(now_ms().saturating_sub(SEEN_DAYS * 24 * 60 * 60 * 1000));
302 let rows = self
303 .db
304 .prepare(
305 "SELECT context, MAX(updated_at) AS at FROM commit_statuses
306 WHERE repo_id = ? AND updated_at >= ? GROUP BY context ORDER BY at DESC LIMIT ?",
307 )
308 .bind(&[repo.id.as_str().into(), since.into(), SEEN_LIMIT.into()])?
309 .all()
310 .await?
311 .results::<SeenRow>()?;
312 Ok(Outcome::Ok(seen(rows.into_iter().map(|row| (row.context, row.at)).collect())))
313 }
314
315 pub(crate) async fn statuses(&self, repo_id: &str, sha: Option<&str>) -> Result<Vec<CommitStatus>> {
316 let Some(sha) = sha else { return Ok(Vec::new()) };
317 if let Some(found) = self.prefetched_repo(repo_id).filter(|found| found.head.as_deref() == Some(sha)) {
318 return Ok(found
319 .rows::<StatusRow>(crate::prefetch::Slot::Statuses)?
320 .into_iter()
321 .map(CommitStatus::from)
322 .collect());
323 }
324 Ok(self
325 .db
326 .prepare("SELECT context, state, description, target_url, updated_at, source, check_run_id FROM commit_statuses WHERE repo_id = ? AND sha = ? ORDER BY context")
327 .bind(&[repo_id.into(), sha.into()])?
328 .all()
329 .await?
330 .results::<StatusRow>()?
331 .into_iter()
332 .map(CommitStatus::from)
333 .collect())
334 }
335
336 /// A status from another part of g1t: a workflow run, a deploy, a scan.
337 /// Published as `status.created`, as one set through the API is, so
338 /// webhooks hear it.
339 /// Publishing is best effort: the status is kept either way.
340 pub(crate) async fn set_commit_status(&self, a: SetCommitStatusArgs) -> Result<Outcome<bool>> {
341 let event = StatusEvent {
342 repo_id: a.repo_id.clone(),
343 sha: a.sha.clone(),
344 context: a.context.clone(),
345 state: a.state.clone(),
346 description: a.description.clone(),
347 target_url: a.target_url.clone(),
348 };
349 let repo_id = a.repo_id.clone();
350 let stored = self.store_status(a, None).await?;
351 if matches!(stored, Outcome::Ok(_))
352 && let Err(error) = self.publish_as("status.created", &repo_id, None, event).await
353 {
354 worker::console_error!("status.created not published: {error}");
355 }
356 Ok(stored)
357 }
358
359 /// Sets a status, standing for check run `check_run_id` if it does,
360 /// and moves on what was waiting for the commit's checks.
361 pub(crate) async fn store_status(&self, a: SetCommitStatusArgs, check_run_id: Option<&str>) -> Result<Outcome<bool>> {
362 if !matches!(a.state.as_str(), "pending" | "success" | "failure" | "error") {
363 return Ok(Outcome::fail(FailureCode::Invalid, "`state` is pending, success, failure or error."));
364 }
365 self.db
366 .prepare(
367 "INSERT INTO commit_statuses (repo_id, sha, context, state, description, target_url, updated_at, source, check_run_id)
368 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
369 ON CONFLICT (repo_id, sha, context) DO UPDATE SET
370 state = excluded.state, description = excluded.description,
371 target_url = excluded.target_url, updated_at = excluded.updated_at,
372 source = excluded.source, check_run_id = excluded.check_run_id",
373 )
374 .bind(&[
375 a.repo_id.as_str().into(),
376 a.sha.as_str().into(),
377 a.context.as_str().into(),
378 a.state.as_str().into(),
379 a.description.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
380 a.target_url.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
381 rfc3339(now_ms()).into(),
382 a.source.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
383 check_run_id.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
384 ])?
385 .run()
386 .await?;
387 if a.state == "pending" {
388 return Ok(Outcome::Ok(true));
389 }
390 // A merge queue state waiting on its merge_group workflows: judged
391 // on its own attempt's evidence, whatever else is pending on the
392 // commit (the pull request's own checks, an earlier attempt's).
393 self.merge_group_finished(&a.repo_id, &a.sha).await?;
394 // Once every workflow on a pull request's head has finished, its
395 // lifecycle moves on, as it does when its checks finish.
396 let facts = self.facts(&a.repo_id, Some(&a.sha)).await?;
397 if !facts.pending.is_empty() {
398 return Ok(Outcome::Ok(true));
399 }
400 let heads = self
401 .db
402 .prepare("SELECT id, number FROM pulls WHERE repo_id = ? AND head_commit = ? AND status IN ('draft', 'open')")
403 .bind(&[a.repo_id.as_str().into(), a.sha.as_str().into()])?
404 .all()
405 .await?
406 .results::<HeadRow>()?;
407 for head in heads {
408 // A pull request g1t stopped on picks back up once what stopped
409 // it passes: its workflows, and its checks if it has any. The
410 // lifecycle then decides again, within its usual limits.
411 if facts.failed.is_empty() {
412 let resumed = self
413 .db
414 .prepare(
415 "UPDATE pulls SET stalled = NULL WHERE id = ? AND managed = 1 AND stalled IS NOT NULL
416 AND (check_status IS NULL OR check_status = 'passed') RETURNING id AS value",
417 )
418 .bind(&[head.id.as_str().into()])?
419 .first::<crate::rows::ValueRow>(None)
420 .await?;
421 if resumed.is_some() {
422 self.announce_resumed(&head.id, None).await?;
423 self.note(
424 &a.repo_id,
425 head.number,
426 (crate::lifecycle::POLICY_ACTOR_ID, crate::lifecycle::POLICY_ACTOR_NAME),
427 "picked this back up: its workflows pass now",
428 )
429 .await?;
430 }
431 }
432 // A pull request the merge queue took out rejoins once a new
433 // commit's checks all pass (integration.rs).
434 if facts.failed.is_empty() && facts.required_failed().is_empty() && facts.expected().is_empty() {
435 self.requeue_if_fixed(&head.id, &a.sha).await?;
436 }
437 self.publish_as(
438 "checks.completed",
439 &a.repo_id,
440 None,
441 ChecksEvent {
442 pull_id: head.id.clone(),
443 repo_id: a.repo_id.clone(),
444 number: head.number,
445 status: if facts.failed.is_empty() { "passed" } else { "failed" },
446 commit: a.sha.clone(),
447 },
448 )
449 .await?;
450 // A merge that brought it up to date and waits for its required
451 // checks on this commit lands, or stops on one that failed. The
452 // status is kept either way.
453 if let Err(error) = self.land_when_checked(&head.id).await {
454 worker::console_error!("landing {} after its checks: {error}", head.id);
455 }
456 }
457 Ok(Outcome::Ok(true))
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 fn status(context: &str, state: &str) -> CommitStatus {
466 CommitStatus {
467 context: context.into(),
468 state: state.into(),
469 description: None,
470 target_url: None,
471 updated_at: String::new(),
472 source: None,
473 check_run_id: None,
474 }
475 }
476
477 fn names(list: &[&str]) -> Vec<String> {
478 list.iter().map(|name| (*name).to_owned()).collect()
479 }
480
481 fn run(id: &str, git_ref: &str, event: &str, status: &str, conclusion: Option<&str>) -> (WorkflowRun, Vec<Job>) {
482 let run: WorkflowRun = serde_json::from_value(serde_json::json!({
483 "id": id, "workflowId": "wf", "path": ".g1t/workflows/ci.yml", "name": "CI", "title": "t",
484 "number": 1, "attempt": 1, "event": event, "ref": git_ref, "sha": "same", "pull": null,
485 "status": status, "conclusion": conclusion, "error": null, "actor": null,
486 "createdAt": "2026-10-14T10:00:00.000Z", "startedAt": null, "finishedAt": null,
487 }))
488 .unwrap();
489 let job: Job = serde_json::from_value(serde_json::json!({
490 "id": format!("job_{id}"), "runId": id, "key": "test", "name": "test", "needs": [],
491 "status": status, "conclusion": conclusion, "steps": [], "annotations": [],
492 "reason": null, "startedAt": null, "finishedAt": null,
493 }))
494 .unwrap();
495 (run, vec![job])
496 }
497
498 fn attempt(head_ref: &str, runs: u32) -> Attempt<'static> {
499 Attempt { sha: "same", head_ref: head_ref.into(), since: Some("2026-10-14T10:00:00.000Z"), runs }
500 }
501
502 #[test]
503 fn a_retry_on_the_same_commit_is_not_decided_by_the_earlier_attempt() {
504 const OLD: &str = "refs/heads/g1t-queue/qen_1-old";
505 const NEW: &str = "refs/heads/g1t-queue/qen_1-new";
506 let required = names(&["CI"]);
507 // The commit's statuses: the flaky earlier attempt's failure under
508 // the shared context, and the pull request's own passing checks.
509 let mut flaky = status("CI / merge_group", "failure");
510 flaky.source = Some("actions".into());
511 let mut own = status("CI / pull_request", "success");
512 own.source = Some("actions".into());
513 let statuses = [flaky, own];
514 let old = run("run_old", OLD, "merge_group", "completed", Some("failure"));
515 let pr = run("run_pr", "refs/heads/feature", "pull_request", "completed", Some("success"));
516 // Read right after the retry's dispatch: its run is still going.
517 let going = run("run_new", NEW, "merge_group", "in_progress", None);
518 let runs = [going.clone(), pr.clone(), old.clone()];
519 let facts = attempt_evidence(&statuses, &runs, &attempt(NEW, 1)).facts(&required);
520 assert!(facts.failed.is_empty(), "the earlier attempt's failure is not this one's: {facts:?}");
521 assert!(!facts.pending.is_empty() && !facts.passed());
522 // Nor does the pull request's own run satisfy the required check.
523 let none = attempt_evidence(&statuses, &[pr.clone(), old.clone()], &attempt(NEW, 0)).facts(&required);
524 assert!(!none.passed());
525 assert_eq!(none.expected(), ["CI"]);
526 let done = run("run_new", NEW, "merge_group", "completed", Some("success"));
527 let facts = attempt_evidence(&statuses, &[done, pr, old], &attempt(NEW, 1)).facts(&required);
528 assert!(facts.passed(), "{facts:?}");
529 }
530
531 #[test]
532 fn runs_the_dispatch_registered_but_cannot_be_read_are_still_pending() {
533 // The actions service could not be read: no runs at all.
534 let facts = attempt_evidence(&[], &[], &attempt("refs/heads/g1t-queue/x", 2)).facts(&[]);
535 assert!(!facts.passed(), "never a pass from missing evidence");
536 assert!(!facts.pending.is_empty());
537 let one = run("run_a", "refs/heads/g1t-queue/x", "merge_group", "completed", Some("success"));
538 assert!(!attempt_evidence(&[], &[one.clone()], &attempt("refs/heads/g1t-queue/x", 2)).facts(&[]).passed());
539 assert!(attempt_evidence(&[], &[one], &attempt("refs/heads/g1t-queue/x", 1)).facts(&[]).passed());
540 }
541
542 #[test]
543 fn other_tools_count_only_once_set_after_the_attempt_was_claimed() {
544 let mut before = status("deploy / preview", "failure");
545 before.updated_at = "2026-10-14T09:59:59.999Z".into();
546 let mut after = status("lint", "success");
547 after.updated_at = "2026-10-14T10:00:00.000Z".into();
548 let evidence = attempt_evidence(&[before, after], &[], &attempt("refs/heads/g1t-queue/x", 0));
549 let contexts: Vec<&str> = evidence.statuses.iter().map(|status| status.context.as_str()).collect();
550 assert_eq!(contexts, ["lint"]);
551 }
552
553 #[test]
554 fn integration_evidence_requires_completed_checks() {
555 let required = names(&["CI"]);
556 assert!(!WorkflowFacts::of(&[], &required).passed());
557 assert!(!WorkflowFacts::of(&[status("CI", "pending")], &required).passed());
558 assert!(!WorkflowFacts::of(&[status("CI", "failure")], &required).passed());
559 assert!(WorkflowFacts::of(&[status("CI", "success")], &required).passed());
560 }
561
562 #[test]
563 fn only_required_checks_hold_a_merge() {
564 let statuses = [status("CI / push", "pending"), status("Lint / pull_request", "failure"), status("Docs", "success")];
565 // Nothing required: nothing holds it, whatever failed.
566 let free = WorkflowFacts::of(&statuses, &[]);
567 assert_eq!(free.pending, ["CI / push"]);
568 assert_eq!(free.failed, ["Lint / pull_request"]);
569 assert!(free.required_failed().is_empty() && free.expected().is_empty());
570 let both = WorkflowFacts::of(&statuses, &names(&["CI", "Lint"]));
571 assert_eq!(both.required_failed(), ["Lint"]);
572 assert!(WorkflowFacts::of(&[status("Docs", "success")], &names(&["Docs"])).required_failed().is_empty());
573 }
574
575 #[test]
576 fn a_required_check_nothing_reported_is_expected() {
577 let facts = WorkflowFacts::of(&[status("CI / pull_request", "success")], &names(&["CI", "Deploy"]));
578 assert_eq!(facts.expected(), ["Deploy"]);
579 }
580
581 #[test]
582 fn seen_checks_are_named_once_with_their_events() {
583 let rows = vec![
584 ("CI / push".to_owned(), "2026-10-01T00:00:00Z".to_owned()),
585 ("CI / pull_request".to_owned(), "2026-10-03T00:00:00Z".to_owned()),
586 ("g1t / deploy".to_owned(), "2026-10-02T00:00:00Z".to_owned()),
587 ];
588 let seen = seen(rows);
589 assert_eq!(seen.len(), 2);
590 assert_eq!(seen[0].name, "CI");
591 assert_eq!(seen[0].events, ["pull_request", "push"]);
592 assert_eq!(seen[0].last_seen, "2026-10-03T00:00:00Z");
593 assert_eq!(seen[1].name, "g1t / deploy");
594 assert!(seen[1].events.is_empty());
595 }
596}