Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1 | //! 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 | ||
| 59 | use std::collections::BTreeSet; | |
| 60 | ||
| 61 | use serde::{Deserialize, Serialize}; | |
| 62 | ||
| 63 | /// Where a CODEOWNERS file may be, in the order they are looked for. | |
| 64 | pub 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. | |
| 73 | pub const MAX_BYTES: usize = 3 * 1024 * 1024; | |
| 74 | ||
| 75 | /// Whether `path` (from the repository root, no leading `/`) is one of | |
| 76 | /// [`LOCATIONS`]. | |
| 77 | pub 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. | |
| 83 | pub 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")] | |
| 90 | pub 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. | |
| 102 | fn 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 | ||
| 108 | fn 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 | ||
| 122 | impl 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)] | |
| 156 | pub 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)] | |
| 172 | pub 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")] | |
| 186 | pub 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)] | |
| 213 | pub 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)] | |
| 225 | pub 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. | |
| 237 | fn 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. | |
| 270 | fn 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. | |
| 283 | struct Header { | |
| 284 | name: String, | |
| 285 | optional: bool, | |
| 286 | approvals: Option<u32>, | |
| 287 | owners: Vec<Owner>, | |
| 288 | bad_owners: Vec<String>, | |
| 289 | } | |
| 290 | ||
| 291 | fn 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 | ||
| 342 | fn 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. | |
| 353 | pub 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 | ||
| 441 | impl 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)] | |
| 610 | pub 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)] | |
| 622 | pub 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)] | |
| 642 | enum 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)] | |
| 652 | enum 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)] | |
| 661 | struct 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 | ||
| 669 | fn 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 | ||
| 687 | fn 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 | ||
| 725 | fn path_parts(path: &str) -> Vec<&str> { | |
| 726 | path.split('/').filter(|part| !part.is_empty()).collect() | |
| 727 | } | |
| 728 | ||
| 729 | impl 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 | ||
| 744 | fn 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 | ||
| 755 | fn 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. | |
| 793 | pub 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")] | |
| 804 | pub 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 | ||
| 819 | fn 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. | |
| 850 | pub 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)] | |
| 875 | pub 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)] | |
| 883 | pub 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. | |
| 908 | pub 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". | |
| 960 | fn 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". | |
| 972 | pub 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)] | |
| 1024 | pub 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)] | |
| 1042 | pub 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)] | |
| 1061 | pub 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)] | |
| 1069 | mod 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. | |
| 1155 | docs/* docs@example.com | |
| 1156 | ||
| 1157 | # Any directory named apps, at any depth. | |
| 1158 | apps/ @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 | |
| 1253 | Cargo.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\ | |
| 1324 | docs/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 | |
| 1387 | docs/ | |
| 1388 | *.md | |
| 1389 | README.md @ana | |
| 1390 | ||
| 1391 | ^[Frontend] | |
| 1392 | app/assets/ @fe-lead | |
| 1393 | *.css @designer | |
| 1394 | ||
| 1395 | [Backend] @acme/backend | |
| 1396 | app/models/ | |
| 1397 | lib/ | |
| 1398 | lib/vendor/ @vendor-keeper | |
| 1399 | ||
| 1400 | [documentation] | |
| 1401 | CONTRIBUTING.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 | |
| 1501 | db/ | |
| 1502 | db/schema.rb | |
| 1503 | [Database | |
| 1504 | db/seeds.rb @seed-keeper | |
| 1505 | ||
| 1506 | [Release] | |
| 1507 | VERSION @release | |
| 1508 | ||
| 1509 | [database][2] @acme/data | |
| 1510 | db/migrate/ | |
| 1511 | ||
| 1512 | [Release][0] | |
| 1513 | CHANGELOG.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 | |
| 1824 | src/[ab].rs @bo | |
| 1825 | lib/ @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 | |
| 1917 | docs/ @acme/docs @acme/infra | |
| 1918 | *.md ana@example.com @ghost | |
| 1919 | [Ops] @carl | |
| 1920 | ops/ @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 | } |