diff --git a/frontend/packages/swarm-ui/src/pages/PrReportPage.tsx b/frontend/packages/swarm-ui/src/pages/PrReportPage.tsx index 0d8d1f1a..77a2870b 100644 --- a/frontend/packages/swarm-ui/src/pages/PrReportPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/PrReportPage.tsx @@ -81,6 +81,7 @@ const COLUMNS: TableColumn[] = [ key: "labels", header: "labels", render: (r) => (r.labels.length ? r.labels.join(", ") : "—"), + sortBy: (r) => r.labels.join(", "), filterValues: (r) => r.labels, filterMode: "multiselect", }, @@ -89,6 +90,7 @@ const COLUMNS: TableColumn[] = [ header: "reviewers", render: (r) => r.requested_reviewers.length ? r.requested_reviewers.join(", ") : "—", + sortBy: (r) => r.requested_reviewers.join(", "), filterValues: (r) => r.requested_reviewers, filterMode: "multiselect", }, diff --git a/swarm-controller/src/forge/pr_report.rs b/swarm-controller/src/forge/pr_report.rs index 3e59af59..ff07ede0 100644 --- a/swarm-controller/src/forge/pr_report.rs +++ b/swarm-controller/src/forge/pr_report.rs @@ -65,28 +65,136 @@ pub struct PrReportRow { pub ci: Option, } -/// Whether `branch` is named by a branch protection pattern: a name, or a glob -/// where `*` stops at a `/` and `**` does not. +/// Whether `branch` is named by a branch protection pattern. The forge reads a +/// rule's name as a glob with `/` as the separator: `*` and `?` stop at a `/`, +/// `**` does not, `[a-z]` and `[!a-z]` are classes, `{a,b}` is a choice and a +/// backslash escapes the next character. fn glob_matches(pattern: &str, branch: &str) -> bool { - fn go(p: &[u8], s: &[u8]) -> bool { - match p.split_first() { - None => s.is_empty(), - Some((b'*', rest)) => { - if let Some(rest) = rest.strip_prefix(b"*") { - // `**`: any run, slashes included. - (0..=s.len()).any(|i| go(rest, &s[i..])) - } else { - // `*`: any run up to the next slash. - let reach = s.iter().position(|&c| c == b'/').unwrap_or(s.len()); - (0..=reach).any(|i| go(rest, &s[i..])) - } + let branch: Vec = branch.chars().collect(); + expand_choices(pattern).iter().any(|p| { + let p: Vec = p.chars().collect(); + glob_run(&p, &branch) + }) +} + +/// `pattern` with each `{a,b}` choice written out: `v{1,2}` is `v1` and `v2`. +/// A `{` with no closing `}` is itself. +fn expand_choices(pattern: &str) -> Vec { + let chars: Vec = pattern.chars().collect(); + let mut escaped = false; + let mut open = None; + for (i, &c) in chars.iter().enumerate() { + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + '{' => { + open = Some(i); + break; } - Some((&c, rest)) => s - .split_first() - .is_some_and(|(&d, tail)| c == d && go(rest, tail)), + _ => {} } } - go(pattern.as_bytes(), branch.as_bytes()) + let Some(open) = open else { + return vec![pattern.to_owned()]; + }; + // The matching `}` and the commas between, at this nesting only. + let (mut depth, mut escaped) = (0_usize, false); + let mut cuts = vec![open]; + for (i, &c) in chars.iter().enumerate().skip(open) { + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + '{' => depth += 1, + ',' if depth == 1 => cuts.push(i), + '}' => { + depth -= 1; + if depth == 0 { + cuts.push(i); + let text = |from: usize, to: usize| chars[from..to].iter().collect::(); + let (head, tail) = (text(0, open), text(i + 1, chars.len())); + return cuts + .windows(2) + .flat_map(|w| { + expand_choices(&format!("{head}{}{tail}", text(w[0] + 1, w[1]))) + }) + .collect(); + } + } + _ => {} + } + } + vec![pattern.to_owned()] +} + +/// Whether the choice-free pattern `p` matches all of `s`. +fn glob_run(p: &[char], s: &[char]) -> bool { + let Some((&c, rest)) = p.split_first() else { + return s.is_empty(); + }; + match c { + '*' => { + if let Some(rest) = rest.strip_prefix(&['*']) { + // `**`: any run, slashes included. + (0..=s.len()).any(|i| glob_run(rest, &s[i..])) + } else { + // `*`: any run up to the next slash. + let reach = s.iter().position(|&d| d == '/').unwrap_or(s.len()); + (0..=reach).any(|i| glob_run(rest, &s[i..])) + } + } + '?' => s + .split_first() + .is_some_and(|(&d, tail)| d != '/' && glob_run(rest, tail)), + '[' => match glob_class(rest) { + Some((members, negated, after)) => s.split_first().is_some_and(|(&d, tail)| { + d != '/' && members.contains(d) != negated && glob_run(after, tail) + }), + // No closing `]`: a literal bracket. + None => s + .split_first() + .is_some_and(|(&d, tail)| d == '[' && glob_run(rest, tail)), + }, + '\\' if !rest.is_empty() => s + .split_first() + .is_some_and(|(&d, tail)| d == rest[0] && glob_run(&rest[1..], tail)), + _ => s + .split_first() + .is_some_and(|(&d, tail)| c == d && glob_run(rest, tail)), + } +} + +/// What a `[` class holds: its members, whether it is negated (`[!a]`), and +/// the pattern after its `]`. `None` for a class with no `]`. +fn glob_class(after_bracket: &[char]) -> Option<(Members, bool, &[char])> { + let (negated, body) = match after_bracket.split_first() { + Some((&'!', body)) => (true, body), + _ => (false, after_bracket), + }; + let end = body.iter().position(|&c| c == ']')?; + let mut members = Members::default(); + let mut i = 0; + while i < end { + if i + 2 < end && body[i + 1] == '-' { + members.ranges.push((body[i], body[i + 2])); + i += 3; + } else { + members.ranges.push((body[i], body[i])); + i += 1; + } + } + Some((members, negated, &body[end + 1..])) +} + +/// The characters and ranges a `[` class names. +#[derive(Default)] +struct Members { + ranges: Vec<(char, char)>, +} + +impl Members { + fn contains(&self, c: char) -> bool { + self.ranges.iter().any(|&(lo, hi)| (lo..=hi).contains(&c)) + } } /// The protection rule for `base`, as the approvals it asks for: `None` when @@ -373,6 +481,41 @@ mod tests { assert!(!glob_matches("*", "iris/foo")); } + /// Forgejo reads a rule's name as a glob with `/` as its separator, so + /// `?`, `[..]` and `{a,b}` name branches too. A rule read as naming none + /// would show a PR as needing no approvals. + #[test] + fn a_pattern_may_use_choices_classes_and_a_single_character() { + for (pattern, branch, matches) in [ + ("{main,release}", "main", true), + ("{main,release}", "release", true), + ("{main,release}", "dev", false), + ("{main,release}", "mainx", false), + ("release/{1,2}.*", "release/2.0", true), + ("release/{1,2}.*", "release/3.0", false), + ("{a,{b,c}}/x", "c/x", true), + ("v?", "v1", true), + ("v?", "v10", false), + ("v?", "v/", false), + ("v[0-9]", "v7", true), + ("v[0-9]", "va", false), + ("v[!0-9]", "va", true), + ("v[!0-9]", "v7", false), + ("v[abc]", "vb", true), + (r"v\*", "v*", true), + (r"v\*", "vx", false), + ("a[b", "a[b", true), + ("{a", "{a", true), + ("**/main", "x/y/main", true), + ] { + assert_eq!( + glob_matches(pattern, branch), + matches, + "{pattern} on {branch}" + ); + } + } + #[test] fn the_first_matching_rule_sets_the_approvals() { let rules = [rule("main", 1), rule("*", 2)];