| 1 | //! CODEOWNERS on pull requests (`g1t_contracts::codeowners`). |
| 2 | //! |
| 3 | //! **Which file.** The one on the branch a pull request merges into, the |
| 4 | //! first of `.g1t/CODEOWNERS`, `.github/CODEOWNERS`, `CODEOWNERS`, |
| 5 | //! `docs/CODEOWNERS` and `.gitlab/CODEOWNERS` that exists. A file over |
| 6 | //! 3 MB is ignored as a whole, and says so in its errors. |
| 7 | //! |
| 8 | //! **Owners.** Identity resolves each owner the file names (`resolve_owners`): |
| 9 | //! people, teams of the repository's workspace (with everyone in their |
| 10 | //! child teams) and confirmed email addresses, each needing the Write role |
| 11 | //! or higher. An owner that does not resolve, or cannot write, is an error |
| 12 | //! of the file and owns nothing; a rule left with no owner that resolves |
| 13 | //! asks for no review. `@g1t` is g1t's agent, which counts only where the |
| 14 | //! file names `@g1t` itself. |
| 15 | //! |
| 16 | //! **Reviews.** When a pull request is opened, marked ready, or pushed to, |
| 17 | //! the owners of the files it changes are asked to review it, once each: |
| 18 | //! people as reviewers, teams as team reviewers (team_reviews.rs). Drafts |
| 19 | //! are asked once they are ready. g1t's agent is never asked this way. |
| 20 | //! What was worked out is kept (`pull_code_owners`) for the pull request's |
| 21 | //! page. |
| 22 | //! |
| 23 | //! **Merging.** With `require_code_owner_review` on, merging waits until |
| 24 | //! every rule that owns a changed file has the approvals its section asks |
| 25 | //! for (one by default) from its owners, and no code owner has asked for |
| 26 | //! changes. The pull request's author, or whoever asked g1t for it, never |
| 27 | //! counts. The rule is worked out afresh from the file as it is at merge |
| 28 | //! time, for people and agents alike, and for the merge queue. |
| 29 | //! |
| 30 | //! **The check.** A pull request that changes a CODEOWNERS file gets a |
| 31 | //! status, `g1t / codeowners`, on its head: a failure naming how many |
| 32 | //! lines have errors, or a success. |
| 33 | |
| 34 | use std::collections::HashMap; |
| 35 | |
| 36 | use base64::Engine; |
| 37 | use g1t_contracts::codeowners::{ |
| 38 | self, CodeOwners, CodeOwnersErrorsArgs, CodeOwnersReport, LineError, Owner, OwnerCheck, PullCodeOwners, Requirement, |
| 39 | }; |
| 40 | use g1t_contracts::repos::{BlobArgs, BlobView, GetByIdArgs, RawFile, RawFileArgs, Repo, RepoPath}; |
| 41 | use g1t_contracts::teams::{ResolveOwnersArgs, ResolvedOwner}; |
| 42 | use g1t_contracts::time::rfc3339; |
| 43 | use g1t_contracts::work::{Comment, Pull, PullStatus, RepoSettings, SetCommitStatusArgs, Verdict}; |
| 44 | use g1t_contracts::{FailureCode, Outcome, User, Viewer}; |
| 45 | use g1t_kit::now_ms; |
| 46 | use serde::Deserialize; |
| 47 | use worker::Result; |
| 48 | |
| 49 | use crate::Work; |
| 50 | |
| 51 | /// The status a pull request that changes a CODEOWNERS file gets. |
| 52 | pub(crate) const CHECK: &str = "g1t / codeowners"; |
| 53 | |
| 54 | /// A CODEOWNERS file as read from a branch. |
| 55 | pub(crate) struct Read { |
| 56 | pub path: String, |
| 57 | pub size: u64, |
| 58 | /// Null when it is over the limit. |
| 59 | pub text: Option<String>, |
| 60 | } |
| 61 | |
| 62 | /// A file read and checked: what it says, who its owners are, and every |
| 63 | /// problem with it. |
| 64 | pub(crate) struct Checked { |
| 65 | pub path: String, |
| 66 | pub size: u64, |
| 67 | pub file: CodeOwners, |
| 68 | pub resolved: Vec<ResolvedOwner>, |
| 69 | pub errors: Vec<LineError>, |
| 70 | } |
| 71 | |
| 72 | impl Checked { |
| 73 | /// Who answers for each owner that resolved. |
| 74 | pub fn members(&self) -> HashMap<String, Vec<String>> { |
| 75 | members_of(&self.resolved) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// Who answers for each owner (by its text, `@acme/backend`) that |
| 80 | /// resolved; owners that did not are left out, so they own nothing. |
| 81 | pub(crate) fn members_of(resolved: &[ResolvedOwner]) -> HashMap<String, Vec<String>> { |
| 82 | resolved |
| 83 | .iter() |
| 84 | .filter(|owner| owner.check == OwnerCheck::Ok) |
| 85 | .map(|owner| (owner.owner.text(), owner.members.clone())) |
| 86 | .collect() |
| 87 | } |
| 88 | |
| 89 | /// The requirements with only owners that resolved, dropping any left |
| 90 | /// with none. |
| 91 | pub(crate) fn answerable(requirements: Vec<Requirement>, members: &HashMap<String, Vec<String>>) -> Vec<Requirement> { |
| 92 | requirements |
| 93 | .into_iter() |
| 94 | .filter_map(|mut requirement| { |
| 95 | requirement.owners.retain(|owner| members.contains_key(&owner.text())); |
| 96 | (!requirement.owners.is_empty()).then_some(requirement) |
| 97 | }) |
| 98 | .collect() |
| 99 | } |
| 100 | |
| 101 | /// Each reviewer's latest verdict, by username, in the order they last gave |
| 102 | /// one. |
| 103 | pub(crate) fn latest_verdicts(comments: &[Comment]) -> Vec<codeowners::Verdict> { |
| 104 | let mut latest: Vec<codeowners::Verdict> = Vec::new(); |
| 105 | for comment in comments { |
| 106 | let Some(verdict) = comment.verdict else { |
| 107 | continue; |
| 108 | }; |
| 109 | let username = comment.author.username.to_lowercase(); |
| 110 | latest.retain(|had| had.username != username); |
| 111 | latest.push(codeowners::Verdict { |
| 112 | username, |
| 113 | approved: verdict == Verdict::Approve, |
| 114 | }); |
| 115 | } |
| 116 | latest |
| 117 | } |
| 118 | |
| 119 | /// Where the reviews a pull request needs stand. |
| 120 | pub(crate) fn standing( |
| 121 | path: &str, |
| 122 | required: bool, |
| 123 | requirements: &[Requirement], |
| 124 | members: &HashMap<String, Vec<String>>, |
| 125 | verdicts: &[codeowners::Verdict], |
| 126 | author: &str, |
| 127 | errors: u32, |
| 128 | ) -> PullCodeOwners { |
| 129 | let lookup = |owner: &Owner| members.get(&owner.text()).cloned().unwrap_or_default(); |
| 130 | let reviews = codeowners::evaluate(requirements, &lookup, verdicts, author); |
| 131 | PullCodeOwners { |
| 132 | path: path.to_owned(), |
| 133 | required, |
| 134 | missing: codeowners::missing(&reviews), |
| 135 | reviews, |
| 136 | errors, |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// Who to ask to review: the people and teams owning what changed, not yet |
| 141 | /// asked by g1t before, not already reviewers, never the author or g1t. |
| 142 | pub(crate) fn to_ask( |
| 143 | requirements: &[Requirement], |
| 144 | resolved: &[ResolvedOwner], |
| 145 | author: &str, |
| 146 | reviewers: &[String], |
| 147 | team_reviewers: &[String], |
| 148 | asked_before: &[String], |
| 149 | ) -> (Vec<String>, Vec<String>, Vec<String>) { |
| 150 | let mut people: Vec<String> = Vec::new(); |
| 151 | let mut teams: Vec<String> = Vec::new(); |
| 152 | let mut owners: Vec<String> = Vec::new(); |
| 153 | for requirement in requirements { |
| 154 | for owner in &requirement.owners { |
| 155 | let text = owner.text(); |
| 156 | if asked_before.contains(&text) || owners.contains(&text) { |
| 157 | continue; |
| 158 | } |
| 159 | let Some(found) = resolved.iter().find(|found| found.owner == *owner && found.check == OwnerCheck::Ok) else { |
| 160 | continue; |
| 161 | }; |
| 162 | match owner { |
| 163 | Owner::Team { .. } => { |
| 164 | let Some(team) = &found.team else { continue }; |
| 165 | owners.push(text); |
| 166 | if !team_reviewers.contains(team) && !teams.contains(team) { |
| 167 | teams.push(team.clone()); |
| 168 | } |
| 169 | } |
| 170 | Owner::User { .. } | Owner::Email { .. } => { |
| 171 | let Some(name) = found.members.first() else { continue }; |
| 172 | if name == g1t_contracts::identity::AGENT_NAME { |
| 173 | continue; |
| 174 | } |
| 175 | owners.push(text); |
| 176 | if !name.eq_ignore_ascii_case(author) && !reviewers.contains(name) && !people.contains(name) { |
| 177 | people.push(name.clone()); |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | (people, teams, owners) |
| 184 | } |
| 185 | |
| 186 | #[derive(Deserialize)] |
| 187 | struct SnapshotRow { |
| 188 | path: String, |
| 189 | requirements: String, |
| 190 | members: String, |
| 191 | errors: u32, |
| 192 | requested: String, |
| 193 | } |
| 194 | |
| 195 | impl Work { |
| 196 | /// The CODEOWNERS file of `path` at `git_ref`, if there is one. |
| 197 | pub(crate) async fn read_codeowners(&self, repo_id: &str, path: &RepoPath, git_ref: &str, viewer: &Viewer) -> Result<Option<Read>> { |
| 198 | for location in codeowners::LOCATIONS { |
| 199 | let found: Outcome<BlobView> = g1t_kit::call( |
| 200 | &self.repos, |
| 201 | "blob", |
| 202 | &BlobArgs { |
| 203 | path: path.clone(), |
| 204 | viewer: viewer.clone(), |
| 205 | git_ref: git_ref.to_owned(), |
| 206 | file_path: location.to_owned(), |
| 207 | }, |
| 208 | ) |
| 209 | .await?; |
| 210 | let Outcome::Ok(blob) = found else { |
| 211 | continue; |
| 212 | }; |
| 213 | if blob.size as usize > codeowners::MAX_BYTES { |
| 214 | return Ok(Some(Read { |
| 215 | path: location.to_owned(), |
| 216 | size: blob.size, |
| 217 | text: None, |
| 218 | })); |
| 219 | } |
| 220 | let text = match blob.text { |
| 221 | Some(text) => Some(text), |
| 222 | // Longer than a page shows: read it whole. |
| 223 | None => { |
| 224 | let raw: Option<RawFile> = g1t_kit::call( |
| 225 | &self.repos, |
| 226 | "raw_file", |
| 227 | &RawFileArgs { |
| 228 | repo_id: repo_id.to_owned(), |
| 229 | git_ref: git_ref.to_owned(), |
| 230 | path: location.to_owned(), |
| 231 | max_bytes: codeowners::MAX_BYTES as u32, |
| 232 | }, |
| 233 | ) |
| 234 | .await?; |
| 235 | raw.and_then(|raw| base64::engine::general_purpose::STANDARD.decode(raw.data).ok()) |
| 236 | .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) |
| 237 | } |
| 238 | }; |
| 239 | return Ok(Some(Read { |
| 240 | path: location.to_owned(), |
| 241 | size: blob.size, |
| 242 | text: Some(text.unwrap_or_default()), |
| 243 | })); |
| 244 | } |
| 245 | Ok(None) |
| 246 | } |
| 247 | |
| 248 | /// Reads, parses and resolves the CODEOWNERS file of a repository at |
| 249 | /// `git_ref`. `owners_repo` is the repository whose access the owners |
| 250 | /// are checked against (the pull request's target, for a head read |
| 251 | /// from a fork). |
| 252 | pub(crate) async fn check_codeowners( |
| 253 | &self, |
| 254 | repo_id: &str, |
| 255 | path: &RepoPath, |
| 256 | git_ref: &str, |
| 257 | viewer: &Viewer, |
| 258 | owners_repo: (&str, &str), |
| 259 | ) -> Result<Option<Checked>> { |
| 260 | let Some(read) = self.read_codeowners(repo_id, path, git_ref, viewer).await? else { |
| 261 | return Ok(None); |
| 262 | }; |
| 263 | let file = match &read.text { |
| 264 | Some(text) => codeowners::parse(text), |
| 265 | None => codeowners::parse(&" ".repeat(codeowners::MAX_BYTES + 1)), |
| 266 | }; |
| 267 | let owners = file.owners(); |
| 268 | let resolved: Vec<ResolvedOwner> = if owners.is_empty() { |
| 269 | Vec::new() |
| 270 | } else { |
| 271 | g1t_kit::call( |
| 272 | &self.identity, |
| 273 | "resolve_owners", |
| 274 | &ResolveOwnersArgs { |
| 275 | repo_id: owners_repo.0.to_owned(), |
| 276 | workspace: owners_repo.1.to_owned(), |
| 277 | owners, |
| 278 | }, |
| 279 | ) |
| 280 | .await? |
| 281 | }; |
| 282 | let checks: HashMap<Owner, OwnerCheck> = resolved.iter().map(|found| (found.owner.clone(), found.check)).collect(); |
| 283 | let mut errors = file.errors.clone(); |
| 284 | errors.extend(codeowners::check_owners(&file, &|owner| checks.get(owner).copied().unwrap_or(OwnerCheck::Ok))); |
| 285 | errors.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.token.cmp(&b.token))); |
| 286 | Ok(Some(Checked { |
| 287 | path: read.path, |
| 288 | size: read.size, |
| 289 | file, |
| 290 | resolved, |
| 291 | errors, |
| 292 | })) |
| 293 | } |
| 294 | |
| 295 | async fn repo_by_id(&self, repo_id: &str, namespace: &str) -> Result<Option<Repo>> { |
| 296 | let found: Outcome<Repo> = g1t_kit::call( |
| 297 | &self.repos, |
| 298 | "get_by_id", |
| 299 | &GetByIdArgs { |
| 300 | id: repo_id.to_owned(), |
| 301 | viewer: Some(User::system(namespace)), |
| 302 | }, |
| 303 | ) |
| 304 | .await?; |
| 305 | Ok(match found { |
| 306 | Outcome::Ok(repo) => Some(repo), |
| 307 | Outcome::Fail(_) => None, |
| 308 | }) |
| 309 | } |
| 310 | |
| 311 | /// The target repository of a pull request, as g1t reads it. |
| 312 | async fn target_of(&self, pull: &Pull) -> Result<Option<Repo>> { |
| 313 | let path: Option<RepoPath> = g1t_kit::call( |
| 314 | &self.repos, |
| 315 | "path_by_id", |
| 316 | &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() }, |
| 317 | ) |
| 318 | .await?; |
| 319 | let Some(path) = path else { |
| 320 | return Ok(None); |
| 321 | }; |
| 322 | self.repo_by_id(&pull.repo_id, &path.namespace).await |
| 323 | } |
| 324 | |
| 325 | /// The pull request's code owners worked out afresh from the branch it |
| 326 | /// merges into: the file, and the reviews the changed files need. |
| 327 | async fn fresh_requirements(&self, repo: &Repo, pull: &Pull) -> Result<Option<(Checked, Vec<Requirement>)>> { |
| 328 | let path = RepoPath { |
| 329 | namespace: repo.namespace.clone(), |
| 330 | name: repo.name.clone(), |
| 331 | }; |
| 332 | let viewer = Some(User::system(&repo.namespace)); |
| 333 | let Some(checked) = self |
| 334 | .check_codeowners(&repo.id, &path, &repo.default_branch, &viewer, (&repo.id, &repo.namespace)) |
| 335 | .await? |
| 336 | else { |
| 337 | return Ok(None); |
| 338 | }; |
| 339 | let files: Vec<String> = pull.files.iter().map(|file| file.path.clone()).collect(); |
| 340 | let requirements = answerable(checked.file.requirements(&files), &checked.members()); |
| 341 | Ok(Some((checked, requirements))) |
| 342 | } |
| 343 | |
| 344 | /// Works out a pull request's code owners after it opened, was marked |
| 345 | /// ready or moved, keeps it for its page, asks them to review, and |
| 346 | /// checks a CODEOWNERS file it changes. Never fails what set it off: a |
| 347 | /// problem is logged. |
| 348 | pub(crate) async fn refresh_code_owners(&self, pull: &Pull) { |
| 349 | if let Err(error) = self.try_refresh_code_owners(pull).await { |
| 350 | worker::console_error!("code owners of {} not worked out: {error}", pull.id); |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | async fn try_refresh_code_owners(&self, pull: &Pull) -> Result<()> { |
| 355 | if !pull.status.is_active() { |
| 356 | return Ok(()); |
| 357 | } |
| 358 | let Some(repo) = self.target_of(pull).await? else { |
| 359 | return Ok(()); |
| 360 | }; |
| 361 | let mut pull = pull.clone(); |
| 362 | if pull.files.is_empty() && pull.head_commit.is_some() { |
| 363 | pull.files = self.refresh_files(&pull).await?; |
| 364 | } |
| 365 | self.check_changed_codeowners(&repo, &pull).await?; |
| 366 | let Some((checked, requirements)) = self.fresh_requirements(&repo, &pull).await? else { |
| 367 | self.db |
| 368 | .prepare("DELETE FROM pull_code_owners WHERE pull_id = ?") |
| 369 | .bind(&[pull.id.as_str().into()])? |
| 370 | .run() |
| 371 | .await?; |
| 372 | return Ok(()); |
| 373 | }; |
| 374 | let before = self |
| 375 | .db |
| 376 | .prepare("SELECT * FROM pull_code_owners WHERE pull_id = ?") |
| 377 | .bind(&[pull.id.as_str().into()])? |
| 378 | .first::<SnapshotRow>(None) |
| 379 | .await?; |
| 380 | let mut asked_before: Vec<String> = before |
| 381 | .as_ref() |
| 382 | .and_then(|row| serde_json::from_str(&row.requested).ok()) |
| 383 | .unwrap_or_default(); |
| 384 | // A draft is asked once it is ready. |
| 385 | if pull.status == PullStatus::Open { |
| 386 | let (people, teams, owners) = to_ask( |
| 387 | &requirements, |
| 388 | &checked.resolved, |
| 389 | &pull.owner().username, |
| 390 | &pull.reviewers, |
| 391 | &pull.team_reviewers, |
| 392 | &asked_before, |
| 393 | ); |
| 394 | if !people.is_empty() || !teams.is_empty() { |
| 395 | self.ask_reviewers(&pull, people, teams, None, true).await?; |
| 396 | } |
| 397 | asked_before.extend(owners); |
| 398 | } |
| 399 | self.db |
| 400 | .prepare( |
| 401 | "INSERT INTO pull_code_owners (pull_id, path, requirements, members, errors, requested, updated_at) |
| 402 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) |
| 403 | ON CONFLICT (pull_id) DO UPDATE SET path = excluded.path, requirements = excluded.requirements, |
| 404 | members = excluded.members, errors = excluded.errors, requested = excluded.requested, |
| 405 | updated_at = excluded.updated_at", |
| 406 | ) |
| 407 | .bind(&[ |
| 408 | pull.id.as_str().into(), |
| 409 | checked.path.as_str().into(), |
| 410 | serde_json::to_string(&requirements)?.into(), |
| 411 | serde_json::to_string(&checked.members())?.into(), |
| 412 | (checked.errors.len() as u32).into(), |
| 413 | serde_json::to_string(&asked_before)?.into(), |
| 414 | rfc3339(now_ms()).into(), |
| 415 | ])? |
| 416 | .run() |
| 417 | .await?; |
| 418 | Ok(()) |
| 419 | } |
| 420 | |
| 421 | /// A pull request that changes a CODEOWNERS file: the file as its head |
| 422 | /// has it, checked, reported as a status on the head. |
| 423 | async fn check_changed_codeowners(&self, repo: &Repo, pull: &Pull) -> Result<()> { |
| 424 | let Some(head) = pull.head_commit.as_deref() else { |
| 425 | return Ok(()); |
| 426 | }; |
| 427 | let Some(changed) = pull |
| 428 | .files |
| 429 | .iter() |
| 430 | .find(|file| codeowners::is_codeowners_path(&file.path)) |
| 431 | .map(|file| file.path.clone()) |
| 432 | else { |
| 433 | return Ok(()); |
| 434 | }; |
| 435 | let (source_id, source) = match (&pull.fork_repo_id, &pull.fork) { |
| 436 | (Some(id), Some(fork)) => (id.clone(), fork.clone()), |
| 437 | _ => ( |
| 438 | repo.id.clone(), |
| 439 | RepoPath { |
| 440 | namespace: repo.namespace.clone(), |
| 441 | name: repo.name.clone(), |
| 442 | }, |
| 443 | ), |
| 444 | }; |
| 445 | let viewer = self.owner_viewer(pull).await?; |
| 446 | let checked = self |
| 447 | .check_codeowners(&source_id, &source, head, &viewer, (&repo.id, &repo.namespace)) |
| 448 | .await?; |
| 449 | let (state, description) = match &checked { |
| 450 | // Deleted: nothing to check. |
| 451 | None => ("success", format!("{changed} was removed")), |
| 452 | Some(checked) if checked.errors.is_empty() => ("success", format!("{} has no errors", checked.path)), |
| 453 | Some(checked) => { |
| 454 | let lines = checked.errors.len(); |
| 455 | ("failure", format!("{} has {lines} {}", checked.path, if lines == 1 { "error" } else { "errors" })) |
| 456 | } |
| 457 | }; |
| 458 | // The pull request's own changes: its head may exist only in its fork. |
| 459 | let target_url = checked |
| 460 | .as_ref() |
| 461 | .map(|_| format!("/{}/{}/pull/{}?tab=changes", repo.namespace, repo.name, pull.number)); |
| 462 | self.set_commit_status(SetCommitStatusArgs { |
| 463 | repo_id: repo.id.clone(), |
| 464 | sha: head.to_owned(), |
| 465 | context: CHECK.to_owned(), |
| 466 | state: state.to_owned(), |
| 467 | description: Some(description), |
| 468 | target_url, |
| 469 | }) |
| 470 | .await?; |
| 471 | Ok(()) |
| 472 | } |
| 473 | |
| 474 | /// The pull request's code owners as last worked out, with where each |
| 475 | /// review stands now. |
| 476 | pub(crate) async fn pull_code_owners(&self, pull: &Pull, comments: &[Comment], settings: &RepoSettings) -> Result<Option<PullCodeOwners>> { |
| 477 | let Some(row) = self |
| 478 | .db |
| 479 | .prepare("SELECT * FROM pull_code_owners WHERE pull_id = ?") |
| 480 | .bind(&[pull.id.as_str().into()])? |
| 481 | .first::<SnapshotRow>(None) |
| 482 | .await? |
| 483 | else { |
| 484 | return Ok(None); |
| 485 | }; |
| 486 | let requirements: Vec<Requirement> = serde_json::from_str(&row.requirements).unwrap_or_default(); |
| 487 | let members: HashMap<String, Vec<String>> = serde_json::from_str(&row.members).unwrap_or_default(); |
| 488 | Ok(Some(standing( |
| 489 | &row.path, |
| 490 | settings.require_code_owner_review, |
| 491 | &requirements, |
| 492 | &members, |
| 493 | &latest_verdicts(comments), |
| 494 | &pull.owner().username, |
| 495 | row.errors, |
| 496 | ))) |
| 497 | } |
| 498 | |
| 499 | /// What code owners still have to approve before the pull request may |
| 500 | /// merge, worked out from the file as it is now; `None` when nothing, |
| 501 | /// or when the repository does not require it. |
| 502 | pub(crate) async fn code_owners_gap(&self, settings: &RepoSettings, pull: &Pull) -> Result<Option<String>> { |
| 503 | if !settings.require_code_owner_review { |
| 504 | return Ok(None); |
| 505 | } |
| 506 | let Some(repo) = self.target_of(pull).await? else { |
| 507 | return Ok(None); |
| 508 | }; |
| 509 | let Some((checked, requirements)) = self.fresh_requirements(&repo, pull).await? else { |
| 510 | return Ok(None); |
| 511 | }; |
| 512 | if requirements.is_empty() { |
| 513 | return Ok(None); |
| 514 | } |
| 515 | let comments = self.comments_of(&pull.repo_id, pull.number).await?; |
| 516 | let members = checked.members(); |
| 517 | let standing = standing( |
| 518 | &checked.path, |
| 519 | true, |
| 520 | &requirements, |
| 521 | &members, |
| 522 | &latest_verdicts(&comments), |
| 523 | &pull.owner().username, |
| 524 | checked.errors.len() as u32, |
| 525 | ); |
| 526 | Ok(standing |
| 527 | .missing |
| 528 | .map(|missing| format!("{missing} This repository requires code owners' approval before a pull request merges."))) |
| 529 | } |
| 530 | |
| 531 | async fn comments_of(&self, repo_id: &str, number: u32) -> Result<Vec<Comment>> { |
| 532 | Ok(self |
| 533 | .db |
| 534 | .prepare("SELECT * FROM comments WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id") |
| 535 | .bind(&[repo_id.into(), number.into()])? |
| 536 | .all() |
| 537 | .await? |
| 538 | .results::<crate::rows::CommentRow>()? |
| 539 | .into_iter() |
| 540 | .map(Comment::from) |
| 541 | .collect()) |
| 542 | } |
| 543 | |
| 544 | /// `codeowners_errors`: the CODEOWNERS file of a repository at a |
| 545 | /// branch, checked. |
| 546 | pub(crate) async fn codeowners_errors(&self, a: CodeOwnersErrorsArgs) -> Result<Outcome<CodeOwnersReport>> { |
| 547 | let repo = match self.repo(&a.repo, &a.viewer).await? { |
| 548 | Outcome::Ok(repo) => repo, |
| 549 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 550 | }; |
| 551 | let git_ref = a |
| 552 | .git_ref |
| 553 | .as_deref() |
| 554 | .map(str::trim) |
| 555 | .filter(|git_ref| !git_ref.is_empty()) |
| 556 | .unwrap_or(&repo.default_branch) |
| 557 | .to_owned(); |
| 558 | if git_ref.len() > 255 { |
| 559 | return Ok(Outcome::fail(FailureCode::Invalid, "That ref is too long.")); |
| 560 | } |
| 561 | let path = RepoPath { |
| 562 | namespace: repo.namespace.clone(), |
| 563 | name: repo.name.clone(), |
| 564 | }; |
| 565 | // Read as g1t: the viewer can read the repository, and the file |
| 566 | // decides who owns it, whoever asks. |
| 567 | let viewer = Some(User::system(&repo.namespace)); |
| 568 | let checked = self |
| 569 | .check_codeowners(&repo.id, &path, &git_ref, &viewer, (&repo.id, &repo.namespace)) |
| 570 | .await?; |
| 571 | Ok(Outcome::Ok(match checked { |
| 572 | None => CodeOwnersReport { |
| 573 | path: None, |
| 574 | git_ref, |
| 575 | ..CodeOwnersReport::default() |
| 576 | }, |
| 577 | Some(checked) => { |
| 578 | let mut sections: Vec<String> = Vec::new(); |
| 579 | for section in &checked.file.sections { |
| 580 | if !sections.contains(§ion.name) { |
| 581 | sections.push(section.name.clone()); |
| 582 | } |
| 583 | } |
| 584 | CodeOwnersReport { |
| 585 | path: Some(checked.path), |
| 586 | git_ref, |
| 587 | size: checked.size, |
| 588 | rules: checked.file.rules.len() as u32, |
| 589 | sections, |
| 590 | errors: checked.errors, |
| 591 | } |
| 592 | } |
| 593 | })) |
| 594 | } |
| 595 | } |
| 596 | |
| 597 | #[cfg(test)] |
| 598 | mod tests { |
| 599 | use super::*; |
| 600 | use g1t_contracts::work::CommentKind; |
| 601 | |
| 602 | fn resolved(owner: &str, check: OwnerCheck, members: &[&str], team: Option<&str>) -> ResolvedOwner { |
| 603 | ResolvedOwner { |
| 604 | owner: Owner::parse(owner).unwrap(), |
| 605 | check, |
| 606 | members: members.iter().map(|name| (*name).to_owned()).collect(), |
| 607 | team: team.map(str::to_owned), |
| 608 | } |
| 609 | } |
| 610 | |
| 611 | fn comment(author: &str, verdict: Option<Verdict>) -> Comment { |
| 612 | Comment { |
| 613 | id: format!("cmt_{author}"), |
| 614 | kind: CommentKind::Comment, |
| 615 | author: User { |
| 616 | id: format!("usr_{author}"), |
| 617 | username: author.to_owned(), |
| 618 | ..User::default() |
| 619 | }, |
| 620 | body: String::new(), |
| 621 | path: None, |
| 622 | line: None, |
| 623 | verdict, |
| 624 | created_at: String::new(), |
| 625 | } |
| 626 | } |
| 627 | |
| 628 | const FILE: &str = "\ |
| 629 | # Everything |
| 630 | * @acme/platform |
| 631 | /src/api/ @acme/backend @ana |
| 632 | *.md docs@example.com |
| 633 | /vendor/ @ghost |
| 634 | /infra/ @acme/infra @g1t |
| 635 | |
| 636 | [Security][2] @acme/security |
| 637 | /src/auth/ |
| 638 | "; |
| 639 | |
| 640 | fn owners() -> Vec<ResolvedOwner> { |
| 641 | vec![ |
| 642 | resolved("@acme/platform", OwnerCheck::Ok, &["pat", "quinn"], Some("acme/platform")), |
| 643 | resolved("@acme/backend", OwnerCheck::Ok, &["bo", "cy", "dee"], Some("acme/backend")), |
| 644 | resolved("@ana", OwnerCheck::Ok, &["ana"], None), |
| 645 | resolved("docs@example.com", OwnerCheck::Ok, &["wren"], None), |
| 646 | resolved("@ghost", OwnerCheck::UnknownUser, &[], None), |
| 647 | resolved("@acme/infra", OwnerCheck::TeamNoAccess, &["ivy"], Some("acme/infra")), |
| 648 | resolved("@g1t", OwnerCheck::Ok, &["g1t"], None), |
| 649 | resolved("@acme/security", OwnerCheck::Ok, &["sam", "sky"], Some("acme/security")), |
| 650 | ] |
| 651 | } |
| 652 | |
| 653 | fn requirements(paths: &[&str]) -> Vec<Requirement> { |
| 654 | let file = codeowners::parse(FILE); |
| 655 | let paths: Vec<String> = paths.iter().map(|path| (*path).to_owned()).collect(); |
| 656 | answerable(file.requirements(&paths), &members_of(&owners())) |
| 657 | } |
| 658 | |
| 659 | #[test] |
| 660 | fn owners_that_do_not_resolve_own_nothing() { |
| 661 | // Only @ghost owns vendor/: nothing to ask, nothing to wait for. |
| 662 | assert!(requirements(&["vendor/lib.c"]).is_empty()); |
| 663 | // infra/: @acme/infra cannot write, so only @g1t is left. |
| 664 | let infra = requirements(&["infra/main.tf"]); |
| 665 | assert_eq!(infra.len(), 1); |
| 666 | assert_eq!(infra[0].owners, vec![Owner::parse("@g1t").unwrap()]); |
| 667 | } |
| 668 | |
| 669 | #[test] |
| 670 | fn code_owners_are_asked_once_never_the_author_or_g1t() { |
| 671 | let needed = requirements(&["src/api/users.rs", "README.md", "infra/main.tf", "src/auth/login.rs"]); |
| 672 | let (people, teams, asked) = to_ask(&needed, &owners(), "ana", &[], &[], &[]); |
| 673 | // ana wrote it; docs@example.com is wren; g1t is never asked. |
| 674 | assert_eq!(people, vec!["wren"]); |
| 675 | assert_eq!(teams, vec!["acme/platform", "acme/backend", "acme/security"]); |
| 676 | assert!(asked.contains(&"@ana".to_owned()) && !asked.contains(&"@g1t".to_owned())); |
| 677 | // Asked before, or already a reviewer: not again. |
| 678 | let (people, teams, _) = to_ask(&needed, &owners(), "zed", &["ana".into()], &["acme/backend".into()], &asked); |
| 679 | assert!(people.is_empty() && teams.is_empty()); |
| 680 | let (people, teams, _) = to_ask(&needed, &owners(), "zed", &["ana".into()], &["acme/backend".into()], &[]); |
| 681 | assert_eq!(people, vec!["wren"]); |
| 682 | assert_eq!(teams, vec!["acme/platform", "acme/security"]); |
| 683 | } |
| 684 | |
| 685 | #[test] |
| 686 | fn merging_waits_for_every_rule_and_each_sections_count() { |
| 687 | let needed = requirements(&["src/api/users.rs", "src/auth/login.rs"]); |
| 688 | let members = members_of(&owners()); |
| 689 | let check = |comments: &[Comment]| standing(".github/CODEOWNERS", true, &needed, &members, &latest_verdicts(comments), "zed", 0); |
| 690 | assert!(check(&[]).missing.is_some()); |
| 691 | // A backend approval covers /src/api/; src/auth/ is the platform |
| 692 | // team's in the default section, and security needs two. |
| 693 | let one = check(&[comment("cy", Some(Verdict::Approve)), comment("sam", Some(Verdict::Approve))]); |
| 694 | let missing = one.missing.unwrap(); |
| 695 | assert!(missing.contains("@acme/security"), "{missing}"); |
| 696 | assert!(missing.contains("@acme/platform"), "{missing}"); |
| 697 | assert!(!missing.contains("@acme/backend"), "{missing}"); |
| 698 | let done = check(&[ |
| 699 | comment("cy", Some(Verdict::Approve)), |
| 700 | comment("pat", Some(Verdict::Approve)), |
| 701 | comment("sam", Some(Verdict::Approve)), |
| 702 | comment("sky", Some(Verdict::Approve)), |
| 703 | ]); |
| 704 | assert_eq!(done.missing, None); |
| 705 | assert!(done.reviews.iter().all(|review| review.satisfied)); |
| 706 | // A code owner who asks for changes holds it until they approve. |
| 707 | let changed = check(&[ |
| 708 | comment("cy", Some(Verdict::Approve)), |
| 709 | comment("pat", Some(Verdict::Approve)), |
| 710 | comment("sam", Some(Verdict::Approve)), |
| 711 | comment("sky", Some(Verdict::Approve)), |
| 712 | comment("dee", Some(Verdict::RequestChanges)), |
| 713 | ]); |
| 714 | assert!(changed.missing.is_some()); |
| 715 | let changed_back = [ |
| 716 | comment("dee", Some(Verdict::RequestChanges)), |
| 717 | comment("cy", Some(Verdict::Approve)), |
| 718 | comment("pat", Some(Verdict::Approve)), |
| 719 | comment("sam", Some(Verdict::Approve)), |
| 720 | comment("sky", Some(Verdict::Approve)), |
| 721 | comment("dee", Some(Verdict::Approve)), |
| 722 | ]; |
| 723 | assert_eq!(check(&changed_back).missing, None); |
| 724 | } |
| 725 | |
| 726 | #[test] |
| 727 | fn the_author_and_g1t_count_only_as_the_file_says() { |
| 728 | // infra/ is owned by @g1t alone (once @acme/infra is dropped). |
| 729 | let infra = requirements(&["infra/main.tf"]); |
| 730 | let members = members_of(&owners()); |
| 731 | let by = |author: &str, comments: &[Comment]| { |
| 732 | standing("CODEOWNERS", true, &infra, &members, &latest_verdicts(comments), author, 0).missing |
| 733 | }; |
| 734 | assert_eq!(by("zed", &[comment("g1t", Some(Verdict::Approve))]), None); |
| 735 | // Elsewhere g1t's approval does not count. |
| 736 | let api = requirements(&["src/api/users.rs"]); |
| 737 | let api_missing = standing("CODEOWNERS", true, &api, &members, &latest_verdicts(&[comment("g1t", Some(Verdict::Approve))]), "zed", 0); |
| 738 | assert!(api_missing.missing.is_some()); |
| 739 | // The author approving their own does not count. |
| 740 | let own = standing("CODEOWNERS", true, &api, &members, &latest_verdicts(&[comment("ana", Some(Verdict::Approve))]), "ana", 0); |
| 741 | assert!(own.missing.is_some()); |
| 742 | let other = standing("CODEOWNERS", true, &api, &members, &latest_verdicts(&[comment("ana", Some(Verdict::Approve))]), "zed", 0); |
| 743 | assert_eq!(other.missing, None); |
| 744 | } |
| 745 | |
| 746 | #[test] |
| 747 | fn only_verdicts_count_and_the_latest_one() { |
| 748 | let comments = [ |
| 749 | comment("bo", Some(Verdict::RequestChanges)), |
| 750 | comment("cy", None), |
| 751 | comment("BO", Some(Verdict::Approve)), |
| 752 | ]; |
| 753 | let latest = latest_verdicts(&comments); |
| 754 | assert_eq!(latest.len(), 1); |
| 755 | assert_eq!(latest[0].username, "bo"); |
| 756 | assert!(latest[0].approved); |
| 757 | } |
| 758 | } |