use serde::{Deserialize, Serialize};
use crate::api::client::{ApiError, GitHub, repo_vars};
use crate::api::model::RepoId;
use crate::api::raw;
pub const PLACES: [&str; 3] = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Codeowners {
pub path: Option<String>,
pub rules: Vec<Rule>,
pub errors: Option<Vec<OwnersError>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
pub line: u32,
pub pattern: String,
pub owners: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OwnersError {
pub line: u32,
pub column: u32,
pub kind: String,
pub message: String,
pub suggestion: Option<String>,
}
impl Codeowners {
pub fn new(found: Option<(&str, &str)>, errors: Option<Vec<OwnersError>>) -> Self {
let Some((path, text)) = found else { return Self::default() };
Self {
path: Some(path.to_owned()),
rules: parse(text),
errors,
}
}
pub fn owner(&self, path: &str) -> Option<&Rule> {
self.rules.iter().rev().find(|r| matches(&r.pattern, path))
}
}
pub fn parse(text: &str) -> Vec<Rule> {
(text.lines().zip(1u32..))
.filter_map(|(line, n)| {
let line = match line
.find(" #")
.or_else(|| line.starts_with('#').then_some(0))
{
Some(at) => line.get(..at).unwrap_or_default(),
None => line,
};
let mut words = line.split_whitespace();
let pattern = words.next()?.replace("\\#", "#");
Some(Rule {
line: n,
pattern,
owners: words.map(str::to_owned).collect(),
})
})
.collect()
}
pub fn matches(pattern: &str, path: &str) -> bool {
let folder_only = pattern.ends_with('/');
let trimmed = pattern.trim_end_matches('/');
let anchored = trimmed.contains('/');
let pattern: Vec<&str> = trimmed.trim_start_matches('/').split('/').collect();
if pattern.iter().all(|p| p.is_empty()) {
return false;
}
let one_level = pattern.len() > 1 && pattern.last() == Some(&"*");
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
let starts = if anchored { 0..1 } else { 0..parts.len() };
starts.into_iter().any(|start| {
let rest = parts.get(start..).unwrap_or_default();
let file = !folder_only && segments(&pattern, rest);
let folder = !one_level
&& (1..rest.len()).any(|end| segments(&pattern, rest.get(..end).unwrap_or_default()));
file || folder
})
}
fn segments(pattern: &[&str], path: &[&str]) -> bool {
match pattern.split_first() {
None => path.is_empty(),
Some((&"**", rest)) => {
(0..=path.len()).any(|skip| segments(rest, path.get(skip..).unwrap_or_default()))
}
Some((first, rest)) => match path.split_first() {
Some((name, more)) => glob(first, name) && segments(rest, more),
None => false,
},
}
}
fn glob(pattern: &str, name: &str) -> bool {
fn go(p: &[char], n: &[char]) -> bool {
match p.split_first() {
None => n.is_empty(),
Some(('*', rest)) => {
(0..=n.len()).any(|skip| go(rest, n.get(skip..).unwrap_or_default()))
}
Some((c, rest)) => match n.split_first() {
Some((m, more)) => (*c == '?' || c == m) && go(rest, more),
None => false,
},
}
}
let (p, n): (Vec<char>, Vec<char>) = (pattern.chars().collect(), name.chars().collect());
go(&p, &n)
}
#[derive(Deserialize)]
struct WireError {
line: u32,
column: u32,
kind: String,
message: String,
suggestion: Option<String>,
}
#[derive(Deserialize)]
struct WireOwners {
errors: Vec<WireError>,
}
fn errors(owners: Option<WireOwners>) -> Vec<OwnersError> {
(owners.into_iter().flat_map(|o| o.errors))
.map(|e| OwnersError {
line: e.line,
column: e.column,
kind: e.kind,
message: e.message,
suggestion: e.suggestion.filter(|s| !s.is_empty()),
})
.collect()
}
fn ref_name(rev: &str) -> Option<&str> {
(rev != "HEAD").then_some(rev)
}
impl GitHub {
pub async fn codeowners(&self, repo: &RepoId, rev: &str) -> Result<Codeowners, ApiError> {
#[derive(Deserialize)]
struct Text {
text: Option<String>,
}
#[derive(Deserialize)]
struct Wire {
github: Option<Text>,
root: Option<Text>,
docs: Option<Text>,
codeowners: Option<WireOwners>,
}
let [github, root, docs] = PLACES.map(|p| format!("{rev}:{p}"));
let vars =
repo_vars!(repo, "github": github, "root": root, "docs": docs, "ref": ref_name(rev));
let wire: Wire = (self.graphql_json(
raw::CODEOWNERS,
vars,
format!("{repo}@{rev}"),
"/repository",
))
.await?;
let texts = [wire.github, wire.root, wire.docs];
let found = (PLACES.iter().zip(texts)).find_map(|(path, t)| Some((*path, t?.text?)));
let found = found.as_ref().map(|(path, text)| (*path, text.as_str()));
Ok(Codeowners::new(found, Some(errors(wire.codeowners))))
}
pub async fn codeowners_errors(
&self,
repo: &RepoId,
rev: &str,
) -> Result<Vec<OwnersError>, ApiError> {
let vars = repo_vars!(repo, "ref": ref_name(rev));
let subject = format!("{repo}@{rev}");
let owners: Option<WireOwners> = (self.graphql_json(
raw::CODEOWNERS_ERRORS,
vars,
subject,
"/repository/codeowners",
))
.await
.or_else(|err| match err {
ApiError::NotFound(_) => Ok(None),
err => Err(err),
})?;
Ok(errors(owners))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn patterns_match_as_github_reads_them() {
let yes = |p: &str, path: &str| assert!(matches(p, path), "{p} should match {path}");
let no = |p: &str, path: &str| assert!(!matches(p, path), "{p} shouldn't match {path}");
yes("*", "a/b/c.rs");
yes("*.js", "src/app/main.js");
no("*.js", "src/app/main.ts");
yes("/build/logs/", "build/logs/a/b.log");
no("/build/logs/", "src/build/logs/a.log");
yes("docs/*", "docs/getting-started.md");
no("docs/*", "docs/build-app/troubleshooting.md");
yes("apps/", "web/apps/x/y.rs");
no("apps/", "apps");
yes("/docs/", "docs/a/b.md");
yes("**/logs", "deep/down/logs/x.log");
yes("/scripts/**", "scripts/a/b.sh");
yes("src/ma?n.rs", "src/main.rs");
yes("LICENSE", "LICENSE");
yes("LICENSE", "vendor/LICENSE");
no("/LICENSE", "vendor/LICENSE");
}
#[test]
fn the_last_rule_owns() {
let text = "# Owners\n* @octocat\n\n*.md @o/docs docs@example.com # prose\n/vendor/\n";
let owners = Codeowners::new(Some(("CODEOWNERS", text)), None);
let owner = |path: &str| owners.owner(path).map(|r| (r.line, r.owners.clone()));
assert_eq!(owner("src/main.rs"), Some((2, vec!["@octocat".to_owned()])));
assert_eq!(
owner("README.md"),
Some((4, vec!["@o/docs".to_owned(), "docs@example.com".to_owned()]))
);
assert_eq!(owner("vendor/x.md"), Some((5, Vec::new())));
assert_eq!(Codeowners::new(None, None).owner("a"), None);
}
}