Skip to content
354 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). Where g1t sees an agent's pull request through,
9//! any check that failed sends the agent back to fix it, with what the
10//! failing jobs printed; once it is out of revisions, only a required
11//! check holds the pull request for a person.
12
13use g1t_contracts::checks::StatusEvent;
14use g1t_contracts::events::ChecksEvent;
15use g1t_contracts::time::rfc3339;
16use g1t_contracts::work::{
17 CommitStatus, RequiredCheck, RequiredState, SeenCheck, SeenChecksArgs, SetCommitStatusArgs, check_name,
18 required_checks,
19};
20use g1t_contracts::{FailureCode, Outcome};
21use g1t_kit::now_ms;
22use serde::Deserialize;
23use worker::Result;
24
25use crate::Work;
26
27#[derive(Deserialize)]
28struct StatusRow {
29 context: String,
30 state: String,
31 description: Option<String>,
32 target_url: Option<String>,
33 updated_at: String,
34 #[serde(default)]
35 source: Option<String>,
36 #[serde(default)]
37 check_run_id: Option<String>,
38}
39
40impl From<StatusRow> for CommitStatus {
41 fn from(row: StatusRow) -> Self {
42 CommitStatus {
43 context: row.context,
44 state: row.state,
45 description: row.description,
46 target_url: row.target_url,
47 updated_at: row.updated_at,
48 source: row.source,
49 check_run_id: row.check_run_id,
50 }
51 }
52}
53
54#[derive(Deserialize)]
55struct HeadRow {
56 id: String,
57 number: u32,
58}
59
60/// What a commit's checks say: every status still running and every one
61/// that failed, by context, and where each required check stands.
62#[derive(Clone, Debug, Default, PartialEq)]
63pub(crate) struct WorkflowFacts {
64 pub(crate) pending: Vec<String>,
65 pub(crate) failed: Vec<String>,
66 pub(crate) required: Vec<RequiredCheck>,
67}
68
69impl WorkflowFacts {
70 /// `required` names the checks the default branch's protection requires.
71 pub(crate) fn of(statuses: &[CommitStatus], required: &[String]) -> WorkflowFacts {
72 WorkflowFacts {
73 pending: statuses.iter().filter(|s| s.state == "pending").map(|s| s.context.clone()).collect(),
74 failed: statuses.iter().filter(|s| s.state == "failure" || s.state == "error").map(|s| s.context.clone()).collect(),
75 required: required_checks(required, statuses),
76 }
77 }
78
79 fn required_in(&self, state: RequiredState) -> Vec<String> {
80 self.required.iter().filter(|check| check.state == state).map(|check| check.name.clone()).collect()
81 }
82
83 /// The required checks that failed, by name.
84 pub(crate) fn required_failed(&self) -> Vec<String> {
85 self.required_in(RequiredState::Failure)
86 }
87
88 /// The required checks nothing has reported on the commit yet.
89 pub(crate) fn expected(&self) -> Vec<String> {
90 self.required_in(RequiredState::Expected)
91 }
92}
93
94/// The check names in `(context, last reported)` rows, most recent first:
95/// each name once, with the events it was reported for.
96pub(crate) fn seen(rows: Vec<(String, String)>) -> Vec<SeenCheck> {
97 let mut rows = rows;
98 rows.sort_by(|a, b| b.1.cmp(&a.1));
99 let mut out: Vec<SeenCheck> = Vec::new();
100 for (context, at) in rows {
101 let (name, event) = check_name(&context);
102 match out.iter_mut().find(|seen| seen.name.eq_ignore_ascii_case(name)) {
103 Some(seen) => {
104 if let Some(event) = event
105 && !seen.events.iter().any(|known| known == event)
106 {
107 seen.events.push(event.to_owned());
108 }
109 }
110 None => out.push(SeenCheck {
111 name: name.to_owned(),
112 events: event.map(|event| vec![event.to_owned()]).unwrap_or_default(),
113 last_seen: at,
114 }),
115 }
116 }
117 out
118}
119
120/// How far back `seen_checks` looks, and the most contexts it reads.
121const SEEN_DAYS: u64 = 30;
122const SEEN_LIMIT: u32 = 200;
123
124#[derive(Deserialize)]
125struct SeenRow {
126 context: String,
127 at: String,
128}
129
130pub(crate) fn list(names: &[String]) -> String {
131 match names {
132 [] => String::new(),
133 [one] => one.clone(),
134 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
135 }
136}
137
138impl Work {
139 /// Where a commit's checks stand, against the repository's required ones.
140 pub(crate) async fn facts(&self, repo_id: &str, sha: Option<&str>) -> Result<WorkflowFacts> {
141 let (statuses, settings) =
142 futures_util::future::try_join(self.statuses(repo_id, sha), self.settings_by_id(repo_id)).await?;
143 Ok(WorkflowFacts::of(&statuses, &settings.required_checks))
144 }
145
146 /// The check names reported on a repository's commits lately, for
147 /// choosing which to require.
148 pub(crate) async fn seen_checks(&self, a: SeenChecksArgs) -> Result<Outcome<Vec<SeenCheck>>> {
149 let repo = match self.repo(&a.repo, &a.viewer).await? {
150 Outcome::Ok(repo) => repo,
151 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
152 };
153 let since = rfc3339(now_ms().saturating_sub(SEEN_DAYS * 24 * 60 * 60 * 1000));
154 let rows = self
155 .db
156 .prepare(
157 "SELECT context, MAX(updated_at) AS at FROM commit_statuses
158 WHERE repo_id = ? AND updated_at >= ? GROUP BY context ORDER BY at DESC LIMIT ?",
159 )
160 .bind(&[repo.id.as_str().into(), since.into(), SEEN_LIMIT.into()])?
161 .all()
162 .await?
163 .results::<SeenRow>()?;
164 Ok(Outcome::Ok(seen(rows.into_iter().map(|row| (row.context, row.at)).collect())))
165 }
166
167 pub(crate) async fn statuses(&self, repo_id: &str, sha: Option<&str>) -> Result<Vec<CommitStatus>> {
168 let Some(sha) = sha else { return Ok(Vec::new()) };
169 if let Some(found) = self.prefetched_repo(repo_id).filter(|found| found.head.as_deref() == Some(sha)) {
170 return Ok(found
171 .rows::<StatusRow>(crate::prefetch::Slot::Statuses)?
172 .into_iter()
173 .map(CommitStatus::from)
174 .collect());
175 }
176 Ok(self
177 .db
178 .prepare("SELECT context, state, description, target_url, updated_at, source, check_run_id FROM commit_statuses WHERE repo_id = ? AND sha = ? ORDER BY context")
179 .bind(&[repo_id.into(), sha.into()])?
180 .all()
181 .await?
182 .results::<StatusRow>()?
183 .into_iter()
184 .map(CommitStatus::from)
185 .collect())
186 }
187
188 /// A status from another part of g1t: a workflow run, a deploy, a scan.
189 /// Published as `status.created`, as one set through the API is, so
190 /// webhooks hear it and a GitHub mirror's commit is told (integrations).
191 /// Publishing is best effort: the status is kept either way.
192 pub(crate) async fn set_commit_status(&self, a: SetCommitStatusArgs) -> Result<Outcome<bool>> {
193 let event = StatusEvent {
194 repo_id: a.repo_id.clone(),
195 sha: a.sha.clone(),
196 context: a.context.clone(),
197 state: a.state.clone(),
198 description: a.description.clone(),
199 target_url: a.target_url.clone(),
200 };
201 let repo_id = a.repo_id.clone();
202 let stored = self.store_status(a, None).await?;
203 if matches!(stored, Outcome::Ok(_))
204 && let Err(error) = self.publish_as("status.created", &repo_id, None, event).await
205 {
206 worker::console_error!("status.created not published: {error}");
207 }
208 Ok(stored)
209 }
210
211 /// Sets a status, standing for check run `check_run_id` if it does,
212 /// and moves on what was waiting for the commit's checks.
213 pub(crate) async fn store_status(&self, a: SetCommitStatusArgs, check_run_id: Option<&str>) -> Result<Outcome<bool>> {
214 if !matches!(a.state.as_str(), "pending" | "success" | "failure" | "error") {
215 return Ok(Outcome::fail(FailureCode::Invalid, "`state` is pending, success, failure or error."));
216 }
217 self.db
218 .prepare(
219 "INSERT INTO commit_statuses (repo_id, sha, context, state, description, target_url, updated_at, source, check_run_id)
220 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
221 ON CONFLICT (repo_id, sha, context) DO UPDATE SET
222 state = excluded.state, description = excluded.description,
223 target_url = excluded.target_url, updated_at = excluded.updated_at,
224 source = excluded.source, check_run_id = excluded.check_run_id",
225 )
226 .bind(&[
227 a.repo_id.as_str().into(),
228 a.sha.as_str().into(),
229 a.context.as_str().into(),
230 a.state.as_str().into(),
231 a.description.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
232 a.target_url.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
233 rfc3339(now_ms()).into(),
234 a.source.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
235 check_run_id.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
236 ])?
237 .run()
238 .await?;
239 if a.state == "pending" {
240 return Ok(Outcome::Ok(true));
241 }
242 // Once every workflow on a pull request's head has finished, its
243 // lifecycle moves on, as it does when its checks finish.
244 let facts = self.facts(&a.repo_id, Some(&a.sha)).await?;
245 if !facts.pending.is_empty() {
246 return Ok(Outcome::Ok(true));
247 }
248 // A merge queue state waiting on its merge_group workflows.
249 self.merge_group_finished(&a.repo_id, &a.sha, &facts).await?;
250 let heads = self
251 .db
252 .prepare("SELECT id, number FROM pulls WHERE repo_id = ? AND head_commit = ? AND status IN ('draft', 'open')")
253 .bind(&[a.repo_id.as_str().into(), a.sha.as_str().into()])?
254 .all()
255 .await?
256 .results::<HeadRow>()?;
257 for head in heads {
258 // A pull request g1t stopped on picks back up once what stopped
259 // it passes: its workflows, and its checks if it has any. The
260 // lifecycle then decides again, within its usual limits.
261 if facts.failed.is_empty() {
262 let resumed = self
263 .db
264 .prepare(
265 "UPDATE pulls SET stalled = NULL WHERE id = ? AND managed = 1 AND stalled IS NOT NULL
266 AND (check_status IS NULL OR check_status = 'passed') RETURNING id AS value",
267 )
268 .bind(&[head.id.as_str().into()])?
269 .first::<crate::rows::ValueRow>(None)
270 .await?;
271 if resumed.is_some() {
272 self.announce_resumed(&head.id, None).await?;
273 self.note(
274 &a.repo_id,
275 head.number,
276 (crate::lifecycle::POLICY_ACTOR_ID, crate::lifecycle::POLICY_ACTOR_NAME),
277 "picked this back up: its workflows pass now",
278 )
279 .await?;
280 }
281 }
282 self.publish_as(
283 "checks.completed",
284 &a.repo_id,
285 None,
286 ChecksEvent {
287 pull_id: head.id,
288 repo_id: a.repo_id.clone(),
289 number: head.number,
290 status: if facts.failed.is_empty() { "passed" } else { "failed" },
291 commit: a.sha.clone(),
292 },
293 )
294 .await?;
295 }
296 Ok(Outcome::Ok(true))
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 fn status(context: &str, state: &str) -> CommitStatus {
305 CommitStatus {
306 context: context.into(),
307 state: state.into(),
308 description: None,
309 target_url: None,
310 updated_at: String::new(),
311 source: None,
312 check_run_id: None,
313 }
314 }
315
316 fn names(list: &[&str]) -> Vec<String> {
317 list.iter().map(|name| (*name).to_owned()).collect()
318 }
319
320 #[test]
321 fn only_required_checks_hold_a_merge() {
322 let statuses = [status("CI / push", "pending"), status("Lint / pull_request", "failure"), status("Docs", "success")];
323 // Nothing required: nothing holds it, whatever failed.
324 let free = WorkflowFacts::of(&statuses, &[]);
325 assert_eq!(free.pending, ["CI / push"]);
326 assert_eq!(free.failed, ["Lint / pull_request"]);
327 assert!(free.required_failed().is_empty() && free.expected().is_empty());
328 let both = WorkflowFacts::of(&statuses, &names(&["CI", "Lint"]));
329 assert_eq!(both.required_failed(), ["Lint"]);
330 assert!(WorkflowFacts::of(&[status("Docs", "success")], &names(&["Docs"])).required_failed().is_empty());
331 }
332
333 #[test]
334 fn a_required_check_nothing_reported_is_expected() {
335 let facts = WorkflowFacts::of(&[status("CI / pull_request", "success")], &names(&["CI", "Deploy"]));
336 assert_eq!(facts.expected(), ["Deploy"]);
337 }
338
339 #[test]
340 fn seen_checks_are_named_once_with_their_events() {
341 let rows = vec![
342 ("CI / push".to_owned(), "2026-10-01T00:00:00Z".to_owned()),
343 ("CI / pull_request".to_owned(), "2026-10-03T00:00:00Z".to_owned()),
344 ("g1t / deploy".to_owned(), "2026-10-02T00:00:00Z".to_owned()),
345 ];
346 let seen = seen(rows);
347 assert_eq!(seen.len(), 2);
348 assert_eq!(seen[0].name, "CI");
349 assert_eq!(seen[0].events, ["pull_request", "push"]);
350 assert_eq!(seen[0].last_seen, "2026-10-03T00:00:00Z");
351 assert_eq!(seen[1].name, "g1t / deploy");
352 assert!(seen[1].events.is_empty());
353 }
354}