Skip to content
971 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge checks: statuses and check runs on every commit1//! Checks on commits: statuses and check runs that CI, integrations and
2//! tokens report through the API, and g1t Actions' jobs read as check runs.
3//!
4//! A check run reported here is kept in `commit_check_runs`, grouped per
5//! reporter and commit in `commit_check_suites`, and also stands as a
6//! status of its name (`commit_statuses.check_run_id`), so required checks
7//! and rulesets are met by either (statuses.rs `store_status`). A g1t
8//! Actions job is not kept here: the actions service's runs and jobs are
9//! read as suites and check runs (`g1t_contracts::checks::job_check_run`).
10
11use std::collections::{BTreeMap, HashMap};
12
13use g1t_contracts::access::Capability;
14use g1t_contracts::actions::{RunActionArgs, RunDetail};
15use g1t_contracts::checks::*;
16use g1t_contracts::repos::{Commit, LogArgs, Repo, RepoPath};
17use g1t_contracts::time::rfc3339;
18use g1t_contracts::work::{CommitStatus, SetCommitStatusArgs};
19use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
20use g1t_kit::now_ms;
21use serde::Deserialize;
22use serde_json::Value;
23use worker::Result;
24use worker::wasm_bindgen::JsValue;
25
26use crate::{Work, allowed};
27
28/// Unwraps an `Outcome`, returning its failure from the enclosing method.
29macro_rules! check {
30 ($outcome:expr) => {
31 match $outcome {
32 Outcome::Ok(value) => value,
33 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
34 }
35 };
36}
37
38fn optional(value: Option<&str>) -> JsValue {
39 value.map_or(JsValue::NULL, JsValue::from)
40}
41
42fn invalid<T>(message: impl Into<String>) -> Outcome<T> {
43 Outcome::fail(FailureCode::Invalid, message)
44}
45
46fn no_check_run<T>() -> Outcome<T> {
47 Outcome::fail(FailureCode::NotFound, "Check run not found.")
48}
49
50fn no_suite<T>() -> Outcome<T> {
51 Outcome::fail(FailureCode::NotFound, "Check suite not found.")
52}
53
54/// `owner/name`, as pages on the site name it.
55fn full_name(repo: &Repo) -> String {
56 format!("{}/{}", repo.namespace, repo.name)
57}
58
59/// A check run's page on the site.
60fn page(repo: &Repo, id: &str) -> String {
61 format!("/{}/checks/{id}", full_name(repo))
62}
63
64/// What a check run's status says, as a status's description can hold it.
65fn description_of(title: Option<&str>, summary: Option<&str>) -> Option<String> {
66 let text = title.filter(|title| !title.trim().is_empty()).or(summary)?;
67 let line = text.lines().find(|line| !line.trim().is_empty())?.trim();
68 let mut out: String = line.chars().take(MAX_DESCRIPTION_CHARS).collect();
69 if line.chars().count() > MAX_DESCRIPTION_CHARS {
70 out = out.chars().take(MAX_DESCRIPTION_CHARS - 1).collect::<String>() + "…";
71 }
72 Some(out)
73}
74
75#[derive(Deserialize)]
76struct RunRow {
77 id: String,
78 suite_id: String,
79 head_sha: String,
80 name: String,
81 status: String,
82 conclusion: Option<String>,
83 started_at: Option<String>,
84 completed_at: Option<String>,
85 details_url: Option<String>,
86 external_id: Option<String>,
87 title: Option<String>,
88 summary: Option<String>,
89 text: Option<String>,
90 annotations_count: u32,
91 actions: String,
92 app_slug: String,
93 app_name: String,
94 created_at: String,
95}
96
97impl RunRow {
98 fn view(self, repo: &Repo) -> CommitCheckRun {
99 CommitCheckRun {
100 html_url: page(repo, &self.id),
101 id: self.id,
102 name: self.name,
103 head_sha: self.head_sha,
104 status: self.status,
105 conclusion: self.conclusion,
106 started_at: self.started_at,
107 completed_at: self.completed_at,
108 details_url: self.details_url,
109 external_id: self.external_id,
110 output: CheckOutput { title: self.title, summary: self.summary, text: self.text, annotations_count: self.annotations_count },
111 actions: serde_json::from_str(&self.actions).unwrap_or_default(),
112 check_suite: SuiteRef { id: self.suite_id },
113 app: CheckApp { slug: self.app_slug, name: self.app_name },
114 workflow: None,
115 created_at: self.created_at,
116 }
117 }
118}
119
120#[derive(Deserialize)]
121struct SuiteRow {
122 id: String,
123 head_sha: String,
124 head_branch: Option<String>,
125 status: String,
126 conclusion: Option<String>,
127 app_slug: String,
128 app_name: String,
129 created_at: String,
130 updated_at: String,
131 #[serde(default)]
132 runs: u32,
133}
134
135impl From<SuiteRow> for CommitCheckSuite {
136 fn from(row: SuiteRow) -> Self {
137 CommitCheckSuite {
138 id: row.id,
139 head_sha: row.head_sha,
140 head_branch: row.head_branch,
141 status: row.status,
142 conclusion: row.conclusion,
143 app: CheckApp { slug: row.app_slug, name: row.app_name },
144 name: None,
145 latest_check_runs_count: row.runs,
146 created_at: row.created_at,
147 updated_at: row.updated_at,
148 }
149 }
150}
151
152#[derive(Deserialize)]
153struct AnnotationRow {
154 path: String,
155 start_line: u32,
156 end_line: u32,
157 start_column: Option<u32>,
158 end_column: Option<u32>,
159 annotation_level: String,
160 message: String,
161 title: Option<String>,
162 raw_details: Option<String>,
163}
164
165#[derive(Deserialize)]
166struct StatusRow {
167 sha: String,
168 context: String,
169 state: String,
170 description: Option<String>,
171 target_url: Option<String>,
172 updated_at: String,
173 source: Option<String>,
174 check_run_id: Option<String>,
175}
176
177impl StatusRow {
178 fn status(self) -> (String, CommitStatus) {
179 (
180 self.sha,
181 CommitStatus {
182 context: self.context,
183 state: self.state,
184 description: self.description,
185 target_url: self.target_url,
186 updated_at: self.updated_at,
187 source: self.source,
188 check_run_id: self.check_run_id,
189 },
190 )
191 }
192}
193
194const RUN_COLUMNS: &str = "id, suite_id, head_sha, name, status, conclusion, started_at, completed_at, details_url, external_id,
195 title, summary, text, annotations_count, actions, app_slug, app_name, created_at";
196
197const SUITE_COLUMNS: &str = "s.id, s.head_sha, s.head_branch, s.status, s.conclusion, s.app_slug, s.app_name, s.created_at, s.updated_at,
198 (SELECT count(DISTINCT name) FROM commit_check_runs r WHERE r.suite_id = s.id) AS runs";
199
200/// `?, ?, ?` for `count` values.
201fn marks(count: usize) -> String {
202 vec!["?"; count].join(", ")
203}
204
205/// Each name's latest check run, of runs newest first.
206fn latest_by_name(runs: Vec<CommitCheckRun>) -> Vec<CommitCheckRun> {
207 let mut seen: Vec<(String, String)> = Vec::new();
208 runs.into_iter()
209 .filter(|run| {
210 let key = (run.check_suite.id.clone(), run.name.clone());
211 if seen.contains(&key) {
212 false
213 } else {
214 seen.push(key);
215 true
216 }
217 })
218 .collect()
219}
220
221/// The check runs of g1t Actions jobs in `details`, newest run first:
222/// with `all`, every run's; otherwise the latest of each workflow and
223/// event.
224fn job_runs(repo: &Repo, details: &[RunDetail], all: bool) -> Vec<CommitCheckRun> {
225 let pairs: Vec<(g1t_contracts::actions::WorkflowRun, &RunDetail)> =
226 details.iter().map(|detail| (detail.run.clone(), detail)).collect();
227 let chosen: Vec<&(g1t_contracts::actions::WorkflowRun, &RunDetail)> =
228 if all { pairs.iter().collect() } else { latest_runs(&pairs) };
229 let name = full_name(repo);
230 chosen
231 .into_iter()
232 .flat_map(|(run, detail)| detail.jobs.iter().map(|job| job_check_run(&name, run, job)).collect::<Vec<_>>())
233 .collect()
234}
235
236impl Work {
237 /// The repository, if `viewer` may see it.
238 async fn readable(&self, path: &RepoPath, viewer: &Viewer) -> Result<Outcome<Repo>> {
239 self.repo(path, viewer).await
240 }
241
242 /// The repository, if `actor` may report checks on it: the Write role,
243 /// and not archived.
244 async fn reportable(&self, path: &RepoPath, actor: &User) -> Result<Outcome<Repo>> {
245 let repo = check!(self.repo(path, &Some(actor.clone())).await?);
246 check!(allowed(Some(actor), &repo, Capability::Push));
Merge branch 'mirroring' into artifacts-mode247 check!(crate::retired::not_archived(&repo));
Merge checks: statuses and check runs on every commit248 Ok(Outcome::Ok(repo))
249 }
250
251 /// The commit `git_ref` names: a full SHA as it is, a branch, tag or
252 /// short SHA as the repository resolves it.
253 async fn commit_of(&self, path: &RepoPath, viewer: &Viewer, git_ref: &str) -> Result<Outcome<String>> {
254 let git_ref = git_ref.trim();
255 if is_full_sha(git_ref) {
256 return Ok(Outcome::Ok(git_ref.to_ascii_lowercase()));
257 }
258 if git_ref.is_empty() {
259 return Ok(invalid("Name a commit, branch or tag."));
260 }
261 let found: Outcome<Vec<Commit>> = g1t_kit::call(
262 &self.repos,
263 "log",
264 &LogArgs { path: path.clone(), viewer: viewer.clone(), git_ref: Some(git_ref.to_owned()), limit: 1 },
265 )
266 .await?;
267 Ok(match found {
268 Outcome::Ok(commits) => match commits.into_iter().next() {
269 Some(commit) => Outcome::Ok(commit.hash),
270 None => Outcome::fail(FailureCode::NotFound, format!("No commit, branch or tag {git_ref}.")),
271 },
272 Outcome::Fail(_) => Outcome::fail(FailureCode::NotFound, format!("No commit, branch or tag {git_ref}.")),
273 })
274 }
275
276 /// g1t Actions' runs and jobs, by commits, by a run or by a job.
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.277 pub(crate) async fn actions_runs(&self, args: ActionsChecksArgs) -> Result<Vec<RunDetail>> {
Merge checks: statuses and check runs on every commit278 // Without the actions service a commit's other checks still show.
279 Ok(g1t_kit::call(&self.actions, "check_runs", &args).await.unwrap_or_default())
280 }
281
282 async fn stored_run(&self, repo: &Repo, id: &str) -> Result<Option<CommitCheckRun>> {
283 Ok(self
284 .db
285 .prepare(format!("SELECT {RUN_COLUMNS} FROM commit_check_runs WHERE id = ? AND repo_id = ?"))
286 .bind(&[id.into(), repo.id.as_str().into()])?
287 .first::<RunRow>(None)
288 .await?
289 .map(|row| row.view(repo)))
290 }
291
292 /// A check run by id: one reported here, or a g1t Actions job.
293 async fn any_run(&self, repo: &Repo, id: &str) -> Result<Option<(CommitCheckRun, Option<RunDetail>)>> {
294 if id.starts_with("job_") {
295 let details = self.actions_runs(ActionsChecksArgs { repo_id: repo.id.clone(), job_id: Some(id.to_owned()), ..Default::default() }).await?;
296 let found = details.into_iter().find_map(|detail| {
297 let job = detail.jobs.iter().find(|job| job.id == id)?.clone();
298 Some((job_check_run(&full_name(repo), &detail.run, &job), detail))
299 });
300 return Ok(found.map(|(run, detail)| (run, Some(detail))));
301 }
302 Ok(self.stored_run(repo, id).await?.map(|run| (run, None)))
303 }
304
305 /// Sets the status a check run stands as, which moves on whatever
306 /// waits for the commit's checks.
307 async fn project(&self, repo: &Repo, run: &CommitCheckRun, previous_name: Option<&str>) -> Result<()> {
308 if let Some(previous) = previous_name.filter(|previous| *previous != run.name) {
309 self.db
310 .prepare("DELETE FROM commit_statuses WHERE repo_id = ? AND sha = ? AND context = ? AND check_run_id = ?")
311 .bind(&[repo.id.as_str().into(), run.head_sha.as_str().into(), previous.into(), run.id.as_str().into()])?
312 .run()
313 .await?;
314 }
315 let stored = self
316 .store_status(
317 SetCommitStatusArgs {
318 repo_id: repo.id.clone(),
319 sha: run.head_sha.clone(),
320 context: run.name.clone(),
321 state: status_state_of(&run.status, run.conclusion.as_deref()).to_owned(),
322 description: description_of(run.output.title.as_deref(), run.output.summary.as_deref()),
323 target_url: Some(run.details_url.clone().unwrap_or_else(|| run.html_url.clone())),
324 source: Some("api".to_owned()),
325 },
326 Some(&run.id),
327 )
328 .await?;
329 if let Outcome::Fail(failure) = stored {
330 worker::console_error!("work: a check run's status was not set: {}", failure.message);
331 }
332 Ok(())
333 }
334
335 /// Works a suite's status out again from its latest check runs; says
336 /// when it has just completed.
337 async fn refresh_suite(&self, repo: &Repo, suite_id: &str) -> Result<()> {
338 #[derive(Deserialize)]
339 struct Latest {
340 name: String,
341 status: String,
342 conclusion: Option<String>,
343 }
344 let rows = self
345 .db
346 .prepare("SELECT name, status, conclusion FROM commit_check_runs WHERE suite_id = ? ORDER BY created_at DESC, id DESC")
347 .bind(&[suite_id.into()])?
348 .all()
349 .await?
350 .results::<Latest>()?;
351 let mut names: Vec<String> = Vec::new();
352 let mut latest: Vec<(String, Option<String>)> = Vec::new();
353 for row in rows {
354 if !names.contains(&row.name) {
355 names.push(row.name);
356 latest.push((row.status, row.conclusion));
357 }
358 }
359 let (status, conclusion) = suite_state(&latest);
360 let changed = self
361 .db
362 .prepare(
363 "UPDATE commit_check_suites SET status = ?, conclusion = ?, updated_at = ?
364 WHERE id = ? AND (status IS NOT ? OR conclusion IS NOT ?) RETURNING status AS value",
365 )
366 .bind(&[status.into(), optional(conclusion), rfc3339(now_ms()).into(), suite_id.into(), status.into(), optional(conclusion)])?
367 .first::<crate::rows::ValueRow>(None)
368 .await?;
369 if changed.is_some() && status == "completed"
370 && let Some(suite) = self.stored_suite(repo, suite_id).await?
371 {
372 self.publish_as("check_suite.completed", &repo.id, None, CheckSuiteEvent { repo_id: repo.id.clone(), check_suite: suite }).await?;
373 }
374 Ok(())
375 }
376
377 async fn stored_suite(&self, repo: &Repo, id: &str) -> Result<Option<CommitCheckSuite>> {
378 Ok(self
379 .db
380 .prepare(format!("SELECT {SUITE_COLUMNS} FROM commit_check_suites s WHERE s.id = ? AND s.repo_id = ?"))
381 .bind(&[id.into(), repo.id.as_str().into()])?
382 .first::<SuiteRow>(None)
383 .await?
384 .map(CommitCheckSuite::from))
385 }
386
387 /// Adds annotations after the ones a check run has, up to the most it
388 /// keeps. Returns how many it has now.
389 async fn annotate(&self, run_id: &str, had: u32, annotations: &[CheckAnnotation]) -> Result<u32> {
390 let room = MAX_ANNOTATIONS.saturating_sub(had) as usize;
391 let mut statements = Vec::new();
392 for (index, annotation) in annotations.iter().take(room).enumerate() {
393 let seq = had + u32::try_from(index).unwrap_or(0);
394 statements.push(
395 self.db
396 .prepare(
397 "INSERT INTO commit_check_annotations (run_id, seq, path, start_line, end_line, start_column, end_column,
398 annotation_level, message, title, raw_details) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
399 )
400 .bind(&[
401 run_id.into(),
402 seq.into(),
403 annotation.path.trim().into(),
404 annotation.start_line.into(),
405 annotation.end_line.into(),
406 annotation.start_column.map_or(JsValue::NULL, JsValue::from),
407 annotation.end_column.map_or(JsValue::NULL, JsValue::from),
408 annotation.annotation_level.as_str().into(),
409 annotation.message.as_str().into(),
410 optional(annotation.title.as_deref()),
411 optional(annotation.raw_details.as_deref()),
412 ])?,
413 );
414 }
415 let added = u32::try_from(statements.len()).unwrap_or(0);
416 if !statements.is_empty() {
417 self.db.batch(statements).await?;
418 }
419 Ok(had + added)
420 }
421
422 /// `create_commit_status`.
423 pub(crate) async fn create_commit_status(&self, a: CreateStatusArgs) -> Result<Outcome<CommitStatus>> {
424 let context = a.context.as_deref().map(str::trim).filter(|context| !context.is_empty()).unwrap_or("default").to_owned();
425 let description = a.description.as_deref().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned);
426 let target_url = a.target_url.as_deref().map(str::trim).filter(|url| !url.is_empty()).map(str::to_owned);
427 if let Err(message) = validate_status(&a.state, &context, description.as_deref(), target_url.as_deref()) {
428 return Ok(invalid(message));
429 }
430 let repo = check!(self.reportable(&a.repo, &a.actor).await?);
431 let sha = check!(self.commit_of(&a.repo, &Some(a.actor.clone()), &a.sha).await?);
432 check!(
433 self.store_status(
434 SetCommitStatusArgs {
435 repo_id: repo.id.clone(),
436 sha: sha.clone(),
437 context: context.clone(),
438 state: a.state.clone(),
439 description: description.clone(),
440 target_url: target_url.clone(),
441 source: Some("api".to_owned()),
442 },
443 None,
444 )
445 .await?
446 );
447 self.publish(
448 "status.created",
449 &repo.id,
450 &a.actor,
451 StatusEvent { repo_id: repo.id.clone(), sha: sha.clone(), context: context.clone(), state: a.state.clone(), description: description.clone(), target_url: target_url.clone() },
452 )
453 .await?;
454 Ok(Outcome::Ok(CommitStatus {
455 context,
456 state: a.state,
457 description,
458 target_url,
459 updated_at: rfc3339(now_ms()),
460 source: Some("api".to_owned()),
461 check_run_id: None,
462 }))
463 }
464
465 async fn statuses_of(&self, repo: &Repo, shas: &[String], own_only: bool) -> Result<Vec<(String, CommitStatus)>> {
466 if shas.is_empty() {
467 return Ok(Vec::new());
468 }
469 let mut binds: Vec<JsValue> = vec![repo.id.as_str().into()];
470 binds.extend(shas.iter().map(|sha| JsValue::from(sha.as_str())));
471 let own = if own_only { " AND check_run_id IS NULL" } else { "" };
472 Ok(self
473 .db
474 .prepare(format!(
475 "SELECT sha, context, state, description, target_url, updated_at, source, check_run_id FROM commit_statuses
476 WHERE repo_id = ? AND sha IN ({}){own} ORDER BY updated_at DESC, context",
477 marks(shas.len())
478 ))
479 .bind(&binds)?
480 .all()
481 .await?
482 .results::<StatusRow>()?
483 .into_iter()
484 .map(StatusRow::status)
485 .collect())
486 }
487
488 /// `commit_statuses`: a commit's statuses, newest first, without the
489 /// ones check runs stand as.
490 pub(crate) async fn commit_statuses(&self, a: RefArgs) -> Result<Outcome<Vec<CommitStatus>>> {
491 let repo = check!(self.readable(&a.repo, &a.viewer).await?);
492 let sha = check!(self.commit_of(&a.repo, &a.viewer, &a.git_ref).await?);
493 Ok(Outcome::Ok(self.statuses_of(&repo, &[sha], true).await?.into_iter().map(|(_, status)| status).collect()))
494 }
495
496 /// `combined_status`.
497 pub(crate) async fn combined_status(&self, a: RefArgs) -> Result<Outcome<CombinedStatus>> {
498 let repo = check!(self.readable(&a.repo, &a.viewer).await?);
499 let sha = check!(self.commit_of(&a.repo, &a.viewer, &a.git_ref).await?);
500 let mut statuses: Vec<CommitStatus> =
501 self.statuses_of(&repo, std::slice::from_ref(&sha), true).await?.into_iter().map(|(_, status)| status).collect();
502 statuses.sort_by(|a, b| a.context.cmp(&b.context));
503 Ok(Outcome::Ok(CombinedStatus {
504 state: combined_state(&statuses).to_owned(),
505 sha,
506 total_count: u32::try_from(statuses.len()).unwrap_or(u32::MAX),
507 statuses,
508 }))
509 }
510
511 /// `create_check_run`.
512 pub(crate) async fn create_check_run(&self, a: CreateCheckRunArgs) -> Result<Outcome<CommitCheckRun>> {
513 if let Err(message) = validate_run(&a.run, true) {
514 return Ok(invalid(message));
515 }
516 let repo = check!(self.reportable(&a.repo, &a.actor).await?);
517 let head = a.run.head_sha.as_deref().unwrap_or_default();
518 let sha = check!(self.commit_of(&a.repo, &Some(a.actor.clone()), head).await?);
519 let app = CheckApp::of(&a.actor, a.app.as_deref());
520 let now = rfc3339(now_ms());
521 // The reporter's suite for the commit, made the first time.
522 self.db
523 .prepare(
524 "INSERT INTO commit_check_suites (id, repo_id, head_sha, head_branch, app_slug, app_name, status, created_at, updated_at)
525 VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?) ON CONFLICT (repo_id, head_sha, app_slug) DO NOTHING",
526 )
527 .bind(&[
528 new_id("cs", now_ms()).into(),
529 repo.id.as_str().into(),
530 sha.as_str().into(),
531 optional((!is_full_sha(head)).then_some(head.trim())),
532 app.slug.as_str().into(),
533 app.name.as_str().into(),
534 now.as_str().into(),
535 now.as_str().into(),
536 ])?
537 .run()
538 .await?;
539 let suite = self
540 .db
541 .prepare("SELECT id AS value FROM commit_check_suites WHERE repo_id = ? AND head_sha = ? AND app_slug = ?")
542 .bind(&[repo.id.as_str().into(), sha.as_str().into(), app.slug.as_str().into()])?
543 .first::<crate::rows::ValueRow>(None)
544 .await?
545 .map(|row| row.value)
546 .unwrap_or_default();
547 let input = &a.run;
548 let status = match (&input.status, &input.conclusion) {
549 (_, Some(_)) => "completed",
550 (Some(status), None) => status.as_str(),
551 (None, None) => "queued",
552 };
553 let started_at = input.started_at.clone().or_else(|| (status != "queued").then(|| now.clone()));
554 let completed_at = (status == "completed").then(|| input.completed_at.clone().unwrap_or_else(|| now.clone()));
555 let output = input.output.clone().unwrap_or_default();
556 let actions = input.actions.clone().unwrap_or_default();
557 let id = new_id("cr", now_ms());
558 self.db
559 .prepare(
560 "INSERT INTO commit_check_runs (id, repo_id, suite_id, head_sha, name, status, conclusion, started_at, completed_at,
561 details_url, external_id, title, summary, text, actions, app_slug, app_name, created_by, created_at, updated_at)
562 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
563 )
564 .bind(&[
565 id.as_str().into(),
566 repo.id.as_str().into(),
567 suite.as_str().into(),
568 sha.as_str().into(),
569 input.name.as_deref().unwrap_or_default().trim().into(),
570 status.into(),
571 optional(input.conclusion.as_deref()),
572 optional(started_at.as_deref()),
573 optional(completed_at.as_deref()),
574 optional(input.details_url.as_deref().filter(|url| !url.is_empty())),
575 optional(input.external_id.as_deref()),
576 optional(output.title.as_deref()),
577 optional(output.summary.as_deref()),
578 optional(output.text.as_deref()),
579 serde_json::to_string(&actions)?.into(),
580 app.slug.as_str().into(),
581 app.name.as_str().into(),
582 a.actor.id.as_str().into(),
583 now.as_str().into(),
584 now.as_str().into(),
585 ])?
586 .run()
587 .await?;
588 if !output.annotations.is_empty() {
589 let count = self.annotate(&id, 0, &output.annotations).await?;
590 self.db
591 .prepare("UPDATE commit_check_runs SET annotations_count = ? WHERE id = ?")
592 .bind(&[count.into(), id.as_str().into()])?
593 .run()
594 .await?;
595 }
596 let Some(run) = self.stored_run(&repo, &id).await? else {
597 return Ok(no_check_run());
598 };
599 self.project(&repo, &run, None).await?;
600 self.refresh_suite(&repo, &suite).await?;
601 let event = CheckRunEvent { repo_id: repo.id.clone(), check_run: run.clone(), requested_action: None };
602 self.publish("check_run.created", &repo.id, &a.actor, event.clone()).await?;
603 if run.status == "completed" {
604 self.publish("check_run.completed", &repo.id, &a.actor, event).await?;
605 }
606 Ok(Outcome::Ok(run))
607 }
608
609 /// `update_check_run`.
610 pub(crate) async fn update_check_run(&self, a: UpdateCheckRunArgs) -> Result<Outcome<CommitCheckRun>> {
611 if let Err(message) = validate_run(&a.run, false) {
612 return Ok(invalid(message));
613 }
614 let repo = check!(self.reportable(&a.repo, &a.actor).await?);
615 if a.id.starts_with("job_") {
616 return Ok(invalid("That is a g1t Actions job: its workflow reports it, and it cannot be changed through the API."));
617 }
618 let Some(before) = self.stored_run(&repo, &a.id).await? else {
619 return Ok(no_check_run());
620 };
621 let input = &a.run;
622 let now = rfc3339(now_ms());
623 let status = match (&input.status, &input.conclusion) {
624 (_, Some(_)) => "completed".to_owned(),
625 (Some(status), None) => status.clone(),
626 (None, None) => before.status.clone(),
627 };
628 let conclusion = if status == "completed" { input.conclusion.clone().or_else(|| before.conclusion.clone()) } else { None };
629 if status == "completed" && conclusion.is_none() {
630 return Ok(invalid("A completed check run needs a `conclusion`."));
631 }
632 let started_at = input
633 .started_at
634 .clone()
635 .or_else(|| before.started_at.clone())
636 .or_else(|| (status != "queued").then(|| now.clone()));
637 let completed_at = (status == "completed")
638 .then(|| input.completed_at.clone().or_else(|| before.completed_at.clone()).unwrap_or_else(|| now.clone()));
639 let output = input.output.clone().unwrap_or_default();
640 let title = output.title.clone().or_else(|| before.output.title.clone());
641 let summary = output.summary.clone().or_else(|| before.output.summary.clone());
642 let text = output.text.clone().or_else(|| before.output.text.clone());
643 let actions = input.actions.clone().unwrap_or_else(|| before.actions.clone());
644 let name = input.name.as_deref().map(str::trim).unwrap_or(&before.name).to_owned();
645 let details_url = match input.details_url.as_deref() {
646 Some("") => None,
647 Some(url) => Some(url.to_owned()),
648 None => before.details_url.clone(),
649 };
650 let count = if output.annotations.is_empty() {
651 before.output.annotations_count
652 } else {
653 self.annotate(&before.id, before.output.annotations_count, &output.annotations).await?
654 };
655 self.db
656 .prepare(
657 "UPDATE commit_check_runs SET name = ?, status = ?, conclusion = ?, started_at = ?, completed_at = ?, details_url = ?,
658 external_id = ?, title = ?, summary = ?, text = ?, actions = ?, annotations_count = ?, updated_at = ?
659 WHERE id = ?",
660 )
661 .bind(&[
662 name.as_str().into(),
663 status.as_str().into(),
664 optional(conclusion.as_deref()),
665 optional(started_at.as_deref()),
666 optional(completed_at.as_deref()),
667 optional(details_url.as_deref()),
668 optional(input.external_id.as_deref().or(before.external_id.as_deref())),
669 optional(title.as_deref()),
670 optional(summary.as_deref()),
671 optional(text.as_deref()),
672 serde_json::to_string(&actions)?.into(),
673 count.into(),
674 now.as_str().into(),
675 before.id.as_str().into(),
676 ])?
677 .run()
678 .await?;
679 let Some(run) = self.stored_run(&repo, &before.id).await? else {
680 return Ok(no_check_run());
681 };
682 self.project(&repo, &run, Some(&before.name)).await?;
683 self.refresh_suite(&repo, &run.check_suite.id).await?;
684 if before.status != "completed" && run.status == "completed" {
685 let event = CheckRunEvent { repo_id: repo.id.clone(), check_run: run.clone(), requested_action: None };
686 self.publish("check_run.completed", &repo.id, &a.actor, event).await?;
687 }
688 Ok(Outcome::Ok(run))
689 }
690
691 /// `get_check_run`.
692 pub(crate) async fn get_check_run(&self, a: CheckIdArgs) -> Result<Outcome<CommitCheckRun>> {
693 let repo = check!(self.readable(&a.repo, &a.viewer).await?);
694 Ok(match self.any_run(&repo, &a.id).await? {
695 Some((run, _)) => Outcome::Ok(run),
696 None => no_check_run(),
697 })
698 }
699
700 /// `check_run_annotations`, in the order they were reported.
701 pub(crate) async fn check_run_annotations(&self, a: CheckIdArgs) -> Result<Outcome<Vec<CheckAnnotation>>> {
702 let repo = check!(self.readable(&a.repo, &a.viewer).await?);
703 let Some((run, detail)) = self.any_run(&repo, &a.id).await? else {
704 return Ok(no_check_run());
705 };
706 if let Some(detail) = detail {
707 let job = detail.jobs.iter().find(|job| job.id == run.id);
708 return Ok(Outcome::Ok(job.map(job_annotations).unwrap_or_default()));
709 }
710 let rows = self
711 .db
712 .prepare(
713 "SELECT path, start_line, end_line, start_column, end_column, annotation_level, message, title, raw_details
714 FROM commit_check_annotations WHERE run_id = ? ORDER BY seq",
715 )
716 .bind(&[run.id.as_str().into()])?
717 .all()
718 .await?
719 .results::<AnnotationRow>()?;
720 Ok(Outcome::Ok(
721 rows.into_iter()
722 .map(|row| CheckAnnotation {
723 path: row.path,
724 start_line: row.start_line,
725 end_line: row.end_line,
726 start_column: row.start_column,
727 end_column: row.end_column,
728 annotation_level: row.annotation_level,
729 message: row.message,
730 title: row.title,
731 raw_details: row.raw_details,
732 })
733 .collect(),
734 ))
735 }
736
737 /// The check runs reported here on `shas`, newest first.
738 async fn stored_runs(&self, repo: &Repo, shas: &[String]) -> Result<Vec<CommitCheckRun>> {
739 if shas.is_empty() {
740 return Ok(Vec::new());
741 }
742 let mut binds: Vec<JsValue> = vec![repo.id.as_str().into()];
743 binds.extend(shas.iter().map(|sha| JsValue::from(sha.as_str())));
744 Ok(self
745 .db
746 .prepare(format!(
747 "SELECT {RUN_COLUMNS} FROM commit_check_runs WHERE repo_id = ? AND head_sha IN ({}) ORDER BY created_at DESC, id DESC",
748 marks(shas.len())
749 ))
750 .bind(&binds)?
751 .all()
752 .await?
753 .results::<RunRow>()?
754 .into_iter()
755 .map(|row| row.view(repo))
756 .collect())
757 }
758
759 /// `ref_check_runs`.
760 pub(crate) async fn ref_check_runs(&self, a: RefCheckRunsArgs) -> Result<Outcome<CheckRunList>> {
761 let all = match a.filter.as_deref().unwrap_or("latest") {
762 "latest" => false,
763 "all" => true,
764 _ => return Ok(invalid("`filter` is latest or all.")),
765 };
766 if let Some(status) = a.status.as_deref()
767 && !STATUSES.contains(&status)
768 {
769 return Ok(invalid("`status` is queued, in_progress or completed."));
770 }
771 let repo = check!(self.readable(&a.repo, &a.viewer).await?);
772 let sha = check!(self.commit_of(&a.repo, &a.viewer, &a.git_ref).await?);
773 let shas = [sha.clone()];
774 let (stored, details) = futures_util::future::try_join(
775 self.stored_runs(&repo, &shas),
776 self.actions_runs(ActionsChecksArgs { repo_id: repo.id.clone(), shas: vec![sha], ..Default::default() }),
777 )
778 .await?;
779 let stored = if all { stored } else { latest_by_name(stored) };
780 let mut runs: Vec<CommitCheckRun> = stored.into_iter().chain(job_runs(&repo, &details, all)).collect();
781 runs.retain(|run| {
782 a.check_name.as_deref().is_none_or(|name| run.name == name)
783 && a.status.as_deref().is_none_or(|status| run.status == status)
784 && a.app.as_deref().is_none_or(|app| run.app.slug == app)
785 });
786 Ok(Outcome::Ok(CheckRunList { total_count: u32::try_from(runs.len()).unwrap_or(u32::MAX), check_runs: runs }))
787 }
788
789 /// `ref_check_suites`.
790 pub(crate) async fn ref_check_suites(&self, a: RefCheckSuitesArgs) -> Result<Outcome<CheckSuiteList>> {
791 let repo = check!(self.readable(&a.repo, &a.viewer).await?);
792 let sha = check!(self.commit_of(&a.repo, &a.viewer, &a.git_ref).await?);
793 let stored = self
794 .db
795 .prepare(format!("SELECT {SUITE_COLUMNS} FROM commit_check_suites s WHERE s.repo_id = ? AND s.head_sha = ? ORDER BY s.created_at DESC"))
796 .bind(&[repo.id.as_str().into(), sha.as_str().into()])?
797 .all()
798 .await?
799 .results::<SuiteRow>()?;
800 let names: HashMap<String, Vec<String>> = match &a.check_name {
801 Some(_) => {
802 let mut by_suite: HashMap<String, Vec<String>> = HashMap::new();
803 for run in self.stored_runs(&repo, std::slice::from_ref(&sha)).await? {
804 by_suite.entry(run.check_suite.id).or_default().push(run.name);
805 }
806 by_suite
807 }
808 None => HashMap::new(),
809 };
810 let details = self.actions_runs(ActionsChecksArgs { repo_id: repo.id.clone(), shas: vec![sha], ..Default::default() }).await?;
811 let mut suites: Vec<CommitCheckSuite> = stored
812 .into_iter()
813 .map(CommitCheckSuite::from)
814 .filter(|suite| a.check_name.as_ref().is_none_or(|name| names.get(&suite.id).is_some_and(|list| list.contains(name))))
815 .collect();
816 suites.extend(
817 details
818 .iter()
819 .filter(|detail| a.check_name.as_ref().is_none_or(|name| detail.jobs.iter().any(|job| job.name == *name)))
820 .map(|detail| run_check_suite(&detail.run, &detail.jobs)),
821 );
822 suites.retain(|suite| a.app.as_deref().is_none_or(|app| suite.app.slug == app));
823 Ok(Outcome::Ok(CheckSuiteList { total_count: u32::try_from(suites.len()).unwrap_or(u32::MAX), check_suites: suites }))
824 }
825
826 /// `get_check_suite`.
827 pub(crate) async fn get_check_suite(&self, a: CheckIdArgs) -> Result<Outcome<CommitCheckSuite>> {
828 let repo = check!(self.readable(&a.repo, &a.viewer).await?);
829 if a.id.starts_with("run_") {
830 let details = self.actions_runs(ActionsChecksArgs { repo_id: repo.id.clone(), run_id: Some(a.id.clone()), ..Default::default() }).await?;
831 return Ok(match details.first() {
832 Some(detail) => Outcome::Ok(run_check_suite(&detail.run, &detail.jobs)),
833 None => no_suite(),
834 });
835 }
836 Ok(match self.stored_suite(&repo, &a.id).await? {
837 Some(suite) => Outcome::Ok(suite),
838 None => no_suite(),
839 })
840 }
841
842 /// Runs a g1t Actions workflow run again, for a job or the run asked for.
843 async fn rerun_actions(&self, actor: &User, repo: &RepoPath, run_id: &str) -> Result<Outcome<bool>> {
844 let rerun: Outcome<Value> = g1t_kit::call(
845 &self.actions,
846 "rerun",
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)847 &RunActionArgs { actor: actor.clone(), repo: repo.clone(), id: run_id.to_owned(), failed_only: false, job: None, debug: false, force: false },
Merge checks: statuses and check runs on every commit848 )
849 .await?;
850 Ok(match rerun {
851 Outcome::Ok(_) => Outcome::Ok(true),
852 Outcome::Fail(failure) => Outcome::Fail(failure),
853 })
854 }
855
856 /// `rerequest_check_run`.
857 pub(crate) async fn rerequest_check_run(&self, a: RerequestArgs) -> Result<Outcome<bool>> {
858 let repo = check!(self.reportable(&a.repo, &a.actor).await?);
859 let Some((run, detail)) = self.any_run(&repo, &a.id).await? else {
860 return Ok(no_check_run());
861 };
862 if let Some(detail) = detail {
863 return self.rerun_actions(&a.actor, &a.repo, &detail.run.id).await;
864 }
865 let event = CheckRunEvent { repo_id: repo.id.clone(), check_run: run, requested_action: None };
866 self.publish("check_run.rerequested", &repo.id, &a.actor, event).await?;
867 Ok(Outcome::Ok(true))
868 }
869
870 /// `rerequest_check_suite`.
871 pub(crate) async fn rerequest_check_suite(&self, a: RerequestArgs) -> Result<Outcome<bool>> {
872 let repo = check!(self.reportable(&a.repo, &a.actor).await?);
873 if a.id.starts_with("run_") {
874 return self.rerun_actions(&a.actor, &a.repo, &a.id).await;
875 }
876 let Some(suite) = self.stored_suite(&repo, &a.id).await? else {
877 return Ok(no_suite());
878 };
879 self.publish("check_suite.rerequested", &repo.id, &a.actor, CheckSuiteEvent { repo_id: repo.id.clone(), check_suite: suite }).await?;
880 Ok(Outcome::Ok(true))
881 }
882
883 /// `request_check_action`.
884 pub(crate) async fn request_check_action(&self, a: RequestActionArgs) -> Result<Outcome<bool>> {
885 let repo = check!(self.reportable(&a.repo, &a.actor).await?);
886 let Some(run) = self.stored_run(&repo, &a.id).await? else {
887 return Ok(no_check_run());
888 };
889 if !run.actions.iter().any(|action| action.identifier == a.identifier) {
890 return Ok(invalid("This check run offers no such action."));
891 }
892 let event = CheckRunEvent { repo_id: repo.id.clone(), check_run: run, requested_action: Some(a.identifier) };
893 self.publish("check_run.requested_action", &repo.id, &a.actor, event).await?;
894 Ok(Outcome::Ok(true))
895 }
896
897 /// `commit_checks`: every check on each commit, in one read of each
898 /// kind for all of them.
899 pub(crate) async fn commit_checks(&self, a: CommitChecksArgs) -> Result<Outcome<BTreeMap<String, CommitChecks>>> {
900 let mut shas: Vec<String> = Vec::new();
901 for sha in a.shas.iter().map(|sha| sha.trim().to_ascii_lowercase()) {
902 if is_full_sha(&sha) && !shas.contains(&sha) {
903 shas.push(sha);
904 }
905 }
906 if shas.len() > MAX_COMMITS {
907 return Ok(invalid(format!("At most {MAX_COMMITS} commits at a time.")));
908 }
909 let repo = check!(self.readable(&a.repo, &a.viewer).await?);
910 if shas.is_empty() {
911 return Ok(Outcome::Ok(BTreeMap::new()));
912 }
913 let (statuses, stored, details) = futures_util::future::try_join3(
914 self.statuses_of(&repo, &shas, false),
915 self.stored_runs(&repo, &shas),
916 self.actions_runs(ActionsChecksArgs { repo_id: repo.id.clone(), shas: shas.clone(), ..Default::default() }),
917 )
918 .await?;
919 let stored = latest_by_name(stored);
920 let pairs: Vec<(g1t_contracts::actions::WorkflowRun, &RunDetail)> =
921 details.iter().map(|detail| (detail.run.clone(), detail)).collect();
922 let latest = latest_runs(&pairs);
923 let mut out = BTreeMap::new();
924 for sha in shas {
925 let mut runs: Vec<CommitCheckRun> = stored.iter().filter(|run| run.head_sha == sha).cloned().collect();
926 let mut job_contexts = Vec::new();
927 for (run, detail) in latest.iter().filter(|(run, _)| run.sha == sha) {
928 if !detail.jobs.is_empty() {
929 job_contexts.push(format!("{} / {}", run.name, run.event));
930 }
931 runs.extend(detail.jobs.iter().map(|job| job_check_run(&full_name(&repo), run, job)));
932 }
933 let mine: Vec<CommitStatus> =
934 statuses.iter().filter(|(of, _)| *of == sha).map(|(_, status)| status.clone()).collect();
935 let summary = summarize(&runs, &mine, &job_contexts);
936 if summary.total > 0 {
937 out.insert(sha, summary);
938 }
939 }
940 Ok(Outcome::Ok(out))
941 }
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947
948 #[test]
949 fn a_check_runs_status_says_what_its_title_says_in_a_line() {
950 assert_eq!(description_of(Some("3 problems"), Some("long")), Some("3 problems".to_owned()));
951 assert_eq!(description_of(None, Some("\n## Coverage 81%\nmore")), Some("## Coverage 81%".to_owned()));
952 assert_eq!(description_of(Some(" "), None), None);
953 let long = "x".repeat(300);
954 let cut = description_of(Some(&long), None).unwrap();
955 assert_eq!(cut.chars().count(), MAX_DESCRIPTION_CHARS);
956 assert!(cut.ends_with('…'));
957 }
958
959 #[test]
960 fn only_each_names_latest_run_counts() {
961 let run = |id: &str, suite: &str, name: &str| CommitCheckRun {
962 id: id.into(),
963 name: name.into(),
964 check_suite: SuiteRef { id: suite.into() },
965 ..CommitCheckRun::default()
966 };
967 let kept = latest_by_name(vec![run("cr_3", "cs_1", "lint"), run("cr_2", "cs_1", "lint"), run("cr_1", "cs_2", "lint")]);
968 let ids: Vec<&str> = kept.iter().map(|run| run.id.as_str()).collect();
969 assert_eq!(ids, ["cr_3", "cr_1"]);
970 }
971}

This file's history is long; its oldest lines are credited to the oldest commit read.