Skip to content

g1t/services/work/src/commit_checks.rs

972 lines42,234 bytesCodeBlame

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

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