Skip to content

g1t/services/work/src/codeowners.rs

758 lines30,481 bytesCodeBlame
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
34use std::collections::HashMap;
35
36use base64::Engine;
37use g1t_contracts::codeowners::{
38 self, CodeOwners, CodeOwnersErrorsArgs, CodeOwnersReport, LineError, Owner, OwnerCheck, PullCodeOwners, Requirement,
39};
40use g1t_contracts::repos::{BlobArgs, BlobView, GetByIdArgs, RawFile, RawFileArgs, Repo, RepoPath};
41use g1t_contracts::teams::{ResolveOwnersArgs, ResolvedOwner};
42use g1t_contracts::time::rfc3339;
43use g1t_contracts::work::{Comment, Pull, PullStatus, RepoSettings, SetCommitStatusArgs, Verdict};
44use g1t_contracts::{FailureCode, Outcome, User, Viewer};
45use g1t_kit::now_ms;
46use serde::Deserialize;
47use worker::Result;
48
49use crate::Work;
50
51/// The status a pull request that changes a CODEOWNERS file gets.
52pub(crate) const CHECK: &str = "g1t / codeowners";
53
54/// A CODEOWNERS file as read from a branch.
55pub(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.
64pub(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
72impl 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.
81pub(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.
91pub(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.
103pub(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.
120pub(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.
142pub(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)]
187struct SnapshotRow {
188 path: String,
189 requirements: String,
190 members: String,
191 errors: u32,
192 requested: String,
193}
194
195impl 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(&section.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)]
598mod 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}