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.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 | ||
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 295 | pub(crate) async fn repo_by_id(&self, repo_id: &str, namespace: &str) -> Result<Option<Repo>> { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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. | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 312 | pub(crate) async fn target_of(&self, pull: &Pull) -> Result<Option<Repo>> { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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, | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 469 | source: Some("g1t".to_owned()), |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 470 | }) |
| 471 | .await?; | |
| 472 | Ok(()) | |
| 473 | } | |
| 474 | ||
| 475 | /// The pull request's code owners as last worked out, with where each | |
| 476 | /// review stands now. | |
| 477 | pub(crate) async fn pull_code_owners(&self, pull: &Pull, comments: &[Comment], settings: &RepoSettings) -> Result<Option<PullCodeOwners>> { | |
| 478 | let Some(row) = self | |
| 479 | .db | |
| 480 | .prepare("SELECT * FROM pull_code_owners WHERE pull_id = ?") | |
| 481 | .bind(&[pull.id.as_str().into()])? | |
| 482 | .first::<SnapshotRow>(None) | |
| 483 | .await? | |
| 484 | else { | |
| 485 | return Ok(None); | |
| 486 | }; | |
| 487 | let requirements: Vec<Requirement> = serde_json::from_str(&row.requirements).unwrap_or_default(); | |
| 488 | let members: HashMap<String, Vec<String>> = serde_json::from_str(&row.members).unwrap_or_default(); | |
| 489 | Ok(Some(standing( | |
| 490 | &row.path, | |
| 491 | settings.require_code_owner_review, | |
| 492 | &requirements, | |
| 493 | &members, | |
| 494 | &latest_verdicts(comments), | |
| 495 | &pull.owner().username, | |
| 496 | row.errors, | |
| 497 | ))) | |
| 498 | } | |
| 499 | ||
| 500 | /// What code owners still have to approve before the pull request may | |
| 501 | /// merge, worked out from the file as it is now; `None` when nothing, | |
| 502 | /// or when the repository does not require it. | |
| 503 | pub(crate) async fn code_owners_gap(&self, settings: &RepoSettings, pull: &Pull) -> Result<Option<String>> { | |
| 504 | if !settings.require_code_owner_review { | |
| 505 | return Ok(None); | |
| 506 | } | |
| 507 | let Some(repo) = self.target_of(pull).await? else { | |
| 508 | return Ok(None); | |
| 509 | }; | |
| 510 | let Some((checked, requirements)) = self.fresh_requirements(&repo, pull).await? else { | |
| 511 | return Ok(None); | |
| 512 | }; | |
| 513 | if requirements.is_empty() { | |
| 514 | return Ok(None); | |
| 515 | } | |
| 516 | let comments = self.comments_of(&pull.repo_id, pull.number).await?; | |
| 517 | let members = checked.members(); | |
| 518 | let standing = standing( | |
| 519 | &checked.path, | |
| 520 | true, | |
| 521 | &requirements, | |
| 522 | &members, | |
| 523 | &latest_verdicts(&comments), | |
| 524 | &pull.owner().username, | |
| 525 | checked.errors.len() as u32, | |
| 526 | ); | |
| 527 | Ok(standing | |
| 528 | .missing | |
| 529 | .map(|missing| format!("{missing} This repository requires code owners' approval before a pull request merges."))) | |
| 530 | } | |
| 531 | ||
| 532 | async fn comments_of(&self, repo_id: &str, number: u32) -> Result<Vec<Comment>> { | |
| 533 | Ok(self | |
| 534 | .db | |
| 535 | .prepare("SELECT * FROM comments WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL ORDER BY id") | |
| 536 | .bind(&[repo_id.into(), number.into()])? | |
| 537 | .all() | |
| 538 | .await? | |
| 539 | .results::<crate::rows::CommentRow>()? | |
| 540 | .into_iter() | |
| 541 | .map(Comment::from) | |
| 542 | .collect()) | |
| 543 | } | |
| 544 | ||
| 545 | /// `codeowners_errors`: the CODEOWNERS file of a repository at a | |
| 546 | /// branch, checked. | |
| 547 | pub(crate) async fn codeowners_errors(&self, a: CodeOwnersErrorsArgs) -> Result<Outcome<CodeOwnersReport>> { | |
| 548 | let repo = match self.repo(&a.repo, &a.viewer).await? { | |
| 549 | Outcome::Ok(repo) => repo, | |
| 550 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 551 | }; | |
| 552 | let git_ref = a | |
| 553 | .git_ref | |
| 554 | .as_deref() | |
| 555 | .map(str::trim) | |
| 556 | .filter(|git_ref| !git_ref.is_empty()) | |
| 557 | .unwrap_or(&repo.default_branch) | |
| 558 | .to_owned(); | |
| 559 | if git_ref.len() > 255 { | |
| 560 | return Ok(Outcome::fail(FailureCode::Invalid, "That ref is too long.")); | |
| 561 | } | |
| 562 | let path = RepoPath { | |
| 563 | namespace: repo.namespace.clone(), | |
| 564 | name: repo.name.clone(), | |
| 565 | }; | |
| 566 | // Read as g1t: the viewer can read the repository, and the file | |
| 567 | // decides who owns it, whoever asks. | |
| 568 | let viewer = Some(User::system(&repo.namespace)); | |
| 569 | let checked = self | |
| 570 | .check_codeowners(&repo.id, &path, &git_ref, &viewer, (&repo.id, &repo.namespace)) | |
| 571 | .await?; | |
| 572 | Ok(Outcome::Ok(match checked { | |
| 573 | None => CodeOwnersReport { | |
| 574 | path: None, | |
| 575 | git_ref, | |
| 576 | ..CodeOwnersReport::default() | |
| 577 | }, | |
| 578 | Some(checked) => { | |
| 579 | let mut sections: Vec<String> = Vec::new(); | |
| 580 | for section in &checked.file.sections { | |
| 581 | if !sections.contains(§ion.name) { | |
| 582 | sections.push(section.name.clone()); | |
| 583 | } | |
| 584 | } | |
| 585 | CodeOwnersReport { | |
| 586 | path: Some(checked.path), | |
| 587 | git_ref, | |
| 588 | size: checked.size, | |
| 589 | rules: checked.file.rules.len() as u32, | |
| 590 | sections, | |
| 591 | errors: checked.errors, | |
| 592 | } | |
| 593 | } | |
| 594 | })) | |
| 595 | } | |
| 596 | } | |
| 597 | ||
| 598 | #[cfg(test)] | |
| 599 | mod tests { | |
| 600 | use super::*; | |
| 601 | use g1t_contracts::work::CommentKind; | |
| 602 | ||
| 603 | fn resolved(owner: &str, check: OwnerCheck, members: &[&str], team: Option<&str>) -> ResolvedOwner { | |
| 604 | ResolvedOwner { | |
| 605 | owner: Owner::parse(owner).unwrap(), | |
| 606 | check, | |
| 607 | members: members.iter().map(|name| (*name).to_owned()).collect(), | |
| 608 | team: team.map(str::to_owned), | |
| 609 | } | |
| 610 | } | |
| 611 | ||
| 612 | fn comment(author: &str, verdict: Option<Verdict>) -> Comment { | |
| 613 | Comment { | |
| 614 | id: format!("cmt_{author}"), | |
| 615 | kind: CommentKind::Comment, | |
| 616 | author: User { | |
| 617 | id: format!("usr_{author}"), | |
| 618 | username: author.to_owned(), | |
| 619 | ..User::default() | |
| 620 | }, | |
| 621 | body: String::new(), | |
| 622 | path: None, | |
| 623 | line: None, | |
| 624 | verdict, | |
| 625 | created_at: String::new(), | |
| 626 | } | |
| 627 | } | |
| 628 | ||
| 629 | const FILE: &str = "\ | |
| 630 | # Everything | |
| 631 | * @acme/platform | |
| 632 | /src/api/ @acme/backend @ana | |
| 633 | *.md docs@example.com | |
| 634 | /vendor/ @ghost | |
| 635 | /infra/ @acme/infra @g1t | |
| 636 | ||
| 637 | [Security][2] @acme/security | |
| 638 | /src/auth/ | |
| 639 | "; | |
| 640 | ||
| 641 | fn owners() -> Vec<ResolvedOwner> { | |
| 642 | vec![ | |
| 643 | resolved("@acme/platform", OwnerCheck::Ok, &["pat", "quinn"], Some("acme/platform")), | |
| 644 | resolved("@acme/backend", OwnerCheck::Ok, &["bo", "cy", "dee"], Some("acme/backend")), | |
| 645 | resolved("@ana", OwnerCheck::Ok, &["ana"], None), | |
| 646 | resolved("docs@example.com", OwnerCheck::Ok, &["wren"], None), | |
| 647 | resolved("@ghost", OwnerCheck::UnknownUser, &[], None), | |
| 648 | resolved("@acme/infra", OwnerCheck::TeamNoAccess, &["ivy"], Some("acme/infra")), | |
| 649 | resolved("@g1t", OwnerCheck::Ok, &["g1t"], None), | |
| 650 | resolved("@acme/security", OwnerCheck::Ok, &["sam", "sky"], Some("acme/security")), | |
| 651 | ] | |
| 652 | } | |
| 653 | ||
| 654 | fn requirements(paths: &[&str]) -> Vec<Requirement> { | |
| 655 | let file = codeowners::parse(FILE); | |
| 656 | let paths: Vec<String> = paths.iter().map(|path| (*path).to_owned()).collect(); | |
| 657 | answerable(file.requirements(&paths), &members_of(&owners())) | |
| 658 | } | |
| 659 | ||
| 660 | #[test] | |
| 661 | fn owners_that_do_not_resolve_own_nothing() { | |
| 662 | // Only @ghost owns vendor/: nothing to ask, nothing to wait for. | |
| 663 | assert!(requirements(&["vendor/lib.c"]).is_empty()); | |
| 664 | // infra/: @acme/infra cannot write, so only @g1t is left. | |
| 665 | let infra = requirements(&["infra/main.tf"]); | |
| 666 | assert_eq!(infra.len(), 1); | |
| 667 | assert_eq!(infra[0].owners, vec![Owner::parse("@g1t").unwrap()]); | |
| 668 | } | |
| 669 | ||
| 670 | #[test] | |
| 671 | fn code_owners_are_asked_once_never_the_author_or_g1t() { | |
| 672 | let needed = requirements(&["src/api/users.rs", "README.md", "infra/main.tf", "src/auth/login.rs"]); | |
| 673 | let (people, teams, asked) = to_ask(&needed, &owners(), "ana", &[], &[], &[]); | |
| 674 | // ana wrote it; docs@example.com is wren; g1t is never asked. | |
| 675 | assert_eq!(people, vec!["wren"]); | |
| 676 | assert_eq!(teams, vec!["acme/platform", "acme/backend", "acme/security"]); | |
| 677 | assert!(asked.contains(&"@ana".to_owned()) && !asked.contains(&"@g1t".to_owned())); | |
| 678 | // Asked before, or already a reviewer: not again. | |
| 679 | let (people, teams, _) = to_ask(&needed, &owners(), "zed", &["ana".into()], &["acme/backend".into()], &asked); | |
| 680 | assert!(people.is_empty() && teams.is_empty()); | |
| 681 | let (people, teams, _) = to_ask(&needed, &owners(), "zed", &["ana".into()], &["acme/backend".into()], &[]); | |
| 682 | assert_eq!(people, vec!["wren"]); | |
| 683 | assert_eq!(teams, vec!["acme/platform", "acme/security"]); | |
| 684 | } | |
| 685 | ||
| 686 | #[test] | |
| 687 | fn merging_waits_for_every_rule_and_each_sections_count() { | |
| 688 | let needed = requirements(&["src/api/users.rs", "src/auth/login.rs"]); | |
| 689 | let members = members_of(&owners()); | |
| 690 | let check = |comments: &[Comment]| standing(".github/CODEOWNERS", true, &needed, &members, &latest_verdicts(comments), "zed", 0); | |
| 691 | assert!(check(&[]).missing.is_some()); | |
| 692 | // A backend approval covers /src/api/; src/auth/ is the platform | |
| 693 | // team's in the default section, and security needs two. | |
| 694 | let one = check(&[comment("cy", Some(Verdict::Approve)), comment("sam", Some(Verdict::Approve))]); | |
| 695 | let missing = one.missing.unwrap(); | |
| 696 | assert!(missing.contains("@acme/security"), "{missing}"); | |
| 697 | assert!(missing.contains("@acme/platform"), "{missing}"); | |
| 698 | assert!(!missing.contains("@acme/backend"), "{missing}"); | |
| 699 | let done = check(&[ | |
| 700 | comment("cy", Some(Verdict::Approve)), | |
| 701 | comment("pat", Some(Verdict::Approve)), | |
| 702 | comment("sam", Some(Verdict::Approve)), | |
| 703 | comment("sky", Some(Verdict::Approve)), | |
| 704 | ]); | |
| 705 | assert_eq!(done.missing, None); | |
| 706 | assert!(done.reviews.iter().all(|review| review.satisfied)); | |
| 707 | // A code owner who asks for changes holds it until they approve. | |
| 708 | let changed = check(&[ | |
| 709 | comment("cy", Some(Verdict::Approve)), | |
| 710 | comment("pat", Some(Verdict::Approve)), | |
| 711 | comment("sam", Some(Verdict::Approve)), | |
| 712 | comment("sky", Some(Verdict::Approve)), | |
| 713 | comment("dee", Some(Verdict::RequestChanges)), | |
| 714 | ]); | |
| 715 | assert!(changed.missing.is_some()); | |
| 716 | let changed_back = [ | |
| 717 | comment("dee", Some(Verdict::RequestChanges)), | |
| 718 | comment("cy", Some(Verdict::Approve)), | |
| 719 | comment("pat", Some(Verdict::Approve)), | |
| 720 | comment("sam", Some(Verdict::Approve)), | |
| 721 | comment("sky", Some(Verdict::Approve)), | |
| 722 | comment("dee", Some(Verdict::Approve)), | |
| 723 | ]; | |
| 724 | assert_eq!(check(&changed_back).missing, None); | |
| 725 | } | |
| 726 | ||
| 727 | #[test] | |
| 728 | fn the_author_and_g1t_count_only_as_the_file_says() { | |
| 729 | // infra/ is owned by @g1t alone (once @acme/infra is dropped). | |
| 730 | let infra = requirements(&["infra/main.tf"]); | |
| 731 | let members = members_of(&owners()); | |
| 732 | let by = |author: &str, comments: &[Comment]| { | |
| 733 | standing("CODEOWNERS", true, &infra, &members, &latest_verdicts(comments), author, 0).missing | |
| 734 | }; | |
| 735 | assert_eq!(by("zed", &[comment("g1t", Some(Verdict::Approve))]), None); | |
| 736 | // Elsewhere g1t's approval does not count. | |
| 737 | let api = requirements(&["src/api/users.rs"]); | |
| 738 | let api_missing = standing("CODEOWNERS", true, &api, &members, &latest_verdicts(&[comment("g1t", Some(Verdict::Approve))]), "zed", 0); | |
| 739 | assert!(api_missing.missing.is_some()); | |
| 740 | // The author approving their own does not count. | |
| 741 | let own = standing("CODEOWNERS", true, &api, &members, &latest_verdicts(&[comment("ana", Some(Verdict::Approve))]), "ana", 0); | |
| 742 | assert!(own.missing.is_some()); | |
| 743 | let other = standing("CODEOWNERS", true, &api, &members, &latest_verdicts(&[comment("ana", Some(Verdict::Approve))]), "zed", 0); | |
| 744 | assert_eq!(other.missing, None); | |
| 745 | } | |
| 746 | ||
| 747 | #[test] | |
| 748 | fn only_verdicts_count_and_the_latest_one() { | |
| 749 | let comments = [ | |
| 750 | comment("bo", Some(Verdict::RequestChanges)), | |
| 751 | comment("cy", None), | |
| 752 | comment("BO", Some(Verdict::Approve)), | |
| 753 | ]; | |
| 754 | let latest = latest_verdicts(&comments); | |
| 755 | assert_eq!(latest.len(), 1); | |
| 756 | assert_eq!(latest[0].username, "bo"); | |
| 757 | assert!(latest[0].approved); | |
| 758 | } | |
| 759 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.