Watch
0
0
Fork
You've already forked hyperhive
0

swarm ui: sort every pull requests column, and read a protection rule as the forge does

The labels and reviewers columns had no sort, so "every column sorts" was
false; both now sort on their joined text.

A branch protection rule's name is a glob the forge compiles with `/` as its
separator, which also takes `?`, `[a-z]`, `[!a-z]`, `{a,b}` and a backslash
escape. The report read only `*` and `**`, so a rule such as `{main,release}`
matched nothing and its PRs showed as protected by no rule, needing no
approvals. It now reads all of them.
This commit is contained in:
iris 2026-11-07 11:14:53 +01:00 • committed by mara
commit 5633107b32

View file

@ -65,28 +65,136 @@ pub struct PrReportRow {
pub ci: Option<CiState>,
}
/// 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<char> = branch.chars().collect();
expand_choices(pattern).iter().any(|p| {
let p: Vec<char> = 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<String> {
let chars: Vec<char> = 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::<String>();
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)];