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.
277 async fn actions_runs(&self, args: ActionsChecksArgs) -> Result<Vec<RunDetail>> {
278 // 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.