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 commit | 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::{Work, allowed}; | |
| 27 | ||
| 28 | /// Unwraps an `Outcome`, returning its failure from the enclosing method. | |
| 29 | macro_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 | ||
| 38 | fn optional(value: Option<&str>) -> JsValue { | |
| 39 | value.map_or(JsValue::NULL, JsValue::from) | |
| 40 | } | |
| 41 | ||
| 42 | fn invalid<T>(message: impl Into<String>) -> Outcome<T> { | |
| 43 | Outcome::fail(FailureCode::Invalid, message) | |
| 44 | } | |
| 45 | ||
| 46 | fn no_check_run<T>() -> Outcome<T> { | |
| 47 | Outcome::fail(FailureCode::NotFound, "Check run not found.") | |
| 48 | } | |
| 49 | ||
| 50 | fn 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. | |
| 55 | fn full_name(repo: &Repo) -> String { | |
| 56 | format!("{}/{}", repo.namespace, repo.name) | |
| 57 | } | |
| 58 | ||
| 59 | /// A check run's page on the site. | |
| 60 | fn 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. | |
| 65 | fn 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)] | |
| 76 | struct 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 | ||
| 97 | impl 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)] | |
| 121 | struct 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 | ||
| 135 | impl 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)] | |
| 153 | struct 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)] | |
| 166 | struct 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 | ||
| 177 | impl 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 | ||
| 194 | const 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 | ||
| 197 | 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, | |
| 198 | (SELECT count(DISTINCT name) FROM commit_check_runs r WHERE r.suite_id = s.id) AS runs"; | |
| 199 | ||
| 200 | /// `?, ?, ?` for `count` values. | |
| 201 | fn marks(count: usize) -> String { | |
| 202 | vec!["?"; count].join(", ") | |
| 203 | } | |
| 204 | ||
| 205 | /// Each name's latest check run, of runs newest first. | |
| 206 | fn 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. | |
| 224 | fn 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 | ||
| 236 | impl 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-mode | 247 | check!(crate::retired::not_archived(&repo)); |
| Merge checks: statuses and check runs on every commit | 248 | Ok(Outcome::Ok(repo)) |
| 249 | } | |
| 250 | ||
| 251 | /// The commit `git_ref` names: a full SHA as it is, a branch, tag or | |
| 252 | /// short SHA as the repository resolves it. | |
| 253 | async fn commit_of(&self, path: &RepoPath, viewer: &Viewer, git_ref: &str) -> Result<Outcome<String>> { | |
| 254 | let git_ref = git_ref.trim(); | |
| 255 | if is_full_sha(git_ref) { | |
| 256 | return Ok(Outcome::Ok(git_ref.to_ascii_lowercase())); | |
| 257 | } | |
| 258 | if git_ref.is_empty() { | |
| 259 | return Ok(invalid("Name a commit, branch or tag.")); | |
| 260 | } | |
| 261 | let found: Outcome<Vec<Commit>> = g1t_kit::call( | |
| 262 | &self.repos, | |
| 263 | "log", | |
| 264 | &LogArgs { path: path.clone(), viewer: viewer.clone(), git_ref: Some(git_ref.to_owned()), limit: 1 }, | |
| 265 | ) | |
| 266 | .await?; | |
| 267 | Ok(match found { | |
| 268 | Outcome::Ok(commits) => match commits.into_iter().next() { | |
| 269 | Some(commit) => Outcome::Ok(commit.hash), | |
| 270 | None => Outcome::fail(FailureCode::NotFound, format!("No commit, branch or tag {git_ref}.")), | |
| 271 | }, | |
| 272 | Outcome::Fail(_) => Outcome::fail(FailureCode::NotFound, format!("No commit, branch or tag {git_ref}.")), | |
| 273 | }) | |
| 274 | } | |
| 275 | ||
| 276 | /// g1t Actions' runs and jobs, by commits, by a run or by a job. | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 277 | pub(crate) async fn actions_runs(&self, args: ActionsChecksArgs) -> Result<Vec<RunDetail>> { |
| Merge checks: statuses and check runs on every commit | 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 commit | 848 | ) |
| 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)] | |
| 945 | mod 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.