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:
parent
560afef2b6
commit
5633107b32
2 changed files with 161 additions and 16 deletions
|
|
@ -81,6 +81,7 @@ const COLUMNS: TableColumn<PrReportRow>[] = [
|
|||
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<PrReportRow>[] = [
|
|||
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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
Loading…
Reference in a new issue