| 1 | //! 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 | |
| 11 | use std::collections::{BTreeMap, HashMap}; |
| 12 | |
| 13 | use g1t_contracts::access::Capability; |
| 14 | use g1t_contracts::actions::{RunActionArgs, RunDetail}; |
| 15 | use g1t_contracts::checks::*; |
| 16 | use g1t_contracts::repos::{Commit, LogArgs, Repo, RepoPath}; |
| 17 | use g1t_contracts::time::rfc3339; |
| 18 | use g1t_contracts::work::{CommitStatus, SetCommitStatusArgs}; |
| 19 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id}; |
| 20 | use g1t_kit::now_ms; |
| 21 | use serde::Deserialize; |
| 22 | use serde_json::Value; |
| 23 | use worker::Result; |
| 24 | use worker::wasm_bindgen::JsValue; |
| 25 | |
| 26 | use crate::retired::writable; |
| 27 | use crate::{Work, allowed}; |
| 28 | |
| 29 | /// Unwraps an `Outcome`, returning its failure from the enclosing method. |
| 30 | macro_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 | |
| 39 | fn optional(value: Option<&str>) -> JsValue { |
| 40 | value.map_or(JsValue::NULL, JsValue::from) |
| 41 | } |
| 42 | |
| 43 | fn invalid<T>(message: impl Into<String>) -> Outcome<T> { |
| 44 | Outcome::fail(FailureCode::Invalid, message) |
| 45 | } |
| 46 | |
| 47 | fn no_check_run<T>() -> Outcome<T> { |
| 48 | Outcome::fail(FailureCode::NotFound, "Check run not found.") |
| 49 | } |
| 50 | |
| 51 | fn 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. |
| 56 | fn full_name(repo: &Repo) -> String { |
| 57 | format!("{}/{}", repo.namespace, repo.name) |
| 58 | } |
| 59 | |
| 60 | /// A check run's page on the site. |
| 61 | fn 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. |
| 66 | fn 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)] |
| 77 | struct 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 | |
| 98 | impl 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)] |
| 122 | struct 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 | |
| 136 | impl 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)] |
| 154 | struct 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)] |
| 167 | struct 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 | |
| 178 | impl 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 | |
| 195 | const 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 | |
| 198 | const 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. |
| 202 | fn marks(count: usize) -> String { |
| 203 | vec!["?"; count].join(", ") |
| 204 | } |
| 205 | |
| 206 | /// Each name's latest check run, of runs newest first. |
| 207 | fn 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. |
| 225 | fn 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 | |
| 237 | impl 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)] |
| 946 | mod 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 | } |