Skip to content

g1t/crates/contracts/src/codeowners.rs

2,188 lines79,405 bytesCodeBlame
1//! Code owners: who owns which paths in a repository, as a CODEOWNERS
2//! file says, and whether a pull request has the reviews they ask for.
3//!
4//! **Where the file is.** g1t reads one file from the branch a pull
5//! request merges into: the first of [`LOCATIONS`] that exists
6//! ([`pick_location`]). A file larger than [`MAX_BYTES`] is ignored as a
7//! whole, with one error saying so.
8//!
9//! **Rules.** Each line is a pattern, then zero or more owners separated
10//! by whitespace. An owner is a person (`@ana`), a team (`@acme/backend`)
11//! or an email address (`ana@example.com`). For each path, the last rule
12//! that matches it wins, so later lines override earlier ones; a rule
13//! with no owners says the path has none. `#` starts a comment at the
14//! start of a line or after whitespace; `\#` at the start of a pattern
15//! stands for a literal `#`, and `\ ` for a space inside a pattern. Blank
16//! lines, a byte order mark and `\r\n` line ends are all fine.
17//!
18//! **Patterns** follow the CODEOWNERS format, which takes its rules from
19//! ignore files:
20//!
21//! - `*` matches anything but `/`, and `?` one character but `/`.
22//! - `**/` at the start matches in every directory, `/**` at the end
23//! matches everything inside, and `/**/` matches zero or more
24//! directories. `**` anywhere else is the same as `*`.
25//! - A leading `/`, or a `/` anywhere but at the end, anchors the pattern
26//! to the root of the repository. Without one, it matches at any depth.
27//! - A trailing `/` names a directory: it matches everything inside one
28//! of that name, never a file of that name.
29//! - A pattern that matches a directory matches every file under it, so
30//! `docs` owns `docs/a.md` and `x/docs/y/z.md`. The exception is a
31//! pattern whose last part has a wildcard, such as `docs/*` or
32//! `*.md`: it matches files only, so `docs/*` owns `docs/a.md` but not
33//! `docs/sub/a.md`, as the standard says.
34//! - Paths are case-sensitive.
35//!
36//! Negation (`!`) and character ranges (`[a-z]`) are not part of the
37//! standard; a line that uses them is reported and skipped.
38//!
39//! **Sections.** A line like `[Docs]` starts a named section, and the
40//! rules after it belong to it until the next header. Every section,
41//! and the default section before the first header, applies its own last
42//! match, so one path can need reviews from several sections. A header
43//! may say how many approvals the section needs (`[Docs][2]`, from 1 to
44//! 10, 1 when left out), that it is optional (`^[Docs]`: its owners are
45//! asked but not required), and default owners (`[Docs] @acme/writers`)
46//! that a rule in it gets when it names none. Two headers with the same
47//! name, compared without regard to case, are one section: the first
48//! spelling is kept, and a later header's approvals and default owners
49//! replace the earlier ones when it gives them; a later `^` makes the
50//! section optional. A malformed header is reported, and the rules after
51//! it stay in the section before.
52//!
53//! **Reviews.** [`CodeOwners::requirements`] turns a pull request's
54//! changed paths into the reviews they need, [`evaluate`] says where each
55//! stands given the reviewers' latest verdicts, and [`missing`] writes the
56//! sentence a refused merge shows. [`check_owners`] reports owners that
57//! do not resolve to someone who can review.
58
59use std::collections::BTreeSet;
60
61use serde::{Deserialize, Serialize};
62
63/// Where a CODEOWNERS file may be, in the order they are looked for.
64pub const LOCATIONS: [&str; 5] = [
65 ".g1t/CODEOWNERS",
66 ".github/CODEOWNERS",
67 "CODEOWNERS",
68 "docs/CODEOWNERS",
69 ".gitlab/CODEOWNERS",
70];
71
72/// The largest file read; a larger one is ignored with an error.
73pub const MAX_BYTES: usize = 3 * 1024 * 1024;
74
75/// Whether `path` (from the repository root, no leading `/`) is one of
76/// [`LOCATIONS`].
77pub fn is_codeowners_path(path: &str) -> bool {
78 LOCATIONS.contains(&path)
79}
80
81/// The location that is read, given the paths of [`LOCATIONS`] that exist:
82/// the first of them in [`LOCATIONS`] order.
83pub fn pick_location(existing: &[&str]) -> Option<&'static str> {
84 LOCATIONS.into_iter().find(|location| existing.contains(location))
85}
86
87/// Someone a rule names as an owner.
88#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
89#[serde(tag = "kind", rename_all = "snake_case")]
90pub enum Owner {
91 /// A person, by username (lowercase).
92 User { username: String },
93 /// A team, by its workspace and slug (both lowercase).
94 Team { workspace: String, slug: String },
95 /// Whoever has confirmed this address (lowercase).
96 Email { email: String },
97}
98
99/// Whether `text` is a username, workspace or team slug as written in a
100/// CODEOWNERS file: letters, digits, `.`, `_` and `-`, starting with a
101/// letter or digit.
102fn is_name(text: &str) -> bool {
103 let mut chars = text.chars();
104 matches!(chars.next(), Some(first) if first.is_ascii_alphanumeric())
105 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
106}
107
108fn is_email(text: &str) -> bool {
109 let Some((local, domain)) = text.split_once('@') else {
110 return false;
111 };
112 !local.is_empty()
113 && !domain.contains('@')
114 && domain.contains('.')
115 && !domain.starts_with('.')
116 && !domain.ends_with('.')
117 && !domain.contains("..")
118 && !domain.contains('/')
119 && !text.chars().any(|c| c.is_whitespace() || c.is_control())
120}
121
122impl Owner {
123 /// Reads one owner as written: `@ana` is a person, `@acme/backend` a
124 /// team, `ana@example.com` an address. Anything else is `None`.
125 /// Names and addresses are lowercased.
126 pub fn parse(token: &str) -> Option<Owner> {
127 if let Some(rest) = token.strip_prefix('@') {
128 return match rest.split_once('/') {
129 None => is_name(rest).then(|| Owner::User {
130 username: rest.to_ascii_lowercase(),
131 }),
132 Some((workspace, slug)) => (is_name(workspace) && is_name(slug)).then(|| Owner::Team {
133 workspace: workspace.to_ascii_lowercase(),
134 slug: slug.to_ascii_lowercase(),
135 }),
136 };
137 }
138 is_email(token).then(|| Owner::Email {
139 email: token.to_lowercase(),
140 })
141 }
142
143 /// The owner as written in a file: `@ana`, `@acme/backend`,
144 /// `ana@example.com`.
145 pub fn text(&self) -> String {
146 match self {
147 Owner::User { username } => format!("@{username}"),
148 Owner::Team { workspace, slug } => format!("@{workspace}/{slug}"),
149 Owner::Email { email } => email.clone(),
150 }
151 }
152}
153
154/// A named section of the file.
155#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
156pub struct Section {
157 /// As the first header for it spells it, trimmed.
158 pub name: String,
159 /// The line of the first header for it.
160 pub line: u32,
161 /// Its owners are asked to review, but their approval is not required.
162 pub optional: bool,
163 /// Approvals it asks for when it is required (1 unless the header
164 /// says otherwise).
165 pub approvals: u32,
166 /// Owners for its rules that name none.
167 pub default_owners: Vec<Owner>,
168}
169
170/// One line that gives a pattern its owners.
171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
172pub struct Rule {
173 /// 1-based.
174 pub line: u32,
175 /// As written, with `\#` and `\ ` read as `#` and a space.
176 pub pattern: String,
177 /// The valid owners it names, in order; empty when it names none.
178 pub owners: Vec<Owner>,
179 /// Index into `CodeOwners::sections`; None for the default section.
180 pub section: Option<usize>,
181}
182
183/// What is wrong with a line of the file, or with an owner it names.
184#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum ErrorKind {
187 /// The file is over [`MAX_BYTES`] and was ignored.
188 TooLarge,
189 /// A pattern starting with `!`; the line was skipped.
190 Negation,
191 /// A pattern with `[` or `]`; the line was skipped.
192 CharacterRange,
193 /// A pattern that names no path; the line was skipped.
194 BadPattern,
195 /// An owner that is not `@user`, `@workspace/team` or an address.
196 BadOwner,
197 /// A section header that could not be read.
198 BadSection,
199 /// No account has that username.
200 UnknownUser,
201 /// The workspace has no team of that slug.
202 UnknownTeam,
203 /// No account has confirmed that address.
204 UnknownEmail,
205 /// The person cannot write to the repository.
206 NoWriteAccess,
207 /// The team has no write access to the repository.
208 TeamNoAccess,
209}
210
211/// One problem found in the file.
212#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
213pub struct LineError {
214 /// 1-based; 0 for the file as a whole.
215 pub line: u32,
216 pub kind: ErrorKind,
217 /// The owner or pattern at fault, as written.
218 pub token: Option<String>,
219 /// A sentence saying what is wrong and how to fix it.
220 pub message: String,
221}
222
223/// A parsed CODEOWNERS file.
224#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
225pub struct CodeOwners {
226 /// Named sections, in the order their first headers appear.
227 pub sections: Vec<Section>,
228 /// Every rule that was kept, in file order.
229 pub rules: Vec<Rule>,
230 /// Problems with the file, in line order.
231 pub errors: Vec<LineError>,
232}
233
234/// Splits a line into whitespace-separated tokens, stopping at a token
235/// that starts with `#` (a comment). `\ ` and `\#` stand for a space and
236/// `#`; other backslashes are kept for the pattern matcher.
237fn tokens(line: &str) -> Vec<String> {
238 let mut out = Vec::new();
239 let mut chars = line.chars().peekable();
240 loop {
241 while chars.next_if(|c| c.is_whitespace()).is_some() {}
242 match chars.peek() {
243 None | Some('#') => break,
244 _ => {}
245 }
246 let mut token = String::new();
247 while let Some(&c) = chars.peek() {
248 if c.is_whitespace() {
249 break;
250 }
251 chars.next();
252 if c == '\\' {
253 match chars.peek() {
254 Some(&next) if next == ' ' || next == '#' => {
255 token.push(next);
256 chars.next();
257 continue;
258 }
259 _ => {}
260 }
261 }
262 token.push(c);
263 }
264 out.push(token);
265 }
266 out
267}
268
269/// Reads owner tokens into the valid owners and the invalid tokens.
270fn read_owners(words: &[String]) -> (Vec<Owner>, Vec<String>) {
271 let mut owners = Vec::new();
272 let mut bad = Vec::new();
273 for word in words {
274 match Owner::parse(word) {
275 Some(owner) => owners.push(owner),
276 None => bad.push(word.clone()),
277 }
278 }
279 (owners, bad)
280}
281
282/// A section header as read from one line.
283struct Header {
284 name: String,
285 optional: bool,
286 approvals: Option<u32>,
287 owners: Vec<Owner>,
288 bad_owners: Vec<String>,
289}
290
291fn parse_header(text: &str) -> Result<Header, String> {
292 let (optional, rest) = match text.strip_prefix('^') {
293 Some(rest) => (true, rest),
294 None => (false, text),
295 };
296 let rest = rest.strip_prefix('[').unwrap_or(rest);
297 let Some(close) = rest.find(']') else {
298 return Err("The section header has no closing ]; write it as [Name].".into());
299 };
300 let name = rest[..close].trim();
301 if name.is_empty() {
302 return Err("The section header has no name; write it as [Name].".into());
303 }
304 if name.contains('[') {
305 return Err("The section name cannot contain [; write it as [Name].".into());
306 }
307 let mut rest = &rest[close + 1..];
308 let mut approvals = None;
309 if let Some(after) = rest.strip_prefix('[') {
310 let Some(close) = after.find(']') else {
311 return Err("The approval count has no closing ]; write it as [Name][2].".into());
312 };
313 let count = after[..close].trim();
314 let parsed = if !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()) {
315 count.parse::<u32>().ok()
316 } else {
317 None
318 };
319 match parsed {
320 Some(n) if (1..=10).contains(&n) => approvals = Some(n),
321 _ => {
322 return Err(format!(
323 "The approval count [{count}] must be a whole number from 1 to 10."
324 ));
325 }
326 }
327 rest = &after[close + 1..];
328 }
329 if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
330 return Err("Put a space between the section header and its owners.".into());
331 }
332 let (owners, bad_owners) = read_owners(&tokens(rest));
333 Ok(Header {
334 name: name.to_string(),
335 optional,
336 approvals,
337 owners,
338 bad_owners,
339 })
340}
341
342fn bad_owner(line: u32, token: &str) -> LineError {
343 LineError {
344 line,
345 kind: ErrorKind::BadOwner,
346 token: Some(token.to_string()),
347 message: format!("{token} is not an owner; write @username, @workspace/team or an email address."),
348 }
349}
350
351/// Reads a CODEOWNERS file. It never fails: what cannot be read is in
352/// `errors`, and everything else is kept.
353pub fn parse(text: &str) -> CodeOwners {
354 let mut file = CodeOwners::default();
355 if text.len() > MAX_BYTES {
356 file.errors.push(LineError {
357 line: 0,
358 kind: ErrorKind::TooLarge,
359 token: None,
360 message: "The CODEOWNERS file is larger than 3 MB, so none of it applies; make it smaller.".into(),
361 });
362 return file;
363 }
364 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
365 let mut current: Option<usize> = None;
366 for (index, raw) in text.split('\n').enumerate() {
367 let line = u32::try_from(index + 1).unwrap_or(u32::MAX);
368 let raw = raw.strip_suffix('\r').unwrap_or(raw);
369 let trimmed = raw.trim_start();
370 if trimmed.is_empty() || trimmed.starts_with('#') {
371 continue;
372 }
373 if trimmed.starts_with('[') || trimmed.starts_with("^[") {
374 match parse_header(trimmed) {
375 Ok(header) => {
376 for token in &header.bad_owners {
377 file.errors.push(bad_owner(line, token));
378 }
379 current = Some(file.add_section(line, header));
380 }
381 Err(message) => file.errors.push(LineError {
382 line,
383 kind: ErrorKind::BadSection,
384 token: Some(trimmed.trim_end().to_string()),
385 message,
386 }),
387 }
388 continue;
389 }
390 let words = tokens(trimmed);
391 let Some((pattern, owner_words)) = words.split_first() else {
392 continue;
393 };
394 let skip = if pattern.starts_with('!') {
395 Some((
396 ErrorKind::Negation,
397 format!(
398 "{pattern} starts with !, and negation is not supported; this line is skipped. Give the path a later rule with no owners instead."
399 ),
400 ))
401 } else if pattern.contains('[') || pattern.contains(']') {
402 Some((
403 ErrorKind::CharacterRange,
404 format!(
405 "{pattern} uses [ or ], and character ranges are not supported; this line is skipped. Write one rule per name, or use * or ?."
406 ),
407 ))
408 } else if compile(pattern).is_none() {
409 Some((
410 ErrorKind::BadPattern,
411 format!(
412 "{pattern} names no path; this line is skipped. Write a file or directory pattern, such as * or /docs/."
413 ),
414 ))
415 } else {
416 None
417 };
418 if let Some((kind, message)) = skip {
419 file.errors.push(LineError {
420 line,
421 kind,
422 token: Some(pattern.clone()),
423 message,
424 });
425 continue;
426 }
427 let (owners, bad) = read_owners(owner_words);
428 for token in &bad {
429 file.errors.push(bad_owner(line, token));
430 }
431 file.rules.push(Rule {
432 line,
433 pattern: pattern.clone(),
434 owners,
435 section: current,
436 });
437 }
438 file
439}
440
441impl CodeOwners {
442 /// Adds a header's section, or combines it with one of the same name;
443 /// returns the section's index.
444 fn add_section(&mut self, line: u32, header: Header) -> usize {
445 let key = header.name.to_lowercase();
446 if let Some(index) = self.sections.iter().position(|s| s.name.to_lowercase() == key) {
447 let section = &mut self.sections[index];
448 if header.optional {
449 section.optional = true;
450 }
451 if let Some(approvals) = header.approvals {
452 section.approvals = approvals;
453 }
454 if !header.owners.is_empty() {
455 section.default_owners = header.owners;
456 }
457 return index;
458 }
459 self.sections.push(Section {
460 name: header.name,
461 line,
462 optional: header.optional,
463 approvals: header.approvals.unwrap_or(1),
464 default_owners: header.owners,
465 });
466 self.sections.len() - 1
467 }
468
469 fn compiled(&self) -> Vec<Option<Compiled>> {
470 self.rules.iter().map(|rule| compile(&rule.pattern)).collect()
471 }
472
473 /// For each section with a match, in order (default section first),
474 /// the section index and the index of the last rule matching `path`.
475 fn last_matches(&self, compiled: &[Option<Compiled>], path: &str) -> Vec<(Option<usize>, usize)> {
476 let mut found: Vec<Option<usize>> = vec![None; self.sections.len() + 1];
477 let parts = path_parts(path);
478 for (index, rule) in self.rules.iter().enumerate().rev() {
479 let slot = rule.section.map_or(0, |s| s + 1);
480 if slot >= found.len() || found[slot].is_some() {
481 continue;
482 }
483 if compiled[index].as_ref().is_some_and(|c| c.matches(&parts)) {
484 found[slot] = Some(index);
485 }
486 }
487 found
488 .into_iter()
489 .enumerate()
490 .filter_map(|(slot, rule)| rule.map(|rule| (slot.checked_sub(1), rule)))
491 .collect()
492 }
493
494 /// The owners a rule gives: its own, or its section's defaults when it
495 /// names none.
496 fn rule_owners(&self, rule: &Rule) -> Vec<Owner> {
497 if !rule.owners.is_empty() {
498 return rule.owners.clone();
499 }
500 rule.section
501 .and_then(|s| self.sections.get(s))
502 .map(|s| s.default_owners.clone())
503 .unwrap_or_default()
504 }
505
506 fn section_name(&self, section: Option<usize>) -> Option<String> {
507 section.and_then(|s| self.sections.get(s)).map(|s| s.name.clone())
508 }
509
510 /// For one path: in each section (default section first, then named
511 /// sections in file order), the last rule that matches it, with its
512 /// owners (the rule's, or the section's defaults when the rule has
513 /// none and the section has some). A section where the last match has
514 /// no owners contributes an [`Applied`] with empty owners.
515 pub fn owners_of(&self, path: &str) -> Vec<Applied> {
516 let compiled = self.compiled();
517 self.last_matches(&compiled, path)
518 .into_iter()
519 .map(|(section, index)| {
520 let rule = &self.rules[index];
521 Applied {
522 section: self.section_name(section),
523 line: rule.line,
524 pattern: rule.pattern.clone(),
525 owners: self.rule_owners(rule),
526 }
527 })
528 .collect()
529 }
530
531 /// For the changed paths of a pull request: one [`Requirement`] per
532 /// section and rule whose applied owners are not empty, with the paths
533 /// it covers, ordered by section (default first) and then line.
534 pub fn requirements(&self, paths: &[String]) -> Vec<Requirement> {
535 let compiled = self.compiled();
536 // (section slot, rule index, requirement)
537 let mut found: Vec<(usize, usize, Requirement)> = Vec::new();
538 for path in paths {
539 for (section, index) in self.last_matches(&compiled, path) {
540 let rule = &self.rules[index];
541 let owners = self.rule_owners(rule);
542 if owners.is_empty() {
543 continue;
544 }
545 if let Some((_, _, requirement)) = found.iter_mut().find(|(_, i, _)| *i == index) {
546 if !requirement.files.contains(path) {
547 requirement.files.push(path.clone());
548 }
549 continue;
550 }
551 let settings = section.and_then(|s| self.sections.get(s));
552 let optional = settings.is_some_and(|s| s.optional);
553 let approvals = if optional {
554 0
555 } else {
556 settings.map_or(1, |s| s.approvals)
557 };
558 found.push((
559 section.map_or(0, |s| s + 1),
560 index,
561 Requirement {
562 section: self.section_name(section),
563 line: rule.line,
564 pattern: rule.pattern.clone(),
565 owners,
566 approvals,
567 optional,
568 files: vec![path.clone()],
569 },
570 ));
571 }
572 }
573 found.sort_by_key(|(slot, _, requirement)| (*slot, requirement.line));
574 found.into_iter().map(|(_, _, requirement)| requirement).collect()
575 }
576
577 /// Every distinct owner named anywhere (rules and section defaults),
578 /// sorted.
579 pub fn owners(&self) -> Vec<Owner> {
580 let mut all = BTreeSet::new();
581 for rule in &self.rules {
582 all.extend(rule.owners.iter().cloned());
583 }
584 for section in &self.sections {
585 all.extend(section.default_owners.iter().cloned());
586 }
587 all.into_iter().collect()
588 }
589
590 /// Lines that name `owner`, ascending. Default owners of a section
591 /// count at the line of its first header.
592 pub fn lines_naming(&self, owner: &Owner) -> Vec<u32> {
593 let mut lines = BTreeSet::new();
594 for rule in &self.rules {
595 if rule.owners.contains(owner) {
596 lines.insert(rule.line);
597 }
598 }
599 for section in &self.sections {
600 if section.default_owners.contains(owner) {
601 lines.insert(section.line);
602 }
603 }
604 lines.into_iter().collect()
605 }
606}
607
608/// The rule that applies to one path in one section.
609#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
610pub struct Applied {
611 /// The section's name; None for the default section.
612 pub section: Option<String>,
613 pub line: u32,
614 pub pattern: String,
615 /// Empty when the rule says the path has no owners.
616 pub owners: Vec<Owner>,
617}
618
619/// One review the changed files need: one per section and matching rule
620/// with owners.
621#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
622pub struct Requirement {
623 /// The section's name; None for the default section.
624 pub section: Option<String>,
625 pub line: u32,
626 pub pattern: String,
627 pub owners: Vec<Owner>,
628 /// Approvals needed: the section's count, 0 when the section is
629 /// optional.
630 pub approvals: u32,
631 pub optional: bool,
632 /// The changed paths it covers, in the order given.
633 pub files: Vec<String>,
634}
635
636// ---------------------------------------------------------------------
637// Pattern matching
638// ---------------------------------------------------------------------
639
640/// One character position in a pattern part.
641#[derive(Clone, Copy, Debug, PartialEq, Eq)]
642enum Tok {
643 Lit(char),
644 /// Any run of characters, none of them `/`.
645 Star,
646 /// Any one character.
647 One,
648}
649
650/// One `/`-separated part of a pattern.
651#[derive(Clone, Debug, PartialEq, Eq)]
652enum Seg {
653 /// `**` as a whole part: zero or more directories (one or more at the
654 /// end of a pattern).
655 Globstar,
656 Glob(Vec<Tok>),
657}
658
659/// A pattern ready to match.
660#[derive(Clone, Debug)]
661struct Compiled {
662 segs: Vec<Seg>,
663 /// Ended with `/`: matches only directories, so only parents of a path.
664 dir_only: bool,
665 /// Its last part has a wildcard: matches the path itself only.
666 files_only: bool,
667}
668
669fn glob_tokens(part: &str) -> Vec<Tok> {
670 let mut out = Vec::new();
671 let mut chars = part.chars();
672 while let Some(c) = chars.next() {
673 let tok = match c {
674 '\\' => Tok::Lit(chars.next().unwrap_or('\\')),
675 '*' => Tok::Star,
676 '?' => Tok::One,
677 c => Tok::Lit(c),
678 };
679 if tok == Tok::Star && out.last() == Some(&Tok::Star) {
680 continue;
681 }
682 out.push(tok);
683 }
684 out
685}
686
687fn compile(pattern: &str) -> Option<Compiled> {
688 let mut rest = pattern;
689 let mut anchored = false;
690 if let Some(after) = rest.strip_prefix('/') {
691 anchored = true;
692 rest = after;
693 }
694 let dir_only = rest.ends_with('/');
695 let rest = rest.trim_end_matches('/');
696 if rest.contains('/') {
697 anchored = true;
698 }
699 let parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
700 if parts.is_empty() {
701 return None;
702 }
703 let mut segs = Vec::new();
704 if !anchored {
705 segs.push(Seg::Globstar);
706 }
707 for part in parts {
708 if part == "**" {
709 if segs.last() != Some(&Seg::Globstar) {
710 segs.push(Seg::Globstar);
711 }
712 } else {
713 segs.push(Seg::Glob(glob_tokens(part)));
714 }
715 }
716 let files_only = !dir_only
717 && matches!(segs.last(), Some(Seg::Glob(toks)) if toks.iter().any(|t| matches!(t, Tok::Star | Tok::One)));
718 Some(Compiled {
719 segs,
720 dir_only,
721 files_only,
722 })
723}
724
725fn path_parts(path: &str) -> Vec<&str> {
726 path.split('/').filter(|part| !part.is_empty()).collect()
727}
728
729impl Compiled {
730 fn matches(&self, parts: &[&str]) -> bool {
731 if parts.is_empty() {
732 return false;
733 }
734 if !self.dir_only && match_segs(&self.segs, parts) {
735 return true;
736 }
737 if self.files_only {
738 return false;
739 }
740 (1..parts.len()).any(|k| match_segs(&self.segs, &parts[..k]))
741 }
742}
743
744fn match_segs(segs: &[Seg], parts: &[&str]) -> bool {
745 match segs.split_first() {
746 None => parts.is_empty(),
747 Some((Seg::Globstar, [])) => !parts.is_empty(),
748 Some((Seg::Globstar, rest)) => (0..=parts.len()).any(|skip| match_segs(rest, &parts[skip..])),
749 Some((Seg::Glob(toks), rest)) => parts
750 .split_first()
751 .is_some_and(|(first, tail)| match_glob(toks, first) && match_segs(rest, tail)),
752 }
753}
754
755fn match_glob(toks: &[Tok], text: &str) -> bool {
756 let chars: Vec<char> = text.chars().collect();
757 let (mut p, mut t) = (0, 0);
758 let mut star: Option<(usize, usize)> = None;
759 while t < chars.len() {
760 match toks.get(p) {
761 Some(Tok::Star) => {
762 star = Some((p, t));
763 p += 1;
764 continue;
765 }
766 Some(Tok::One) => {
767 p += 1;
768 t += 1;
769 continue;
770 }
771 Some(Tok::Lit(c)) if *c == chars[t] => {
772 p += 1;
773 t += 1;
774 continue;
775 }
776 _ => {}
777 }
778 match star {
779 Some((sp, st)) => {
780 p = sp + 1;
781 t = st + 1;
782 star = Some((sp, st + 1));
783 }
784 None => return false,
785 }
786 }
787 toks[p..].iter().all(|tok| *tok == Tok::Star)
788}
789
790/// Whether a pattern, as a [`Rule`] holds it, matches `path` (from the
791/// repository root, no leading `/`), by the rules in the module docs. A
792/// pattern that names no path matches nothing.
793pub fn pattern_matches(pattern: &str, path: &str) -> bool {
794 compile(pattern).is_some_and(|compiled| compiled.matches(&path_parts(path)))
795}
796
797// ---------------------------------------------------------------------
798// Resolving owners
799// ---------------------------------------------------------------------
800
801/// How an owner named in the file resolved, from the caller's lookups.
802#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
803#[serde(rename_all = "snake_case")]
804pub enum OwnerCheck {
805 /// Someone who can review.
806 Ok,
807 /// No account has the username.
808 UnknownUser,
809 /// The workspace has no team of the slug.
810 UnknownTeam,
811 /// No account has confirmed the address.
812 UnknownEmail,
813 /// The person cannot write to the repository.
814 NoWriteAccess,
815 /// The team has no write access to the repository.
816 TeamNoAccess,
817}
818
819fn check_message(owner: &Owner, check: OwnerCheck) -> Option<(ErrorKind, String)> {
820 let text = owner.text();
821 Some(match check {
822 OwnerCheck::Ok => return None,
823 OwnerCheck::UnknownUser => (ErrorKind::UnknownUser, format!("{text} is not a g1t account.")),
824 OwnerCheck::UnknownTeam => (
825 ErrorKind::UnknownTeam,
826 match owner {
827 Owner::Team { workspace, .. } => format!("{text} is not a team of {workspace}."),
828 _ => format!("{text} is not a team."),
829 },
830 ),
831 OwnerCheck::UnknownEmail => (
832 ErrorKind::UnknownEmail,
833 format!("No g1t account has confirmed {}.", text.trim_start_matches('@')),
834 ),
835 OwnerCheck::NoWriteAccess => (
836 ErrorKind::NoWriteAccess,
837 format!("{text} cannot write to this repository; code owners need the Write role or higher."),
838 ),
839 OwnerCheck::TeamNoAccess => (
840 ErrorKind::TeamNoAccess,
841 format!("{text} has no access to this repository; give the team the Write role or higher."),
842 ),
843 })
844}
845
846/// The errors of resolving each owner: one [`LineError`] per line that
847/// names an owner whose check is not [`OwnerCheck::Ok`], sorted by line
848/// then token. `check` is asked once per distinct owner. Syntax errors
849/// are not repeated here.
850pub fn check_owners(file: &CodeOwners, check: &dyn Fn(&Owner) -> OwnerCheck) -> Vec<LineError> {
851 let mut errors = Vec::new();
852 for owner in file.owners() {
853 let Some((kind, message)) = check_message(&owner, check(&owner)) else {
854 continue;
855 };
856 for line in file.lines_naming(&owner) {
857 errors.push(LineError {
858 line,
859 kind,
860 token: Some(owner.text()),
861 message: message.clone(),
862 });
863 }
864 }
865 errors.sort_by(|a, b| (a.line, &a.token).cmp(&(b.line, &b.token)));
866 errors
867}
868
869// ---------------------------------------------------------------------
870// Reviews
871// ---------------------------------------------------------------------
872
873/// One reviewer's latest verdict.
874#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
875pub struct Verdict {
876 pub username: String,
877 /// True for an approval, false for a request for changes.
878 pub approved: bool,
879}
880
881/// Where one requirement stands.
882#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
883pub struct OwnerReview {
884 /// The section's name; None for the default section.
885 pub section: Option<String>,
886 pub line: u32,
887 pub pattern: String,
888 /// Owners as written ("@acme/backend").
889 pub owners: Vec<String>,
890 pub files: Vec<String>,
891 pub optional: bool,
892 /// Approvals needed.
893 pub required: u32,
894 /// Usernames (lowercase) of owners who approved, in verdict order.
895 pub approved_by: Vec<String>,
896 /// Usernames (lowercase) of owners who asked for changes, in verdict
897 /// order.
898 pub changes_requested_by: Vec<String>,
899 /// Optional, or enough approvals and no request for changes.
900 pub satisfied: bool,
901}
902
903/// Where each requirement stands. `members(owner)` returns the usernames
904/// who may answer for that owner (a person; a team's members, child teams
905/// included; the account an address belongs to). A verdict counts for a
906/// requirement when its username is among the members of any of its
907/// owners and is not `author`; names are compared lowercase.
908pub fn evaluate(
909 requirements: &[Requirement],
910 members: &dyn Fn(&Owner) -> Vec<String>,
911 verdicts: &[Verdict],
912 author: &str,
913) -> Vec<OwnerReview> {
914 let author = author.to_lowercase();
915 requirements
916 .iter()
917 .map(|requirement| {
918 let allowed: BTreeSet<String> = requirement
919 .owners
920 .iter()
921 .flat_map(members)
922 .map(|name| name.to_lowercase())
923 .collect();
924 let mut approved_by: Vec<String> = Vec::new();
925 let mut changes_requested_by: Vec<String> = Vec::new();
926 for verdict in verdicts {
927 let name = verdict.username.to_lowercase();
928 if name == author || !allowed.contains(&name) {
929 continue;
930 }
931 let list = if verdict.approved {
932 &mut approved_by
933 } else {
934 &mut changes_requested_by
935 };
936 if !list.contains(&name) {
937 list.push(name);
938 }
939 }
940 let required = requirement.approvals;
941 let satisfied =
942 requirement.optional || (changes_requested_by.is_empty() && approved_by.len() >= required as usize);
943 OwnerReview {
944 section: requirement.section.clone(),
945 line: requirement.line,
946 pattern: requirement.pattern.clone(),
947 owners: requirement.owners.iter().map(Owner::text).collect(),
948 files: requirement.files.clone(),
949 optional: requirement.optional,
950 required,
951 approved_by,
952 changes_requested_by,
953 satisfied,
954 }
955 })
956 .collect()
957}
958
959/// Joins names as "a", "a or b", "a, b or c".
960fn join(names: &[String], word: &str) -> String {
961 match names {
962 [] => String::new(),
963 [one] => one.clone(),
964 [init @ .., last] => format!("{} {word} {last}", init.join(", ")),
965 }
966}
967
968/// What still stands in the way, for a merge refusal: None when every
969/// review is satisfied. Owners who asked for changes come first, then
970/// those who have not approved; at most three are listed, then "and N
971/// more".
972pub fn missing(reviews: &[OwnerReview]) -> Option<String> {
973 const SHOWN: usize = 3;
974 let open: Vec<&OwnerReview> = reviews.iter().filter(|r| !r.satisfied).collect();
975 if open.is_empty() {
976 return None;
977 }
978 let (changes, approvals): (Vec<&OwnerReview>, Vec<&OwnerReview>) =
979 open.iter().partition(|r| !r.changes_requested_by.is_empty());
980 let more = open.len().saturating_sub(SHOWN);
981 let mut sentences = Vec::new();
982 let mut shown = 0;
983 for review in &changes {
984 if shown == SHOWN {
985 break;
986 }
987 shown += 1;
988 let names: Vec<String> = review.changes_requested_by.iter().map(|n| format!("@{n}")).collect();
989 sentences.push(format!(
990 "{} asked for changes on {} (code owner).",
991 join(&names, "and"),
992 review.pattern
993 ));
994 }
995 let mut items = Vec::new();
996 for review in &approvals {
997 if shown == SHOWN {
998 break;
999 }
1000 shown += 1;
1001 let mut item = format!("{} for {}", join(&review.owners, "or"), review.pattern);
1002 if review.required > 1 {
1003 item.push_str(&format!(
1004 " ({} of {} approvals)",
1005 review.approved_by.len(),
1006 review.required
1007 ));
1008 }
1009 items.push(item);
1010 }
1011 if !items.is_empty() {
1012 if more > 0 {
1013 items.push(format!("and {more} more"));
1014 }
1015 sentences.push(format!("Code owners have not approved: {}.", items.join(", ")));
1016 } else if more > 0 {
1017 sentences.push(format!("And {more} more."));
1018 }
1019 Some(sentences.join(" "))
1020}
1021
1022/// Who owns what a pull request changes, as its page and the API show it.
1023#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1024pub struct PullCodeOwners {
1025 /// Where the file was found on the branch the pull request merges
1026 /// into, such as `.github/CODEOWNERS`.
1027 pub path: String,
1028 /// Whether that branch's protection requires code owners' approval.
1029 pub required: bool,
1030 /// One per section and rule that owns a changed file.
1031 pub reviews: Vec<OwnerReview>,
1032 /// What still stands in the way, as the merge box says it; null when
1033 /// every review is satisfied.
1034 pub missing: Option<String>,
1035 /// How many problems the file has (see the errors view).
1036 pub errors: u32,
1037}
1038
1039/// A repository's CODEOWNERS file at one branch, checked like a linter:
1040/// what `GET /repos/{owner}/{name}/codeowners/errors` answers.
1041#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1042pub struct CodeOwnersReport {
1043 /// Where it was found; null when there is none.
1044 pub path: Option<String>,
1045 /// The branch or commit read.
1046 #[serde(rename = "ref")]
1047 pub git_ref: String,
1048 pub size: u64,
1049 /// How many rules and sections it has.
1050 pub rules: u32,
1051 pub sections: Vec<String>,
1052 /// Every problem, by line: syntax, and owners that do not resolve or
1053 /// cannot write to the repository.
1054 pub errors: Vec<LineError>,
1055}
1056
1057/// `codeowners_errors` on the work service. `ref` defaults to the
1058/// repository's default branch. Needs Read on the repository. Returns
1059/// `Outcome<CodeOwnersReport>`.
1060#[derive(Debug, Serialize, Deserialize)]
1061pub struct CodeOwnersErrorsArgs {
1062 pub viewer: crate::Viewer,
1063 pub repo: crate::repos::RepoPath,
1064 #[serde(default, rename = "ref")]
1065 pub git_ref: Option<String>,
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 fn user(name: &str) -> Owner {
1073 Owner::User { username: name.into() }
1074 }
1075
1076 fn team(workspace: &str, slug: &str) -> Owner {
1077 Owner::Team {
1078 workspace: workspace.into(),
1079 slug: slug.into(),
1080 }
1081 }
1082
1083 fn email(address: &str) -> Owner {
1084 Owner::Email { email: address.into() }
1085 }
1086
1087 /// owners_of as "section:line:owners" strings, "-" for the default
1088 /// section and an empty owner list for "no owners".
1089 fn who(file: &CodeOwners, path: &str) -> Vec<String> {
1090 file.owners_of(path)
1091 .into_iter()
1092 .map(|applied| {
1093 let owners: Vec<String> = applied.owners.iter().map(Owner::text).collect();
1094 format!(
1095 "{}:{}:{}",
1096 applied.section.as_deref().unwrap_or("-"),
1097 applied.line,
1098 owners.join(" ")
1099 )
1100 })
1101 .collect()
1102 }
1103
1104 /// requirements as "section:line:pattern:approvals:files".
1105 fn needs(file: &CodeOwners, paths: &[&str]) -> Vec<String> {
1106 let paths: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
1107 file.requirements(&paths)
1108 .into_iter()
1109 .map(|r| {
1110 format!(
1111 "{}:{}:{}:{}:{}",
1112 r.section.as_deref().unwrap_or("-"),
1113 r.line,
1114 r.pattern,
1115 r.approvals,
1116 r.files.join(",")
1117 )
1118 })
1119 .collect()
1120 }
1121
1122 fn kinds(file: &CodeOwners) -> Vec<(u32, ErrorKind, Option<String>)> {
1123 file.errors.iter().map(|e| (e.line, e.kind, e.token.clone())).collect()
1124 }
1125
1126 // -----------------------------------------------------------------
1127 // Fixtures: the single-section convention
1128 // -----------------------------------------------------------------
1129
1130 /// Written like a large real-world file of the single-section
1131 /// convention, comments and all.
1132 const MONOREPO: &str = "\
1133# This is a comment.
1134# Each line is a file pattern followed by one or more owners.
1135
1136# These owners are the default owners for everything in
1137# the repository. Unless a later match takes precedence,
1138# they are asked to review every pull request.
1139* @global-owner1 @global-owner2
1140
1141# Order is important; the last matching pattern takes the most
1142# precedence.
1143*.js @js-owner #This is an inline comment.
1144
1145# Email addresses work too.
1146*.go docs@example.com
1147
1148# Teams can be owners as well.
1149*.html @acme/octocats
1150
1151# A trailing slash: everything in build/logs at the root.
1152/build/logs/ @doctocat
1153
1154# Files directly in docs at the root, not deeper.
1155docs/* docs@example.com
1156
1157# Any directory named apps, at any depth.
1158apps/ @octocat
1159
1160# The docs directory at the root, and everything under it.
1161/docs/ @doctocat
1162
1163# Several owners.
1164/scripts/ @doctocat @octocat
1165
1166# A directory named logs anywhere.
1167**/logs @octocat
1168
1169# The apps directory at the root, except apps/github, which has no owners.
1170/apps/ @octocat
1171/apps/github
1172";
1173
1174 #[test]
1175 fn monorepo_fixture_parses_cleanly() {
1176 let file = parse(MONOREPO);
1177 assert!(file.errors.is_empty(), "{:?}", file.errors);
1178 assert!(file.sections.is_empty());
1179 assert_eq!(file.rules.len(), 12);
1180 assert_eq!(file.rules[0].line, 7);
1181 assert_eq!(file.rules[0].pattern, "*");
1182 assert_eq!(file.rules[1].owners, vec![user("js-owner")]);
1183 assert_eq!(file.rules[11].pattern, "/apps/github");
1184 assert!(file.rules[11].owners.is_empty());
1185 assert!(file.rules.iter().all(|r| r.section.is_none()));
1186 }
1187
1188 #[test]
1189 fn monorepo_fixture_owners() {
1190 let file = parse(MONOREPO);
1191 let cases: &[(&str, &str)] = &[
1192 ("README.md", "-:7:@global-owner1 @global-owner2"),
1193 ("x/docs/y.md", "-:7:@global-owner1 @global-owner2"),
1194 ("src/app.js", "-:11:@js-owner"),
1195 ("main.go", "-:14:docs@example.com"),
1196 ("web/index.html", "-:17:@acme/octocats"),
1197 // /build/logs/ matches, but **/logs comes later.
1198 ("build/logs/a.txt", "-:35:@octocat"),
1199 ("docs/getting-started.md", "-:29:@doctocat"),
1200 ("docs/build-app/troubleshooting.md", "-:29:@doctocat"),
1201 ("x/apps/y.ts", "-:26:@octocat"),
1202 ("a/b/apps/c/d.ts", "-:26:@octocat"),
1203 ("apps/web/index.ts", "-:38:@octocat"),
1204 ("apps/web/app.js", "-:38:@octocat"),
1205 ("apps/github/main.go", "-:39:"),
1206 ("apps/github", "-:39:"),
1207 ("scripts/deploy.sh", "-:32:@doctocat @octocat"),
1208 ("deeply/nested/logs/z.log", "-:35:@octocat"),
1209 ("logs", "-:35:@octocat"),
1210 ];
1211 for (path, expected) in cases {
1212 assert_eq!(who(&file, path), vec![expected.to_string()], "{path}");
1213 }
1214 }
1215
1216 #[test]
1217 fn monorepo_fixture_requirements() {
1218 let file = parse(MONOREPO);
1219 assert_eq!(
1220 needs(
1221 &file,
1222 &[
1223 "README.md",
1224 "src/app.js",
1225 "apps/github/x.go",
1226 "lib/util.js",
1227 "docs/a.md",
1228 "scripts/run.sh",
1229 "build/logs/today.log",
1230 ]
1231 ),
1232 vec![
1233 "-:7:*:1:README.md",
1234 "-:11:*.js:1:src/app.js,lib/util.js",
1235 "-:29:/docs/:1:docs/a.md",
1236 "-:32:/scripts/:1:scripts/run.sh",
1237 "-:35:**/logs:1:build/logs/today.log",
1238 ]
1239 );
1240 }
1241
1242 /// Written like a real-world file for a service workspace: crates,
1243 /// lock files, CI and a release owner, with emails and teams.
1244 const WORKSPACE: &str = "\
1245# Code owners for the platform workspace.
1246# Keep this file sorted from broad to narrow.
1247
1248* @acme/platform
1249
1250# Build and CI
1251/.g1t/ @acme/infra
1252/.g1t/workflows/release.yml @release-captain release@acme.dev
1253Cargo.lock @acme/infra
1254/Cargo.toml @acme/infra @acme/platform
1255
1256# Crates
1257/crates/contracts/ @acme/api-guild
1258/crates/*/migrations/ @dba
1259/crates/**/tests/ @acme/qa
1260/crates/web/src/**/*.css @designer
1261
1262# Generated files have no owners.
1263/crates/contracts/src/generated/
1264
1265# Documentation
1266*.md @acme/docs
1267/crates/web/README.md @Web-Lead
1268";
1269
1270 #[test]
1271 fn workspace_fixture_owners() {
1272 let file = parse(WORKSPACE);
1273 assert!(file.errors.is_empty(), "{:?}", file.errors);
1274 let cases: &[(&str, &str)] = &[
1275 ("src/main.rs", "-:4:@acme/platform"),
1276 (".g1t/workflows/ci.yml", "-:7:@acme/infra"),
1277 (".g1t/workflows/release.yml", "-:8:@release-captain release@acme.dev"),
1278 ("Cargo.lock", "-:9:@acme/infra"),
1279 ("crates/web/Cargo.lock", "-:9:@acme/infra"),
1280 ("Cargo.toml", "-:10:@acme/infra @acme/platform"),
1281 ("crates/web/Cargo.toml", "-:4:@acme/platform"),
1282 ("crates/contracts/src/lib.rs", "-:13:@acme/api-guild"),
1283 ("crates/store/migrations/0001.sql", "-:14:@dba"),
1284 ("crates/store/src/migrations/0001.sql", "-:4:@acme/platform"),
1285 ("crates/store/tests/a.rs", "-:15:@acme/qa"),
1286 ("crates/store/src/deep/tests/a.rs", "-:15:@acme/qa"),
1287 ("crates/web/src/a/b/site.css", "-:16:@designer"),
1288 ("crates/web/src/site.css", "-:16:@designer"),
1289 ("crates/web/site.css", "-:4:@acme/platform"),
1290 ("crates/contracts/src/generated/types.rs", "-:19:"),
1291 ("crates/contracts/src/generated/README.md", "-:22:@acme/docs"),
1292 ("README.md", "-:22:@acme/docs"),
1293 ("crates/web/README.md", "-:23:@web-lead"),
1294 ];
1295 for (path, expected) in cases {
1296 assert_eq!(who(&file, path), vec![expected.to_string()], "{path}");
1297 }
1298 assert_eq!(
1299 needs(
1300 &file,
1301 &[
1302 "crates/contracts/src/generated/types.rs",
1303 "crates/contracts/src/a.rs",
1304 "README.md",
1305 "Cargo.lock"
1306 ]
1307 ),
1308 vec![
1309 "-:9:Cargo.lock:1:Cargo.lock",
1310 "-:13:/crates/contracts/:1:crates/contracts/src/a.rs",
1311 "-:22:*.md:1:README.md",
1312 ]
1313 );
1314 }
1315
1316 /// Written like a real-world front-end file, saved on another system:
1317 /// a byte order mark, CRLF line ends, escapes and inline comments.
1318 const FRONTEND: &str = "\u{feff}# Front end\r\n\
1319* @ana\r\n\
1320\r\n\
1321 # indented comment\r\n\
1322**/*.test.ts @acme/qa # tests\r\n\
1323/package.json @ana @bo\r\n\
1324docs/My\\ Notes.md @writer\r\n\
1325\\#hashtag.txt @bo\r\n\
1326/src/components/ @acme/ui\r\n\
1327/src/components/legacy/**\r\n\
1328/*.config.js @tooling\r\n\
1329?.txt @single\r\n";
1330
1331 #[test]
1332 fn frontend_fixture_owners() {
1333 let file = parse(FRONTEND);
1334 assert!(file.errors.is_empty(), "{:?}", file.errors);
1335 assert_eq!(file.rules[0].line, 2);
1336 assert_eq!(file.rules[3].pattern, "docs/My Notes.md");
1337 assert_eq!(file.rules[4].pattern, "#hashtag.txt");
1338 let cases: &[(&str, &str)] = &[
1339 ("index.ts", "-:2:@ana"),
1340 ("src/a.test.ts", "-:5:@acme/qa"),
1341 ("a.test.ts", "-:5:@acme/qa"),
1342 ("package.json", "-:6:@ana @bo"),
1343 ("web/package.json", "-:2:@ana"),
1344 ("docs/My Notes.md", "-:7:@writer"),
1345 ("#hashtag.txt", "-:8:@bo"),
1346 ("x/#hashtag.txt", "-:8:@bo"),
1347 ("src/components/Button.tsx", "-:9:@acme/ui"),
1348 ("src/components/Button.test.ts", "-:9:@acme/ui"),
1349 ("src/components/legacy/Old.tsx", "-:10:"),
1350 ("src/components/legacy", "-:9:@acme/ui"),
1351 ("vite.config.js", "-:11:@tooling"),
1352 ("web/vite.config.js", "-:2:@ana"),
1353 ("a.txt", "-:12:@single"),
1354 ("ab.txt", "-:2:@ana"),
1355 ];
1356 for (path, expected) in cases {
1357 assert_eq!(who(&file, path), vec![expected.to_string()], "{path}");
1358 }
1359 assert_eq!(
1360 needs(
1361 &file,
1362 &[
1363 "src/components/legacy/Old.tsx",
1364 "package.json",
1365 "index.ts",
1366 "src/x.test.ts"
1367 ]
1368 ),
1369 vec![
1370 "-:2:*:1:index.ts",
1371 "-:5:**/*.test.ts:1:src/x.test.ts",
1372 "-:6:/package.json:1:package.json",
1373 ]
1374 );
1375 }
1376
1377 // -----------------------------------------------------------------
1378 // Fixtures: the sections convention
1379 // -----------------------------------------------------------------
1380
1381 /// Written like a real-world file of the sections convention.
1382 const SECTIONED: &str = "\
1383# Default owners, before any section
1384* @admins
1385
1386[Documentation][2] @acme/writers @lead
1387docs/
1388*.md
1389README.md @ana
1390
1391^[Frontend]
1392app/assets/ @fe-lead
1393*.css @designer
1394
1395[Backend] @acme/backend
1396app/models/
1397lib/
1398lib/vendor/ @vendor-keeper
1399
1400[documentation]
1401CONTRIBUTING.md @bo
1402";
1403
1404 #[test]
1405 fn sectioned_fixture_sections() {
1406 let file = parse(SECTIONED);
1407 assert!(file.errors.is_empty(), "{:?}", file.errors);
1408 assert_eq!(
1409 file.sections,
1410 vec![
1411 Section {
1412 name: "Documentation".into(),
1413 line: 4,
1414 optional: false,
1415 approvals: 2,
1416 default_owners: vec![team("acme", "writers"), user("lead")],
1417 },
1418 Section {
1419 name: "Frontend".into(),
1420 line: 9,
1421 optional: true,
1422 approvals: 1,
1423 default_owners: vec![],
1424 },
1425 Section {
1426 name: "Backend".into(),
1427 line: 13,
1428 optional: false,
1429 approvals: 1,
1430 default_owners: vec![team("acme", "backend")],
1431 },
1432 ]
1433 );
1434 let contributing = file.rules.iter().find(|r| r.pattern == "CONTRIBUTING.md").unwrap();
1435 assert_eq!(contributing.section, Some(0));
1436 assert_eq!(file.rules[0].section, None);
1437 }
1438
1439 #[test]
1440 fn sectioned_fixture_owners() {
1441 let file = parse(SECTIONED);
1442 let cases: &[(&str, &[&str])] = &[
1443 ("main.c", &["-:2:@admins"]),
1444 ("docs/guide.md", &["-:2:@admins", "Documentation:6:@acme/writers @lead"]),
1445 (
1446 "docs/img/a.png",
1447 &["-:2:@admins", "Documentation:5:@acme/writers @lead"],
1448 ),
1449 ("README.md", &["-:2:@admins", "Documentation:7:@ana"]),
1450 ("CONTRIBUTING.md", &["-:2:@admins", "Documentation:19:@bo"]),
1451 ("app/assets/x.css", &["-:2:@admins", "Frontend:11:@designer"]),
1452 ("app/assets/x.js", &["-:2:@admins", "Frontend:10:@fe-lead"]),
1453 ("app/models/user.rb", &["-:2:@admins", "Backend:14:@acme/backend"]),
1454 ("lib/b.rb", &["-:2:@admins", "Backend:15:@acme/backend"]),
1455 ("lib/vendor/a.rb", &["-:2:@admins", "Backend:16:@vendor-keeper"]),
1456 (
1457 "lib/notes.md",
1458 &[
1459 "-:2:@admins",
1460 "Documentation:6:@acme/writers @lead",
1461 "Backend:15:@acme/backend",
1462 ],
1463 ),
1464 ];
1465 for (path, expected) in cases {
1466 assert_eq!(who(&file, path), *expected, "{path}");
1467 }
1468 }
1469
1470 #[test]
1471 fn sectioned_fixture_requirements() {
1472 let file = parse(SECTIONED);
1473 let paths = ["docs/guide.md", "README.md", "app/assets/x.css", "lib/b.rb", "lib/c.rb"];
1474 assert_eq!(
1475 needs(&file, &paths),
1476 vec![
1477 "-:2:*:1:docs/guide.md,README.md,app/assets/x.css,lib/b.rb,lib/c.rb",
1478 "Documentation:6:*.md:2:docs/guide.md",
1479 "Documentation:7:README.md:2:README.md",
1480 "Frontend:11:*.css:0:app/assets/x.css",
1481 "Backend:15:lib/:1:lib/b.rb,lib/c.rb",
1482 ]
1483 );
1484 let paths: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
1485 let reqs = file.requirements(&paths);
1486 assert!(reqs[3].optional);
1487 assert_eq!(reqs[1].owners, vec![team("acme", "writers"), user("lead")]);
1488 }
1489
1490 /// The sections convention with optional counts, a header that later
1491 /// changes its section's settings, rules without owners in a section
1492 /// without defaults, and broken headers in between.
1493 const SECTIONED_SETTINGS: &str = "\
1494*.rb @ruby
1495
1496^[Security][3] @acme/security
1497/config/secrets/
1498*.key
1499
1500[Database] @dba
1501db/
1502db/schema.rb
1503[Database
1504db/seeds.rb @seed-keeper
1505
1506[Release]
1507VERSION @release
1508
1509[database][2] @acme/data
1510db/migrate/
1511
1512[Release][0]
1513CHANGELOG.md @scribe
1514";
1515
1516 #[test]
1517 fn sectioned_settings_fixture() {
1518 let file = parse(SECTIONED_SETTINGS);
1519 assert_eq!(
1520 kinds(&file),
1521 vec![
1522 (10, ErrorKind::BadSection, Some("[Database".into())),
1523 (19, ErrorKind::BadSection, Some("[Release][0]".into())),
1524 ]
1525 );
1526 let security = &file.sections[0];
1527 assert_eq!(security.name, "Security");
1528 assert!(security.optional);
1529 let database = &file.sections[1];
1530 assert_eq!(database.name, "Database");
1531 assert_eq!(database.line, 7);
1532 assert_eq!(database.approvals, 2);
1533 assert_eq!(database.default_owners, vec![team("acme", "data")]);
1534 // After the unclosed header, rules stay in Database; after the
1535 // [0] header, they stay in the second Database header's section.
1536 let seeds = file.rules.iter().find(|r| r.pattern == "db/seeds.rb").unwrap();
1537 assert_eq!(seeds.section, Some(1));
1538 let changelog = file.rules.iter().find(|r| r.pattern == "CHANGELOG.md").unwrap();
1539 assert_eq!(changelog.section, Some(1));
1540
1541 let cases: &[(&str, &[&str])] = &[
1542 ("app/user.rb", &["-:1:@ruby"]),
1543 ("config/secrets/prod.key", &["Security:5:@acme/security"]),
1544 ("config/secrets/prod.yml", &["Security:4:@acme/security"]),
1545 ("db/schema.rb", &["-:1:@ruby", "Database:9:@acme/data"]),
1546 ("db/seeds.rb", &["-:1:@ruby", "Database:11:@seed-keeper"]),
1547 ("db/migrate/001.rb", &["-:1:@ruby", "Database:17:@acme/data"]),
1548 ("VERSION", &["Release:14:@release"]),
1549 ("CHANGELOG.md", &["Database:20:@scribe"]),
1550 ];
1551 for (path, expected) in cases {
1552 assert_eq!(who(&file, path), *expected, "{path}");
1553 }
1554 assert_eq!(
1555 needs(&file, &["config/secrets/a.key", "db/migrate/002.rb", "VERSION"]),
1556 vec![
1557 "-:1:*.rb:1:db/migrate/002.rb",
1558 "Security:5:*.key:0:config/secrets/a.key",
1559 "Database:17:db/migrate/:2:db/migrate/002.rb",
1560 "Release:14:VERSION:1:VERSION",
1561 ]
1562 );
1563 }
1564
1565 #[test]
1566 fn rule_without_owners_in_section_without_defaults_has_none() {
1567 let file = parse("[Docs]\ndocs/ @ana\ndocs/drafts/\n");
1568 assert_eq!(who(&file, "docs/a.md"), vec!["Docs:2:@ana"]);
1569 assert_eq!(who(&file, "docs/drafts/a.md"), vec!["Docs:3:"]);
1570 assert!(needs(&file, &["docs/drafts/a.md"]).is_empty());
1571 }
1572
1573 #[test]
1574 fn optional_header_with_count_needs_no_approvals() {
1575 let file = parse("^[Style][2] @lint\n*.css\n");
1576 assert!(file.sections[0].optional);
1577 assert_eq!(file.sections[0].approvals, 2);
1578 let reqs = file.requirements(&["a.css".to_string()]);
1579 assert_eq!(reqs[0].approvals, 0);
1580 assert!(reqs[0].optional);
1581 }
1582
1583 #[test]
1584 fn later_header_settings_replace_earlier() {
1585 let file = parse("[A][3] @one\nx @x\n^[a]\n[A] @two\n");
1586 let section = &file.sections[0];
1587 assert_eq!(section.name, "A");
1588 assert_eq!(section.approvals, 3);
1589 assert!(section.optional);
1590 assert_eq!(section.default_owners, vec![user("two")]);
1591 assert_eq!(file.sections.len(), 1);
1592 }
1593
1594 // -----------------------------------------------------------------
1595 // Pattern matching
1596 // -----------------------------------------------------------------
1597
1598 fn table(cases: &[(&str, &str, bool)]) {
1599 for (pattern, path, expected) in cases {
1600 assert_eq!(pattern_matches(pattern, path), *expected, "{pattern} vs {path}");
1601 }
1602 }
1603
1604 #[test]
1605 fn single_star_stays_within_a_part() {
1606 table(&[
1607 ("*", "a", true),
1608 ("*", "a/b/c", true),
1609 ("*.js", "a.js", true),
1610 ("*.js", "a/b/c.js", true),
1611 ("*.js", "a.jsx", false),
1612 ("*.js", "a.js/b.txt", false),
1613 ("src/*.js", "src/a.js", true),
1614 ("src/*.js", "src/x/a.js", false),
1615 ("a*b", "ab", true),
1616 ("a*b", "axxb", true),
1617 ("a*b", "a/b", false),
1618 ("a**b", "axxb", true),
1619 ("a**b", "a/x/b", false),
1620 ]);
1621 }
1622
1623 #[test]
1624 fn question_mark_is_one_character() {
1625 table(&[
1626 ("?.txt", "a.txt", true),
1627 ("?.txt", "ab.txt", false),
1628 ("?.txt", ".txt", false),
1629 ("a?c", "abc", true),
1630 ("a?c", "a/c", false),
1631 ("x/?", "x/y", true),
1632 ("x/?", "x/yz", false),
1633 ]);
1634 }
1635
1636 #[test]
1637 fn double_star() {
1638 table(&[
1639 ("a/**/b", "a/b", true),
1640 ("a/**/b", "a/x/b", true),
1641 ("a/**/b", "a/x/y/b", true),
1642 ("a/**/b", "a/x/y/c", false),
1643 ("a/**/b", "x/a/b", false),
1644 ("a/**/b", "a/x/b/inner.txt", true),
1645 ("**/foo", "foo", true),
1646 ("**/foo", "x/foo", true),
1647 ("**/foo", "x/y/foo", true),
1648 ("**/foo", "x/foo/bar", true),
1649 ("**/foo", "x/foobar", false),
1650 ("foo/**", "foo/a", true),
1651 ("foo/**", "foo/a/b", true),
1652 ("foo/**", "foo", false),
1653 ("foo/**", "x/foo/a", false),
1654 ("/foo/**", "foo/a", true),
1655 ("**", "anything/at/all", true),
1656 ("**/*.md", "a.md", true),
1657 ("**/*.md", "x/y/a.md", true),
1658 ("src/**/*.rs", "src/main.rs", true),
1659 ("src/**/*.rs", "src/a/b/main.rs", true),
1660 ("src/**/*.rs", "lib/main.rs", false),
1661 ("a/**/**/b", "a/b", true),
1662 ("a/**/**/b", "a/x/y/b", true),
1663 ]);
1664 }
1665
1666 #[test]
1667 fn anchoring() {
1668 table(&[
1669 ("/*.md", "README.md", true),
1670 ("/*.md", "docs/README.md", false),
1671 ("docs/*", "docs/a.md", true),
1672 ("docs/*", "x/docs/a.md", false),
1673 ("docs/*", "docs/sub/a.md", false),
1674 ("/docs", "docs/a.md", true),
1675 ("/docs", "x/docs/a.md", false),
1676 ("docs", "docs/x", true),
1677 ("docs", "a/docs/x/y", true),
1678 ("docs", "docs", true),
1679 ("docs", "mydocs/x", false),
1680 ("/build/logs", "build/logs/a.txt", true),
1681 ("/build/logs", "build/logs", true),
1682 ("/build/logs", "x/build/logs/a.txt", false),
1683 ("build/logs", "x/build/logs/a.txt", false),
1684 ("a/b", "a/b/c/d", true),
1685 ]);
1686 }
1687
1688 #[test]
1689 fn trailing_slash_is_directory_only() {
1690 table(&[
1691 ("logs/", "logs/a", true),
1692 ("logs/", "x/logs/a", true),
1693 ("logs/", "x/y/logs/a/b", true),
1694 ("logs/", "logs", false),
1695 ("logs/", "x/logs", false),
1696 ("/logs/", "logs/a", true),
1697 ("/logs/", "x/logs/a", false),
1698 ("apps/", "apps/web/index.ts", true),
1699 ("apps/", "x/apps/y.ts", true),
1700 ("/build/logs/", "build/logs/a.txt", true),
1701 ("/build/logs/", "build/logs", false),
1702 ("crates/*/migrations/", "crates/a/migrations/1.sql", true),
1703 ("crates/*/migrations/", "crates/a/b/migrations/1.sql", false),
1704 ("foo/**/", "foo/a/b", true),
1705 ("foo/**/", "foo/a", false),
1706 ]);
1707 }
1708
1709 #[test]
1710 fn case_and_escapes() {
1711 table(&[
1712 ("README.md", "readme.md", false),
1713 ("README.md", "README.md", true),
1714 ("*.MD", "a.md", false),
1715 ("Docs/", "docs/a", false),
1716 ("a\\*b", "a*b", true),
1717 ("a\\*b", "axb", false),
1718 ("a\\?b", "a?b", true),
1719 ("My Notes.md", "x/My Notes.md", true),
1720 ("#notes", "#notes", true),
1721 ]);
1722 }
1723
1724 #[test]
1725 fn patterns_naming_no_path() {
1726 table(&[
1727 ("/", "a", false),
1728 ("//", "a", false),
1729 ("", "a", false),
1730 ("*", "", false),
1731 ]);
1732 }
1733
1734 // -----------------------------------------------------------------
1735 // Precedence
1736 // -----------------------------------------------------------------
1737
1738 #[test]
1739 fn last_match_wins_within_a_section() {
1740 let file = parse("* @a\n*.rs @b\nsrc/ @c\nsrc/*.rs @d\n");
1741 assert_eq!(who(&file, "x.rs"), vec!["-:2:@b"]);
1742 assert_eq!(who(&file, "src/x.rs"), vec!["-:4:@d"]);
1743 assert_eq!(who(&file, "src/x.txt"), vec!["-:3:@c"]);
1744 assert_eq!(who(&file, "src/a/x.rs"), vec!["-:3:@c"]);
1745 let reversed = parse("src/*.rs @d\nsrc/ @c\n*.rs @b\n* @a\n");
1746 assert_eq!(who(&reversed, "src/x.rs"), vec!["-:4:@a"]);
1747 }
1748
1749 #[test]
1750 fn each_section_applies_its_own_last_match() {
1751 let file = parse("* @a\n[One]\n* @b\n*.rs @c\n[Two]\n*.rs @d\n* @e\n");
1752 assert_eq!(who(&file, "x.rs"), vec!["-:1:@a", "One:4:@c", "Two:7:@e"]);
1753 assert_eq!(who(&file, "x.txt"), vec!["-:1:@a", "One:3:@b", "Two:7:@e"]);
1754 }
1755
1756 #[test]
1757 fn ownerless_rule_removes_owners_in_default_section() {
1758 let file = parse("* @a\n/vendor/\n");
1759 assert_eq!(who(&file, "vendor/x"), vec!["-:2:"]);
1760 assert!(file.requirements(&["vendor/x".into()]).is_empty());
1761 assert!(parse("").owners_of("x").is_empty());
1762 }
1763
1764 #[test]
1765 fn requirements_list_each_path_once() {
1766 let file = parse("* @a\n");
1767 assert_eq!(needs(&file, &["x", "y", "x"]), vec!["-:1:*:1:x,y"]);
1768 }
1769
1770 #[test]
1771 fn locations() {
1772 assert_eq!(pick_location(&[]), None);
1773 assert_eq!(
1774 pick_location(&["CODEOWNERS", ".github/CODEOWNERS"]),
1775 Some(".github/CODEOWNERS")
1776 );
1777 assert_eq!(
1778 pick_location(&[".gitlab/CODEOWNERS", "docs/CODEOWNERS"]),
1779 Some("docs/CODEOWNERS")
1780 );
1781 assert_eq!(
1782 pick_location(&[".gitlab/CODEOWNERS", ".g1t/CODEOWNERS"]),
1783 Some(".g1t/CODEOWNERS")
1784 );
1785 assert_eq!(pick_location(&["src/CODEOWNERS"]), None);
1786 assert!(is_codeowners_path(".g1t/CODEOWNERS"));
1787 assert!(is_codeowners_path("CODEOWNERS"));
1788 assert!(!is_codeowners_path("/CODEOWNERS"));
1789 assert!(!is_codeowners_path("codeowners"));
1790 assert!(!is_codeowners_path("a/CODEOWNERS"));
1791 }
1792
1793 // -----------------------------------------------------------------
1794 // Owners and errors
1795 // -----------------------------------------------------------------
1796
1797 #[test]
1798 fn owner_tokens() {
1799 assert_eq!(Owner::parse("@Ana"), Some(user("ana")));
1800 assert_eq!(Owner::parse("@ana.b_c-d"), Some(user("ana.b_c-d")));
1801 assert_eq!(Owner::parse("@Acme/Back-End"), Some(team("acme", "back-end")));
1802 assert_eq!(Owner::parse("Ana@Example.COM"), Some(email("ana@example.com")));
1803 assert_eq!(
1804 Owner::parse("first.last+tag@mail.example.org"),
1805 Some(email("first.last+tag@mail.example.org"))
1806 );
1807 for bad in [
1808 "@", "@a/b/c", "foo", "@a/", "@/b", "@-ana", "@ana!", "a@b", "@a@b.com", "a@@b.com", "a@.com", "a@b.",
1809 "a@b..com", "",
1810 ] {
1811 assert_eq!(Owner::parse(bad), None, "{bad}");
1812 }
1813 for owner in [user("ana"), team("acme", "backend"), email("ana@example.com")] {
1814 assert_eq!(Owner::parse(&owner.text()), Some(owner.clone()));
1815 }
1816 assert_eq!(team("acme", "backend").text(), "@acme/backend");
1817 }
1818
1819 #[test]
1820 fn errors_with_lines() {
1821 let text = "\
1822* @ana
1823!vendor/ @bo
1824src/[ab].rs @bo
1825lib/ @bo nobody @acme/a/b @cy
1826\\#hash @dee # owners of #hash
1827/
1828[Unclosed
1829[]
1830[Name][x]
1831[Name][0]
1832[Name][11]
1833[Name]oops
1834[Good][10] @ok bad-owner
1835";
1836 let file = parse(text);
1837 assert_eq!(
1838 kinds(&file),
1839 vec![
1840 (2, ErrorKind::Negation, Some("!vendor/".into())),
1841 (3, ErrorKind::CharacterRange, Some("src/[ab].rs".into())),
1842 (4, ErrorKind::BadOwner, Some("nobody".into())),
1843 (4, ErrorKind::BadOwner, Some("@acme/a/b".into())),
1844 (6, ErrorKind::BadPattern, Some("/".into())),
1845 (7, ErrorKind::BadSection, Some("[Unclosed".into())),
1846 (8, ErrorKind::BadSection, Some("[]".into())),
1847 (9, ErrorKind::BadSection, Some("[Name][x]".into())),
1848 (10, ErrorKind::BadSection, Some("[Name][0]".into())),
1849 (11, ErrorKind::BadSection, Some("[Name][11]".into())),
1850 (12, ErrorKind::BadSection, Some("[Name]oops".into())),
1851 (13, ErrorKind::BadOwner, Some("bad-owner".into())),
1852 ]
1853 );
1854 let patterns: Vec<&str> = file.rules.iter().map(|r| r.pattern.as_str()).collect();
1855 assert_eq!(patterns, vec!["*", "lib/", "#hash"]);
1856 assert_eq!(file.rules[1].owners, vec![user("bo"), user("cy")]);
1857 assert_eq!(file.rules[2].owners, vec![user("dee")]);
1858 assert_eq!(file.rules[2].line, 5);
1859 assert_eq!(file.sections.len(), 1);
1860 assert_eq!(file.sections[0].approvals, 10);
1861 assert_eq!(file.sections[0].default_owners, vec![user("ok")]);
1862 assert!(file.errors[0].message.contains("negation is not supported"));
1863 assert!(file.errors[2].message.starts_with("nobody is not an owner"));
1864 assert!(file.errors.iter().all(|e| e.message.ends_with('.')));
1865 }
1866
1867 #[test]
1868 fn inline_comments_need_whitespace_before_them() {
1869 let file = parse("a#b @ana#x @bo #c @cy\n");
1870 assert_eq!(file.rules[0].pattern, "a#b");
1871 assert_eq!(file.rules[0].owners, vec![user("bo")]);
1872 assert_eq!(kinds(&file), vec![(1, ErrorKind::BadOwner, Some("@ana#x".into()))]);
1873 let header = parse("[Docs] @ana # writers\n");
1874 assert_eq!(header.sections[0].default_owners, vec![user("ana")]);
1875 }
1876
1877 #[test]
1878 fn crlf_and_bom() {
1879 let file = parse("\u{feff}*.md @ana\r\n\r\n[Docs][2] @bo\r\ndocs/\r\n");
1880 assert!(file.errors.is_empty(), "{:?}", file.errors);
1881 assert_eq!(file.rules[0].pattern, "*.md");
1882 assert_eq!(file.rules[0].owners, vec![user("ana")]);
1883 assert_eq!(file.sections[0].name, "Docs");
1884 assert_eq!(file.sections[0].line, 3);
1885 assert_eq!(file.rules[1].line, 4);
1886 assert_eq!(file.rules[1].pattern, "docs/");
1887 }
1888
1889 #[test]
1890 fn too_large() {
1891 let mut text = String::from("* @ana\n");
1892 text.push_str(&"#".repeat(MAX_BYTES));
1893 let file = parse(&text);
1894 assert!(file.rules.is_empty());
1895 assert_eq!(kinds(&file), vec![(0, ErrorKind::TooLarge, None)]);
1896 let just_fits = "#".repeat(MAX_BYTES);
1897 assert!(parse(&just_fits).errors.is_empty());
1898 }
1899
1900 #[test]
1901 fn owners_and_lines_naming() {
1902 let file = parse("* @bo ana@example.com\n[Docs] @acme/docs @bo\ndocs/\n*.md @bo\n");
1903 assert_eq!(
1904 file.owners(),
1905 vec![user("bo"), team("acme", "docs"), email("ana@example.com")]
1906 );
1907 assert_eq!(file.lines_naming(&user("bo")), vec![1, 2, 4]);
1908 assert_eq!(file.lines_naming(&team("acme", "docs")), vec![2]);
1909 assert!(file.lines_naming(&user("nobody")).is_empty());
1910 }
1911
1912 #[test]
1913 fn check_owners_reports_each_line() {
1914 let file = parse(
1915 "\
1916* @ana @ghost
1917docs/ @acme/docs @acme/infra
1918*.md ana@example.com @ghost
1919[Ops] @carl
1920ops/ @acme/ops
1921",
1922 );
1923 let lookup = |owner: &Owner| match owner.text().as_str() {
1924 "@ghost" => OwnerCheck::UnknownUser,
1925 "@acme/infra" => OwnerCheck::UnknownTeam,
1926 "@acme/docs" => OwnerCheck::TeamNoAccess,
1927 "ana@example.com" => OwnerCheck::UnknownEmail,
1928 "@carl" => OwnerCheck::NoWriteAccess,
1929 _ => OwnerCheck::Ok,
1930 };
1931 let errors = check_owners(&file, &lookup);
1932 let got: Vec<(u32, ErrorKind, &str, &str)> = errors
1933 .iter()
1934 .map(|e| (e.line, e.kind, e.token.as_deref().unwrap(), e.message.as_str()))
1935 .collect();
1936 assert_eq!(
1937 got,
1938 vec![
1939 (1, ErrorKind::UnknownUser, "@ghost", "@ghost is not a g1t account."),
1940 (
1941 2,
1942 ErrorKind::TeamNoAccess,
1943 "@acme/docs",
1944 "@acme/docs has no access to this repository; give the team the Write role or higher."
1945 ),
1946 (
1947 2,
1948 ErrorKind::UnknownTeam,
1949 "@acme/infra",
1950 "@acme/infra is not a team of acme."
1951 ),
1952 (3, ErrorKind::UnknownUser, "@ghost", "@ghost is not a g1t account."),
1953 (
1954 3,
1955 ErrorKind::UnknownEmail,
1956 "ana@example.com",
1957 "No g1t account has confirmed ana@example.com."
1958 ),
1959 (
1960 4,
1961 ErrorKind::NoWriteAccess,
1962 "@carl",
1963 "@carl cannot write to this repository; code owners need the Write role or higher."
1964 ),
1965 ]
1966 );
1967 assert!(check_owners(&file, &|_| OwnerCheck::Ok).is_empty());
1968 }
1969
1970 // -----------------------------------------------------------------
1971 // Reviews
1972 // -----------------------------------------------------------------
1973
1974 fn verdicts(list: &[(&str, bool)]) -> Vec<Verdict> {
1975 list.iter()
1976 .map(|(username, approved)| Verdict {
1977 username: username.to_string(),
1978 approved: *approved,
1979 })
1980 .collect()
1981 }
1982
1983 fn members(owner: &Owner) -> Vec<String> {
1984 let names: &[&str] = match owner.text().as_str() {
1985 "@acme/backend" => &["bo", "cy", "Dee"],
1986 "@acme/writers" => &["wil", "xan"],
1987 "ana@example.com" => &["ana"],
1988 "@ana" => &["ana"],
1989 "@bo" => &["bo"],
1990 _ => &[],
1991 };
1992 names.iter().map(|n| n.to_string()).collect()
1993 }
1994
1995 fn requirement(section: Option<&str>, line: u32, pattern: &str, owners: Vec<Owner>, approvals: u32) -> Requirement {
1996 Requirement {
1997 section: section.map(String::from),
1998 line,
1999 pattern: pattern.into(),
2000 owners,
2001 approvals,
2002 optional: false,
2003 files: vec![format!("{pattern}-file")],
2004 }
2005 }
2006
2007 #[test]
2008 fn approvals_count_per_requirement() {
2009 let reqs = vec![
2010 requirement(None, 1, "src/api/**", vec![team("acme", "backend")], 2),
2011 requirement(None, 2, "*.md", vec![user("ana")], 1),
2012 ];
2013 let reviews = evaluate(&reqs, &members, &verdicts(&[("bo", true), ("ANA", true)]), "eve");
2014 assert_eq!(reviews[0].approved_by, vec!["bo"]);
2015 assert!(!reviews[0].satisfied);
2016 assert_eq!(reviews[1].approved_by, vec!["ana"]);
2017 assert!(reviews[1].satisfied);
2018 assert_eq!(reviews[0].owners, vec!["@acme/backend"]);
2019 assert_eq!(reviews[0].required, 2);
2020
2021 let both = evaluate(
2022 &reqs,
2023 &members,
2024 &verdicts(&[("bo", true), ("dee", true), ("ana", true)]),
2025 "eve",
2026 );
2027 assert_eq!(both[0].approved_by, vec!["bo", "dee"]);
2028 assert!(both.iter().all(|r| r.satisfied));
2029 }
2030
2031 #[test]
2032 fn two_approvals_need_two_people() {
2033 let reqs = vec![requirement(Some("Docs"), 3, "docs/", vec![team("acme", "writers")], 2)];
2034 let same = evaluate(&reqs, &members, &verdicts(&[("wil", true), ("WIL", true)]), "eve");
2035 assert_eq!(same[0].approved_by, vec!["wil"]);
2036 assert!(!same[0].satisfied);
2037 let two = evaluate(&reqs, &members, &verdicts(&[("wil", true), ("xan", true)]), "eve");
2038 assert!(two[0].satisfied);
2039 }
2040
2041 #[test]
2042 fn author_never_counts() {
2043 let reqs = vec![requirement(None, 1, "*", vec![user("ana"), user("bo")], 1)];
2044 let reviews = evaluate(&reqs, &members, &verdicts(&[("ana", true)]), "Ana");
2045 assert!(reviews[0].approved_by.is_empty());
2046 assert!(!reviews[0].satisfied);
2047 let reviews = evaluate(&reqs, &members, &verdicts(&[("ana", false)]), "ana");
2048 assert!(reviews[0].changes_requested_by.is_empty());
2049 }
2050
2051 #[test]
2052 fn only_owners_count() {
2053 let reqs = vec![requirement(None, 1, "*", vec![user("ana")], 1)];
2054 let reviews = evaluate(&reqs, &members, &verdicts(&[("stranger", true), ("bo", false)]), "eve");
2055 assert!(reviews[0].approved_by.is_empty());
2056 assert!(reviews[0].changes_requested_by.is_empty());
2057 assert!(!reviews[0].satisfied);
2058 }
2059
2060 #[test]
2061 fn team_members_and_emails_count() {
2062 let reqs = vec![requirement(
2063 None,
2064 1,
2065 "*",
2066 vec![team("acme", "backend"), email("ana@example.com")],
2067 2,
2068 )];
2069 let reviews = evaluate(&reqs, &members, &verdicts(&[("dee", true), ("ana", true)]), "eve");
2070 assert_eq!(reviews[0].approved_by, vec!["dee", "ana"]);
2071 assert!(reviews[0].satisfied);
2072 }
2073
2074 #[test]
2075 fn a_change_request_blocks() {
2076 let reqs = vec![requirement(None, 1, "docs/", vec![team("acme", "backend")], 1)];
2077 let reviews = evaluate(&reqs, &members, &verdicts(&[("cy", true), ("bo", false)]), "eve");
2078 assert_eq!(reviews[0].approved_by, vec!["cy"]);
2079 assert_eq!(reviews[0].changes_requested_by, vec!["bo"]);
2080 assert!(!reviews[0].satisfied);
2081 assert_eq!(
2082 missing(&reviews).unwrap(),
2083 "@bo asked for changes on docs/ (code owner)."
2084 );
2085 }
2086
2087 #[test]
2088 fn optional_never_blocks() {
2089 let mut req = requirement(Some("Style"), 1, "*.css", vec![user("bo")], 0);
2090 req.optional = true;
2091 let reviews = evaluate(&[req], &members, &verdicts(&[("bo", false)]), "eve");
2092 assert_eq!(reviews[0].changes_requested_by, vec!["bo"]);
2093 assert!(reviews[0].satisfied);
2094 assert_eq!(missing(&reviews), None);
2095 }
2096
2097 #[test]
2098 fn one_approver_satisfies_several_requirements() {
2099 let file = parse("* @acme/backend\n[Docs]\n*.md @bo\n[Api][1] @acme/backend\nsrc/\n");
2100 let reqs = file.requirements(&["src/a.rs".into(), "README.md".into()]);
2101 assert_eq!(reqs.len(), 3);
2102 let reviews = evaluate(&reqs, &members, &verdicts(&[("bo", true)]), "eve");
2103 assert!(reviews.iter().all(|r| r.satisfied), "{reviews:?}");
2104 assert_eq!(missing(&reviews), None);
2105 }
2106
2107 #[test]
2108 fn missing_wording() {
2109 let reqs = vec![
2110 requirement(None, 1, "src/api/**", vec![team("acme", "backend")], 2),
2111 requirement(None, 2, "*.md", vec![user("ana")], 1),
2112 ];
2113 let reviews = evaluate(&reqs, &members, &verdicts(&[("bo", true)]), "eve");
2114 assert_eq!(
2115 missing(&reviews).unwrap(),
2116 "Code owners have not approved: @acme/backend for src/api/** (1 of 2 approvals), @ana for *.md."
2117 );
2118
2119 let reqs = vec![
2120 requirement(None, 1, "docs/", vec![user("bo")], 1),
2121 requirement(None, 2, "*.md", vec![user("ana"), user("bo")], 1),
2122 ];
2123 let reviews = evaluate(&reqs, &members, &verdicts(&[("bo", false)]), "eve");
2124 assert_eq!(
2125 missing(&reviews).unwrap(),
2126 "@bo asked for changes on docs/ (code owner). @bo asked for changes on *.md (code owner)."
2127 );
2128
2129 assert_eq!(missing(&[]), None);
2130 }
2131
2132 #[test]
2133 fn missing_caps_at_three() {
2134 let reqs: Vec<Requirement> = (1..=5)
2135 .map(|n| requirement(None, n, &format!("p{n}/"), vec![user("ana")], 1))
2136 .collect();
2137 let reviews = evaluate(&reqs, &members, &[], "eve");
2138 assert_eq!(
2139 missing(&reviews).unwrap(),
2140 "Code owners have not approved: @ana for p1/, @ana for p2/, @ana for p3/, and 2 more."
2141 );
2142 let mut changes: Vec<Requirement> = (1..=4)
2143 .map(|n| requirement(None, n, &format!("d{n}/"), vec![user("bo")], 1))
2144 .collect();
2145 changes.push(requirement(None, 9, "z/", vec![user("ana")], 1));
2146 let reviews = evaluate(&changes, &members, &verdicts(&[("bo", false)]), "eve");
2147 assert_eq!(
2148 missing(&reviews).unwrap(),
2149 "@bo asked for changes on d1/ (code owner). @bo asked for changes on d2/ (code owner). \
2150 @bo asked for changes on d3/ (code owner). And 2 more."
2151 );
2152 }
2153
2154 // -----------------------------------------------------------------
2155 // Serde
2156 // -----------------------------------------------------------------
2157
2158 #[test]
2159 fn serde_shapes() {
2160 assert_eq!(
2161 serde_json::to_string(&team("acme", "backend")).unwrap(),
2162 r#"{"kind":"team","workspace":"acme","slug":"backend"}"#
2163 );
2164 assert_eq!(
2165 serde_json::to_string(&user("ana")).unwrap(),
2166 r#"{"kind":"user","username":"ana"}"#
2167 );
2168 assert_eq!(
2169 serde_json::to_string(&email("a@b.co")).unwrap(),
2170 r#"{"kind":"email","email":"a@b.co"}"#
2171 );
2172 assert_eq!(
2173 serde_json::to_string(&ErrorKind::CharacterRange).unwrap(),
2174 r#""character_range""#
2175 );
2176 assert_eq!(serde_json::to_string(&ErrorKind::TooLarge).unwrap(), r#""too_large""#);
2177 assert_eq!(
2178 serde_json::to_string(&OwnerCheck::TeamNoAccess).unwrap(),
2179 r#""team_no_access""#
2180 );
2181 assert_eq!(serde_json::to_string(&OwnerCheck::Ok).unwrap(), r#""ok""#);
2182 let file = parse("[Docs][2] @acme/writers\ndocs/\n");
2183 let json = serde_json::to_string(&file).unwrap();
2184 assert!(json.contains(r#""default_owners":[{"kind":"team","workspace":"acme","slug":"writers"}]"#));
2185 let back: CodeOwners = serde_json::from_str(&json).unwrap();
2186 assert_eq!(back, file);
2187 }
2188}