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",
|
key: "labels",
|
||||||
header: "labels",
|
header: "labels",
|
||||||
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
|
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
|
||||||
|
sortBy: (r) => r.labels.join(", "),
|
||||||
filterValues: (r) => r.labels,
|
filterValues: (r) => r.labels,
|
||||||
filterMode: "multiselect",
|
filterMode: "multiselect",
|
||||||
},
|
},
|
||||||
|
|
@ -89,6 +90,7 @@ const COLUMNS: TableColumn<PrReportRow>[] = [
|
||||||
header: "reviewers",
|
header: "reviewers",
|
||||||
render: (r) =>
|
render: (r) =>
|
||||||
r.requested_reviewers.length ? r.requested_reviewers.join(", ") : "—",
|
r.requested_reviewers.length ? r.requested_reviewers.join(", ") : "—",
|
||||||
|
sortBy: (r) => r.requested_reviewers.join(", "),
|
||||||
filterValues: (r) => r.requested_reviewers,
|
filterValues: (r) => r.requested_reviewers,
|
||||||
filterMode: "multiselect",
|
filterMode: "multiselect",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -65,28 +65,136 @@ pub struct PrReportRow {
|
||||||
pub ci: Option<CiState>,
|
pub ci: Option<CiState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `branch` is named by a branch protection pattern: a name, or a glob
|
/// Whether `branch` is named by a branch protection pattern. The forge reads a
|
||||||
/// where `*` stops at a `/` and `**` does not.
|
/// 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 glob_matches(pattern: &str, branch: &str) -> bool {
|
||||||
fn go(p: &[u8], s: &[u8]) -> bool {
|
let branch: Vec<char> = branch.chars().collect();
|
||||||
match p.split_first() {
|
expand_choices(pattern).iter().any(|p| {
|
||||||
None => s.is_empty(),
|
let p: Vec<char> = p.chars().collect();
|
||||||
Some((b'*', rest)) => {
|
glob_run(&p, &branch)
|
||||||
if let Some(rest) = rest.strip_prefix(b"*") {
|
})
|
||||||
// `**`: any run, slashes included.
|
}
|
||||||
(0..=s.len()).any(|i| go(rest, &s[i..]))
|
|
||||||
} else {
|
/// `pattern` with each `{a,b}` choice written out: `v{1,2}` is `v1` and `v2`.
|
||||||
// `*`: any run up to the next slash.
|
/// A `{` with no closing `}` is itself.
|
||||||
let reach = s.iter().position(|&c| c == b'/').unwrap_or(s.len());
|
fn expand_choices(pattern: &str) -> Vec<String> {
|
||||||
(0..=reach).any(|i| go(rest, &s[i..]))
|
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
|
/// The protection rule for `base`, as the approvals it asks for: `None` when
|
||||||
|
|
@ -373,6 +481,41 @@ mod tests {
|
||||||
assert!(!glob_matches("*", "iris/foo"));
|
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]
|
#[test]
|
||||||
fn the_first_matching_rule_sets_the_approvals() {
|
fn the_first_matching_rule_sets_the_approvals() {
|
||||||
let rules = [rule("main", 1), rule("*", 2)];
|
let rules = [rule("main", 1), rule("*", 2)];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue