logiguard fork v3: full patch set on verified 8c74db0 tree
Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
38
tooling/compliance/Cargo.toml
Normal file
38
tooling/compliance/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "compliance"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
octo-client = ["dep:octocrab", "dep:jsonwebtoken", "dep:tokio"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
derive_more.workspace = true
|
||||
futures.workspace = true
|
||||
itertools.workspace = true
|
||||
jsonwebtoken = { version = "10.2", features = ["use_pem"], optional = true }
|
||||
octocrab = { version = "0.49", default-features = false, features = [
|
||||
"default-client",
|
||||
"jwt-aws-lc-rs",
|
||||
"retry",
|
||||
"rustls",
|
||||
"rustls-aws-lc-rs",
|
||||
"stream",
|
||||
"timeout"
|
||||
], optional = true }
|
||||
regex.workspace = true
|
||||
semver.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
indoc.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
||||
1
tooling/compliance/LICENSE-GPL
Symbolic link
1
tooling/compliance/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
1249
tooling/compliance/src/checks.rs
Normal file
1249
tooling/compliance/src/checks.rs
Normal file
File diff suppressed because it is too large
Load Diff
779
tooling/compliance/src/git.rs
Normal file
779
tooling/compliance/src/git.rs
Normal file
@@ -0,0 +1,779 @@
|
||||
#![allow(clippy::disallowed_methods, reason = "This is only used in xtasks")]
|
||||
use std::{
|
||||
fmt::{self, Debug},
|
||||
ops::Not,
|
||||
process::Command,
|
||||
str::FromStr,
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use derive_more::{Deref, DerefMut, FromStr};
|
||||
|
||||
use itertools::Itertools;
|
||||
use regex::Regex;
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(crate) const ZED_ZIPPY_LOGIN: &str = "zed-zippy[bot]";
|
||||
pub(crate) const ZED_ZIPPY_EMAIL: &str = "234243425+zed-zippy[bot]@users.noreply.github.com";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutomatedChangeKind {
|
||||
VersionBump,
|
||||
ReleaseChannelUpdate,
|
||||
}
|
||||
|
||||
impl AutomatedChangeKind {
|
||||
pub(crate) fn expected_files(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::VersionBump => &["Cargo.lock", "crates/zed/Cargo.toml"],
|
||||
Self::ReleaseChannelUpdate => &["crates/zed/RELEASE_CHANNEL"],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expected_loc(&self) -> u64 {
|
||||
match self {
|
||||
Self::VersionBump => 2,
|
||||
Self::ReleaseChannelUpdate => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AutomatedChangeKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::VersionBump => formatter.write_str("version bump"),
|
||||
Self::ReleaseChannelUpdate => formatter.write_str("release channel update"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Subcommand {
|
||||
type ParsedOutput: FromStr<Err = anyhow::Error>;
|
||||
|
||||
fn args(&self) -> impl IntoIterator<Item = String>;
|
||||
}
|
||||
|
||||
#[derive(Deref, DerefMut)]
|
||||
pub struct GitCommand<G: Subcommand> {
|
||||
#[deref]
|
||||
#[deref_mut]
|
||||
subcommand: G,
|
||||
}
|
||||
|
||||
impl<G: Subcommand> GitCommand<G> {
|
||||
#[must_use]
|
||||
pub fn run(subcommand: G) -> Result<G::ParsedOutput> {
|
||||
Self { subcommand }.run_impl()
|
||||
}
|
||||
|
||||
fn run_impl(self) -> Result<G::ParsedOutput> {
|
||||
let command_output = Command::new("git")
|
||||
.args(self.subcommand.args())
|
||||
.output()
|
||||
.context("Failed to spawn command")?;
|
||||
|
||||
if command_output.status.success() {
|
||||
String::from_utf8(command_output.stdout)
|
||||
.map_err(|_| anyhow!("Invalid UTF8"))
|
||||
.and_then(|s| {
|
||||
G::ParsedOutput::from_str(s.trim())
|
||||
.map_err(|e| anyhow!("Failed to parse from string: {e:?}"))
|
||||
})
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Command failed with exit code {}, stderr: {}",
|
||||
command_output.status.code().unwrap_or_default(),
|
||||
String::from_utf8(command_output.stderr).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ReleaseChannel {
|
||||
Stable,
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl ReleaseChannel {
|
||||
pub(crate) fn tag_suffix(&self) -> &'static str {
|
||||
match self {
|
||||
ReleaseChannel::Stable => "",
|
||||
ReleaseChannel::Preview => "-pre",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VersionTag(Version, ReleaseChannel);
|
||||
|
||||
impl VersionTag {
|
||||
pub fn parse(input: &str) -> Result<Self, anyhow::Error> {
|
||||
// Being a bit more lenient for human inputs
|
||||
let version = input.strip_prefix('v').unwrap_or(input);
|
||||
|
||||
let (version_str, channel) = version
|
||||
.strip_suffix("-pre")
|
||||
.map_or((version, ReleaseChannel::Stable), |version_str| {
|
||||
(version_str, ReleaseChannel::Preview)
|
||||
});
|
||||
|
||||
Version::parse(version_str)
|
||||
.map(|version| Self(version, channel))
|
||||
.map_err(|_| anyhow::anyhow!("Failed to parse version from tag!"))
|
||||
}
|
||||
|
||||
pub fn version(&self) -> &Version {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for VersionTag {
|
||||
fn to_string(&self) -> String {
|
||||
format!(
|
||||
"v{version}{channel_suffix}",
|
||||
version = self.0,
|
||||
channel_suffix = self.1.tag_suffix()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deref, FromStr, PartialEq, Eq, Hash, Deserialize)]
|
||||
pub struct CommitSha(pub(crate) String);
|
||||
|
||||
impl CommitSha {
|
||||
pub fn new(sha: String) -> Self {
|
||||
Self(sha)
|
||||
}
|
||||
|
||||
pub fn short(&self) -> &str {
|
||||
self.0.as_str().split_at(8).0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommitDetails {
|
||||
sha: CommitSha,
|
||||
author: Committer,
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl CommitDetails {
|
||||
pub fn new(sha: CommitSha, author: Committer, title: String, body: String) -> Self {
|
||||
Self {
|
||||
sha,
|
||||
author,
|
||||
title,
|
||||
body,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CommitDetails {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
CommitList::from_str(s).and_then(|list| {
|
||||
list.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("No commit found"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Committer {
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
impl Committer {
|
||||
pub fn new(name: &str, email: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
email: email.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_zed_zippy(&self) -> bool {
|
||||
self.email == ZED_ZIPPY_EMAIL
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Committer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "{} ({})", self.name, self.email)
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitDetails {
|
||||
const BODY_DELIMITER: &str = "|body-delimiter|";
|
||||
const COMMIT_DELIMITER: &str = "|commit-delimiter|";
|
||||
const FIELD_DELIMITER: &str = "|field-delimiter|";
|
||||
const FORMAT_STRING: &str = "%H|field-delimiter|%an|field-delimiter|%ae|field-delimiter|%s|body-delimiter|%b|commit-delimiter|";
|
||||
|
||||
fn parse(line: &str, body: &str) -> Result<Self, anyhow::Error> {
|
||||
let Some([sha, author_name, author_email, title]) =
|
||||
line.splitn(4, Self::FIELD_DELIMITER).collect_array()
|
||||
else {
|
||||
return Err(anyhow!("Failed to parse commit fields from input {line}"));
|
||||
};
|
||||
|
||||
Ok(CommitDetails {
|
||||
sha: CommitSha(sha.to_owned()),
|
||||
author: Committer::new(author_name, author_email),
|
||||
title: title.to_owned(),
|
||||
body: body.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pr_number(&self) -> Option<u64> {
|
||||
// Since we use squash merge, all commit titles end with the '(#12345)' pattern.
|
||||
// While we could strictly speaking index into this directly, go for a slightly
|
||||
// less prone approach to errors
|
||||
const PATTERN: &str = " (#";
|
||||
self.title
|
||||
.rfind(PATTERN)
|
||||
.and_then(|location| {
|
||||
self.title[location..]
|
||||
.find(')')
|
||||
.map(|relative_end| location + PATTERN.len()..location + relative_end)
|
||||
})
|
||||
.and_then(|range| self.title[range].parse().ok())
|
||||
}
|
||||
|
||||
pub(crate) fn co_authors(&self) -> Option<Vec<Committer>> {
|
||||
static CO_AUTHOR_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"Co-authored-by: (.+) <(.+)>").unwrap());
|
||||
|
||||
let mut co_authors = Vec::new();
|
||||
|
||||
for cap in CO_AUTHOR_REGEX.captures_iter(&self.body.as_ref()) {
|
||||
let Some((name, email)) = cap
|
||||
.get(1)
|
||||
.map(|m| m.as_str())
|
||||
.zip(cap.get(2).map(|m| m.as_str()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
co_authors.push(Committer::new(name, email));
|
||||
}
|
||||
|
||||
co_authors.is_empty().not().then_some(co_authors)
|
||||
}
|
||||
|
||||
pub(crate) fn author(&self) -> &Committer {
|
||||
&self.author
|
||||
}
|
||||
|
||||
pub(crate) fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn sha(&self) -> &CommitSha {
|
||||
&self.sha
|
||||
}
|
||||
|
||||
pub(crate) fn detect_automated_change(&self) -> Option<(AutomatedChangeKind, &str)> {
|
||||
static VERSION_BUMP_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^Bump to [0-9]+\.[0-9]+\.[0-9]+ for @([a-zA-Z0-9][a-zA-Z0-9-]*)$").unwrap()
|
||||
});
|
||||
static RELEASE_CHANNEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^v[0-9]+\.[0-9]+\.x (?:stable|preview) for @([a-zA-Z0-9][a-zA-Z0-9-]*)$")
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
VERSION_BUMP_REGEX
|
||||
.captures(&self.title)
|
||||
.and_then(|capture| capture.get(1))
|
||||
.map(|r#match| (AutomatedChangeKind::VersionBump, r#match.as_str()))
|
||||
.or_else(|| {
|
||||
RELEASE_CHANNEL_REGEX
|
||||
.captures(&self.title)
|
||||
.and_then(|capture| capture.get(1))
|
||||
.map(|r#match| (AutomatedChangeKind::ReleaseChannelUpdate, r#match.as_str()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deref, Default, DerefMut)]
|
||||
pub struct CommitList(Vec<CommitDetails>);
|
||||
|
||||
impl CommitList {
|
||||
pub fn range(&self) -> Option<String> {
|
||||
self.0
|
||||
.first()
|
||||
.zip(self.0.last())
|
||||
.map(|(first, last)| format!("{}..{}", last.sha().0, first.sha().0))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for CommitList {
|
||||
type IntoIter = std::vec::IntoIter<CommitDetails>;
|
||||
type Item = CommitDetails;
|
||||
|
||||
fn into_iter(self) -> std::vec::IntoIter<Self::Item> {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CommitList {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
Ok(CommitList(
|
||||
input
|
||||
.split(CommitDetails::COMMIT_DELIMITER)
|
||||
.filter(|commit_details| !commit_details.is_empty())
|
||||
.map(|commit_details| {
|
||||
let (line, body) = commit_details
|
||||
.trim()
|
||||
.split_once(CommitDetails::BODY_DELIMITER)
|
||||
.expect("Missing body delimiter");
|
||||
CommitDetails::parse(line, body)
|
||||
.expect("Parsing from the output should succeed")
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetVersionTags;
|
||||
|
||||
impl Subcommand for GetVersionTags {
|
||||
type ParsedOutput = VersionTagList;
|
||||
|
||||
fn args(&self) -> impl IntoIterator<Item = String> {
|
||||
["tag", "-l", "v*"].map(ToOwned::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VersionTagList(Vec<VersionTag>);
|
||||
|
||||
impl VersionTagList {
|
||||
pub fn sorted(mut self) -> Self {
|
||||
self.0.sort_by(|a, b| a.version().cmp(b.version()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn find_previous_minor_version(&self, version_tag: &VersionTag) -> Option<&VersionTag> {
|
||||
self.0
|
||||
.iter()
|
||||
.take_while(|tag| tag.version() < version_tag.version())
|
||||
.collect_vec()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|tag| {
|
||||
(tag.version().major < version_tag.version().major
|
||||
|| (tag.version().major == version_tag.version().major
|
||||
&& tag.version().minor < version_tag.version().minor))
|
||||
&& tag.version().patch == 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for VersionTagList {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let version_tags = s.lines().flat_map(VersionTag::parse).collect_vec();
|
||||
|
||||
version_tags
|
||||
.is_empty()
|
||||
.not()
|
||||
.then_some(Self(version_tags))
|
||||
.ok_or_else(|| anyhow::anyhow!("No version tags found"))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InfoForCommit {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
impl InfoForCommit {
|
||||
pub fn new(sha: impl ToString) -> Self {
|
||||
Self {
|
||||
sha: sha.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Subcommand for InfoForCommit {
|
||||
type ParsedOutput = CommitDetails;
|
||||
|
||||
fn args(&self) -> impl IntoIterator<Item = String> {
|
||||
[
|
||||
"log".to_string(),
|
||||
format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
|
||||
format!("{sha}~1..{sha}", sha = self.sha),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommitsFromVersionToVersion {
|
||||
version_tag: VersionTag,
|
||||
branch: String,
|
||||
}
|
||||
|
||||
impl CommitsFromVersionToVersion {
|
||||
pub fn new(version_tag: VersionTag, branch: String) -> Self {
|
||||
Self {
|
||||
version_tag,
|
||||
branch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Subcommand for CommitsFromVersionToVersion {
|
||||
type ParsedOutput = CommitList;
|
||||
|
||||
fn args(&self) -> impl IntoIterator<Item = String> {
|
||||
[
|
||||
"log".to_string(),
|
||||
format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
|
||||
format!(
|
||||
"{version}..{branch}",
|
||||
version = self.version_tag.to_string(),
|
||||
branch = self.branch
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoOutput;
|
||||
|
||||
impl FromStr for NoOutput {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(_: &str) -> Result<Self, Self::Err> {
|
||||
Ok(NoOutput)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use indoc::indoc;
|
||||
|
||||
#[test]
|
||||
fn parse_stable_version_tag() {
|
||||
let tag = VersionTag::parse("v0.172.8").unwrap();
|
||||
assert_eq!(tag.version().major, 0);
|
||||
assert_eq!(tag.version().minor, 172);
|
||||
assert_eq!(tag.version().patch, 8);
|
||||
assert_eq!(tag.1, ReleaseChannel::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_preview_version_tag() {
|
||||
let tag = VersionTag::parse("v0.172.1-pre").unwrap();
|
||||
assert_eq!(tag.version().major, 0);
|
||||
assert_eq!(tag.version().minor, 172);
|
||||
assert_eq!(tag.version().patch, 1);
|
||||
assert_eq!(tag.1, ReleaseChannel::Preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_tag_without_v_prefix() {
|
||||
let tag = VersionTag::parse("0.172.8").unwrap();
|
||||
assert_eq!(tag.version().major, 0);
|
||||
assert_eq!(tag.version().minor, 172);
|
||||
assert_eq!(tag.version().patch, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_version_tag() {
|
||||
let result = VersionTag::parse("vConradTest");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_tag_stable_roundtrip() {
|
||||
let tag = VersionTag::parse("v0.172.8").unwrap();
|
||||
assert_eq!(tag.to_string(), "v0.172.8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_tag_preview_roundtrip() {
|
||||
let tag = VersionTag::parse("v0.172.1-pre").unwrap();
|
||||
assert_eq!(tag.to_string(), "v0.172.1-pre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_orders_by_semver() {
|
||||
let input = indoc! {"
|
||||
v0.172.8
|
||||
v0.170.1
|
||||
v0.171.4
|
||||
v0.170.2
|
||||
v0.172.11
|
||||
v0.171.3
|
||||
v0.172.9
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
for window in list.0.windows(2) {
|
||||
assert!(
|
||||
window[0].version() <= window[1].version(),
|
||||
"{} should come before {}",
|
||||
window[0].to_string(),
|
||||
window[1].to_string()
|
||||
);
|
||||
}
|
||||
assert_eq!(list.0[0].to_string(), "v0.170.1");
|
||||
assert_eq!(list.0[list.0.len() - 1].to_string(), "v0.172.11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_previous_minor_for_173_returns_172() {
|
||||
let input = indoc! {"
|
||||
v0.170.1
|
||||
v0.170.2
|
||||
v0.171.3
|
||||
v0.171.4
|
||||
v0.172.0
|
||||
v0.172.8
|
||||
v0.172.9
|
||||
v0.172.11
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
let target = VersionTag::parse("v0.173.0").unwrap();
|
||||
let previous = list.find_previous_minor_version(&target).unwrap();
|
||||
assert_eq!(previous.version().major, 0);
|
||||
assert_eq!(previous.version().minor, 172);
|
||||
assert_eq!(previous.version().patch, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_previous_minor_skips_same_minor() {
|
||||
let input = indoc! {"
|
||||
v0.172.8
|
||||
v0.172.9
|
||||
v0.172.11
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
let target = VersionTag::parse("v0.172.8").unwrap();
|
||||
assert!(list.find_previous_minor_version(&target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_previous_minor_with_major_version_gap() {
|
||||
let input = indoc! {"
|
||||
v0.172.0
|
||||
v0.172.9
|
||||
v0.172.11
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
let target = VersionTag::parse("v1.0.0").unwrap();
|
||||
let previous = list.find_previous_minor_version(&target).unwrap();
|
||||
assert_eq!(previous.to_string(), "v0.172.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_previous_minor_requires_zero_patch_version() {
|
||||
let input = indoc! {"
|
||||
v0.172.1
|
||||
v0.172.9
|
||||
v0.172.11
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
let target = VersionTag::parse("v1.0.0").unwrap();
|
||||
assert!(list.find_previous_minor_version(&target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tag_list_from_real_tags() {
|
||||
let input = indoc! {"
|
||||
v0.9999-temporary
|
||||
vConradTest
|
||||
v0.172.8
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap();
|
||||
assert_eq!(list.0.len(), 1);
|
||||
assert_eq!(list.0[0].to_string(), "v0.172.8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_tag_list_fails() {
|
||||
let result = VersionTagList::from_str("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_number_from_squash_merge_title() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Add cool feature (#12345)",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert_eq!(commit.pr_number(), Some(12345));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_number_missing() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Some commit without PR ref",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert_eq!(commit.pr_number(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_number_takes_last_match() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Fix (#123) and refactor (#456)",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert_eq!(commit.pr_number(), Some(456));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn co_authors_parsed_from_body() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Some title",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let body = indoc! {"
|
||||
Co-authored-by: Alice Smith <alice@example.com>
|
||||
Co-authored-by: Bob Jones <bob@example.com>
|
||||
"};
|
||||
let commit = CommitDetails::parse(&line, body).unwrap();
|
||||
let co_authors = commit.co_authors().unwrap();
|
||||
assert_eq!(co_authors.len(), 2);
|
||||
assert_eq!(
|
||||
co_authors[0],
|
||||
Committer::new("Alice Smith", "alice@example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
co_authors[1],
|
||||
Committer::new("Bob Jones", "bob@example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_co_authors_returns_none() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Some title",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert!(commit.co_authors().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_sha_short_returns_first_8_chars() {
|
||||
let sha = CommitSha("abcdef1234567890abcdef1234567890abcdef12".into());
|
||||
assert_eq!(sha.short(), "abcdef12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_detects_version_bump() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
let (kind, actor) = commit.detect_automated_change().unwrap();
|
||||
assert_eq!(kind, AutomatedChangeKind::VersionBump);
|
||||
assert_eq!(actor, "cole-miller");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_detects_stable_release_channel() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}v0.233.x stable for @cole-miller",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
let (kind, actor) = commit.detect_automated_change().unwrap();
|
||||
assert_eq!(kind, AutomatedChangeKind::ReleaseChannelUpdate);
|
||||
assert_eq!(actor, "cole-miller");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_detects_preview_release_channel() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}v0.234.x preview for @cole-miller",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
let (kind, actor) = commit.detect_automated_change().unwrap();
|
||||
assert_eq!(kind, AutomatedChangeKind::ReleaseChannelUpdate);
|
||||
assert_eq!(actor, "cole-miller");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_returns_none_for_regular_commit() {
|
||||
let line = format!(
|
||||
"abc123{d}Alice{d}alice@test.com{d}Fix a bug",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert!(commit.detect_automated_change().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_rejects_wrong_prefix() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}Fix thing for @cole-miller",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert!(commit.detect_automated_change().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_rejects_trailing_text() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller extra",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert!(commit.detect_automated_change().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committer_is_zed_zippy() {
|
||||
let committer = Committer::new("Zed Zippy", ZED_ZIPPY_EMAIL);
|
||||
assert!(committer.is_zed_zippy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committer_is_not_zed_zippy() {
|
||||
let committer = Committer::new("Alice", "alice@test.com");
|
||||
assert!(!committer.is_zed_zippy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_commit_list_from_git_log_format() {
|
||||
let fd = CommitDetails::FIELD_DELIMITER;
|
||||
let bd = CommitDetails::BODY_DELIMITER;
|
||||
let cd = CommitDetails::COMMIT_DELIMITER;
|
||||
|
||||
let input = format!(
|
||||
"sha111{fd}Alice{fd}alice@test.com{fd}First commit (#100){bd}First body{cd}sha222{fd}Bob{fd}bob@test.com{fd}Second commit (#200){bd}Second body{cd}"
|
||||
);
|
||||
|
||||
let list = CommitList::from_str(&input).unwrap();
|
||||
assert_eq!(list.0.len(), 2);
|
||||
|
||||
assert_eq!(list.0[0].sha().0, "sha111");
|
||||
assert_eq!(
|
||||
list.0[0].author(),
|
||||
&Committer::new("Alice", "alice@test.com")
|
||||
);
|
||||
assert_eq!(list.0[0].title(), "First commit (#100)");
|
||||
assert_eq!(list.0[0].pr_number(), Some(100));
|
||||
assert_eq!(list.0[0].body, "First body");
|
||||
|
||||
assert_eq!(list.0[1].sha().0, "sha222");
|
||||
assert_eq!(list.0[1].author(), &Committer::new("Bob", "bob@test.com"));
|
||||
assert_eq!(list.0[1].title(), "Second commit (#200)");
|
||||
assert_eq!(list.0[1].pr_number(), Some(200));
|
||||
assert_eq!(list.0[1].body, "Second body");
|
||||
}
|
||||
}
|
||||
725
tooling/compliance/src/github.rs
Normal file
725
tooling/compliance/src/github.rs
Normal file
@@ -0,0 +1,725 @@
|
||||
use std::{borrow::Cow, collections::HashMap, fmt};
|
||||
|
||||
use anyhow::Result;
|
||||
use derive_more::Deref;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::git::CommitSha;
|
||||
|
||||
pub const PR_REVIEW_LABEL: &str = "PR state:needs review";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GithubUser {
|
||||
pub login: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PullRequestData {
|
||||
pub number: u64,
|
||||
pub user: Option<GithubUser>,
|
||||
pub merged_by: Option<GithubUser>,
|
||||
pub labels: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReviewState {
|
||||
Approved,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthorAssociation {
|
||||
Owner,
|
||||
Member,
|
||||
Collaborator,
|
||||
Contributor,
|
||||
FirstTimeContributor,
|
||||
FirstTimer,
|
||||
Mannequin,
|
||||
None,
|
||||
}
|
||||
|
||||
impl AuthorAssociation {
|
||||
pub fn has_write_access(&self) -> bool {
|
||||
matches!(self, Self::Owner | Self::Member | Self::Collaborator)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Approvable {
|
||||
fn author_login(&self) -> Option<&str>;
|
||||
fn review_state(&self) -> Option<ReviewState>;
|
||||
fn body(&self) -> Option<&str>;
|
||||
fn author_association(&self) -> Option<AuthorAssociation>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PullRequestReview {
|
||||
pub user: Option<GithubUser>,
|
||||
pub state: Option<ReviewState>,
|
||||
pub body: Option<String>,
|
||||
pub author_association: Option<AuthorAssociation>,
|
||||
}
|
||||
|
||||
impl PullRequestReview {
|
||||
pub fn with_body(self, body: impl ToString) -> Self {
|
||||
Self {
|
||||
body: Some(body.to_string()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Approvable for PullRequestReview {
|
||||
fn author_login(&self) -> Option<&str> {
|
||||
self.user.as_ref().map(|user| user.login.as_str())
|
||||
}
|
||||
|
||||
fn review_state(&self) -> Option<ReviewState> {
|
||||
self.state
|
||||
}
|
||||
|
||||
fn body(&self) -> Option<&str> {
|
||||
self.body.as_deref()
|
||||
}
|
||||
|
||||
fn author_association(&self) -> Option<AuthorAssociation> {
|
||||
self.author_association
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PullRequestComment {
|
||||
pub user: GithubUser,
|
||||
pub body: Option<String>,
|
||||
pub author_association: Option<AuthorAssociation>,
|
||||
}
|
||||
|
||||
impl Approvable for PullRequestComment {
|
||||
fn author_login(&self) -> Option<&str> {
|
||||
Some(&self.user.login)
|
||||
}
|
||||
|
||||
fn review_state(&self) -> Option<ReviewState> {
|
||||
None
|
||||
}
|
||||
|
||||
fn body(&self) -> Option<&str> {
|
||||
self.body.as_deref()
|
||||
}
|
||||
|
||||
fn author_association(&self) -> Option<AuthorAssociation> {
|
||||
self.author_association
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Deref, PartialEq, Eq)]
|
||||
pub struct GithubLogin {
|
||||
login: String,
|
||||
}
|
||||
|
||||
impl GithubLogin {
|
||||
pub fn new(login: String) -> Self {
|
||||
Self { login }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GithubLogin {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "@{}", self.login)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct CommitAuthor {
|
||||
name: String,
|
||||
email: String,
|
||||
user: Option<GithubLogin>,
|
||||
}
|
||||
|
||||
impl CommitAuthor {
|
||||
pub(crate) fn user(&self) -> Option<&GithubLogin> {
|
||||
self.user.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for CommitAuthor {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.user.as_ref().zip(other.user.as_ref()).map_or_else(
|
||||
|| self.email == other.email || self.name == other.name,
|
||||
|(l, r)| l == r,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CommitAuthor {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self.user.as_ref() {
|
||||
Some(user) => write!(formatter, "{} ({user})", self.name),
|
||||
None => write!(formatter, "{} ({})", self.name, self.email),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct CommitSignature {
|
||||
#[serde(rename = "isValid")]
|
||||
is_valid: bool,
|
||||
signer: Option<GithubLogin>,
|
||||
}
|
||||
|
||||
impl CommitSignature {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.is_valid
|
||||
}
|
||||
|
||||
pub fn signer(&self) -> Option<&GithubLogin> {
|
||||
self.signer.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CommitFileChange {
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommitMetadata {
|
||||
#[serde(rename = "author")]
|
||||
primary_author: CommitAuthor,
|
||||
#[serde(rename = "authors", deserialize_with = "graph_ql::deserialize_nodes")]
|
||||
co_authors: Vec<CommitAuthor>,
|
||||
#[serde(default)]
|
||||
signature: Option<CommitSignature>,
|
||||
#[serde(default)]
|
||||
additions: u64,
|
||||
#[serde(default)]
|
||||
deletions: u64,
|
||||
}
|
||||
|
||||
impl CommitMetadata {
|
||||
pub fn co_authors(&self) -> Option<impl Iterator<Item = &CommitAuthor>> {
|
||||
let mut co_authors = self
|
||||
.co_authors
|
||||
.iter()
|
||||
.filter(|co_author| *co_author != &self.primary_author)
|
||||
.peekable();
|
||||
|
||||
co_authors.peek().is_some().then_some(co_authors)
|
||||
}
|
||||
|
||||
pub fn primary_author(&self) -> &CommitAuthor {
|
||||
&self.primary_author
|
||||
}
|
||||
|
||||
pub fn signature(&self) -> Option<&CommitSignature> {
|
||||
self.signature.as_ref()
|
||||
}
|
||||
|
||||
pub fn additions(&self) -> u64 {
|
||||
self.additions
|
||||
}
|
||||
|
||||
pub fn deletions(&self) -> u64 {
|
||||
self.deletions
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deref)]
|
||||
pub struct CommitMetadataBySha(HashMap<CommitSha, CommitMetadata>);
|
||||
|
||||
impl CommitMetadataBySha {
|
||||
const SHA_PREFIX: &'static str = "commit";
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for CommitMetadataBySha {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = HashMap::<String, CommitMetadata>::deserialize(deserializer)?;
|
||||
let map = raw
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
let sha = key
|
||||
.strip_prefix(CommitMetadataBySha::SHA_PREFIX)
|
||||
.unwrap_or(&key);
|
||||
(CommitSha::new(sha.to_owned()), value)
|
||||
})
|
||||
.collect();
|
||||
Ok(Self(map))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Repository<'a> {
|
||||
owner: Cow<'a, str>,
|
||||
name: Cow<'a, str>,
|
||||
}
|
||||
|
||||
impl<'a> Repository<'a> {
|
||||
pub const ZED: Repository<'static> = Repository::new_static("zed-industries", "zed");
|
||||
|
||||
pub fn new(owner: &'a str, name: &'a str) -> Self {
|
||||
Self {
|
||||
owner: Cow::Borrowed(owner),
|
||||
name: Cow::Borrowed(name),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn owner(&self) -> &str {
|
||||
&self.owner
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
impl Repository<'static> {
|
||||
pub const fn new_static(owner: &'static str, name: &'static str) -> Self {
|
||||
Self {
|
||||
owner: Cow::Borrowed(owner),
|
||||
name: Cow::Borrowed(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub trait GithubApiClient {
|
||||
async fn get_pull_request(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<PullRequestData>;
|
||||
async fn get_pull_request_reviews(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<Vec<PullRequestReview>>;
|
||||
async fn get_pull_request_comments(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<Vec<PullRequestComment>>;
|
||||
async fn get_commit_metadata(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
commit_shas: &[&CommitSha],
|
||||
) -> Result<CommitMetadataBySha>;
|
||||
async fn get_commit_files(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
sha: &CommitSha,
|
||||
) -> Result<Vec<CommitFileChange>>;
|
||||
async fn check_repo_write_permission(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
login: &GithubLogin,
|
||||
) -> Result<bool>;
|
||||
async fn add_label_to_issue(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
label: &str,
|
||||
issue_number: u64,
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
pub mod graph_ql {
|
||||
use anyhow::{Context as _, Result};
|
||||
use itertools::Itertools as _;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::git::CommitSha;
|
||||
|
||||
use super::CommitMetadataBySha;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GraphQLResponse<T> {
|
||||
pub data: Option<T>,
|
||||
pub errors: Option<Vec<GraphQLError>>,
|
||||
}
|
||||
|
||||
impl<T> GraphQLResponse<T> {
|
||||
pub fn into_data(self) -> Result<T> {
|
||||
if let Some(errors) = &self.errors {
|
||||
if !errors.is_empty() {
|
||||
let messages: String = errors.iter().map(|e| e.message.as_str()).join("; ");
|
||||
anyhow::bail!("GraphQL error: {messages}");
|
||||
}
|
||||
}
|
||||
|
||||
self.data.context("GraphQL response contained no data")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GraphQLError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommitMetadataResponse {
|
||||
pub repository: CommitMetadataBySha,
|
||||
}
|
||||
|
||||
pub fn deserialize_nodes<'de, T, D>(deserializer: D) -> std::result::Result<Vec<T>, D::Error>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct Nodes<T> {
|
||||
nodes: Vec<T>,
|
||||
}
|
||||
Nodes::<T>::deserialize(deserializer).map(|wrapper| wrapper.nodes)
|
||||
}
|
||||
|
||||
pub fn build_commit_metadata_query<'a>(
|
||||
org: &str,
|
||||
repo: &str,
|
||||
shas: impl IntoIterator<Item = &'a CommitSha>,
|
||||
) -> String {
|
||||
const FRAGMENT: &str = r#"
|
||||
... on Commit {
|
||||
author {
|
||||
name
|
||||
email
|
||||
user { login }
|
||||
}
|
||||
authors(first: 10) {
|
||||
nodes {
|
||||
name
|
||||
email
|
||||
user { login }
|
||||
}
|
||||
}
|
||||
signature {
|
||||
isValid
|
||||
signer { login }
|
||||
}
|
||||
additions
|
||||
deletions
|
||||
}
|
||||
"#;
|
||||
|
||||
let objects = shas
|
||||
.into_iter()
|
||||
.map(|commit_sha| {
|
||||
format!(
|
||||
"{sha_prefix}{sha}: object(oid: \"{sha}\") {{ {FRAGMENT} }}",
|
||||
sha_prefix = CommitMetadataBySha::SHA_PREFIX,
|
||||
sha = **commit_sha,
|
||||
)
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
format!("{{ repository(owner: \"{org}\", name: \"{repo}\") {{ {objects} }} }}")
|
||||
.replace("\n", "")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "octo-client")]
|
||||
mod octo_client {
|
||||
use anyhow::{Context, Result};
|
||||
use futures::TryStreamExt as _;
|
||||
use jsonwebtoken::EncodingKey;
|
||||
use octocrab::{
|
||||
Octocrab, Page,
|
||||
models::{
|
||||
AuthorAssociation as OctocrabAuthorAssociation,
|
||||
pulls::ReviewState as OctocrabReviewState,
|
||||
},
|
||||
service::middleware::cache::mem::InMemoryCache,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::pin;
|
||||
|
||||
use crate::{
|
||||
git::CommitSha,
|
||||
github::{Repository, graph_ql},
|
||||
};
|
||||
|
||||
use super::{
|
||||
AuthorAssociation, CommitFileChange, CommitMetadataBySha, GithubApiClient, GithubLogin,
|
||||
GithubUser, PullRequestComment, PullRequestData, PullRequestReview, ReviewState,
|
||||
};
|
||||
|
||||
fn convert_author_association(association: OctocrabAuthorAssociation) -> AuthorAssociation {
|
||||
match association {
|
||||
OctocrabAuthorAssociation::Owner => AuthorAssociation::Owner,
|
||||
OctocrabAuthorAssociation::Member => AuthorAssociation::Member,
|
||||
OctocrabAuthorAssociation::Collaborator => AuthorAssociation::Collaborator,
|
||||
OctocrabAuthorAssociation::Contributor => AuthorAssociation::Contributor,
|
||||
OctocrabAuthorAssociation::FirstTimeContributor => {
|
||||
AuthorAssociation::FirstTimeContributor
|
||||
}
|
||||
OctocrabAuthorAssociation::FirstTimer => AuthorAssociation::FirstTimer,
|
||||
OctocrabAuthorAssociation::Mannequin => AuthorAssociation::Mannequin,
|
||||
OctocrabAuthorAssociation::None => AuthorAssociation::None,
|
||||
_ => AuthorAssociation::None,
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE: u8 = 100;
|
||||
|
||||
pub struct OctocrabClient {
|
||||
client: Octocrab,
|
||||
}
|
||||
|
||||
impl OctocrabClient {
|
||||
pub async fn new(app_id: u64, app_private_key: &str, org: &str) -> Result<Self> {
|
||||
let octocrab = Octocrab::builder()
|
||||
.cache(InMemoryCache::new())
|
||||
.app(
|
||||
app_id.into(),
|
||||
EncodingKey::from_rsa_pem(app_private_key.as_bytes())?,
|
||||
)
|
||||
.build()?;
|
||||
|
||||
let installations = octocrab
|
||||
.apps()
|
||||
.installations()
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch installations")?
|
||||
.take_items();
|
||||
|
||||
let installation_id = installations
|
||||
.into_iter()
|
||||
.find(|installation| installation.account.login == org)
|
||||
.context("Could not find Zed repository in installations")?
|
||||
.id;
|
||||
|
||||
let client = octocrab.installation(installation_id)?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
async fn graphql<R: DeserializeOwned>(&self, query: &serde_json::Value) -> Result<R> {
|
||||
let response: serde_json::Value = self.client.graphql(query).await?;
|
||||
let parsed: graph_ql::GraphQLResponse<R> = serde_json::from_value(response)
|
||||
.context("Failed to parse GraphQL response envelope")?;
|
||||
parsed.into_data()
|
||||
}
|
||||
|
||||
async fn get_all<T: DeserializeOwned + 'static>(
|
||||
&self,
|
||||
page: Page<T>,
|
||||
) -> octocrab::Result<Vec<T>> {
|
||||
self.get_filtered(page, |_| true).await
|
||||
}
|
||||
|
||||
async fn get_filtered<T: DeserializeOwned + 'static>(
|
||||
&self,
|
||||
page: Page<T>,
|
||||
predicate: impl Fn(&T) -> bool,
|
||||
) -> octocrab::Result<Vec<T>> {
|
||||
let stream = page.into_stream(&self.client);
|
||||
pin!(stream);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
while let Some(item) = stream.try_next().await?
|
||||
&& predicate(&item)
|
||||
{
|
||||
results.push(item);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl GithubApiClient for OctocrabClient {
|
||||
async fn get_pull_request(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<PullRequestData> {
|
||||
let pr = self
|
||||
.client
|
||||
.pulls(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.get(pr_number)
|
||||
.await?;
|
||||
Ok(PullRequestData {
|
||||
number: pr.number,
|
||||
user: pr.user.map(|user| GithubUser { login: user.login }),
|
||||
merged_by: pr.merged_by.map(|user| GithubUser { login: user.login }),
|
||||
labels: pr
|
||||
.labels
|
||||
.map(|labels| labels.into_iter().map(|label| label.name).collect()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_pull_request_reviews(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<Vec<PullRequestReview>> {
|
||||
let page = self
|
||||
.client
|
||||
.pulls(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.list_reviews(pr_number)
|
||||
.per_page(PAGE_SIZE)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let reviews = self.get_all(page).await?;
|
||||
|
||||
Ok(reviews
|
||||
.into_iter()
|
||||
.map(|review| PullRequestReview {
|
||||
user: review.user.map(|user| GithubUser { login: user.login }),
|
||||
state: review.state.map(|state| match state {
|
||||
OctocrabReviewState::Approved => ReviewState::Approved,
|
||||
_ => ReviewState::Other,
|
||||
}),
|
||||
body: review.body,
|
||||
author_association: review.author_association.map(convert_author_association),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pull_request_comments(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<Vec<PullRequestComment>> {
|
||||
let page = self
|
||||
.client
|
||||
.issues(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.list_comments(pr_number)
|
||||
.per_page(PAGE_SIZE)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let comments = self.get_all(page).await?;
|
||||
|
||||
Ok(comments
|
||||
.into_iter()
|
||||
.map(|comment| PullRequestComment {
|
||||
user: GithubUser {
|
||||
login: comment.user.login,
|
||||
},
|
||||
body: comment.body,
|
||||
author_association: comment.author_association.map(convert_author_association),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_commit_metadata(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
commit_shas: &[&CommitSha],
|
||||
) -> Result<CommitMetadataBySha> {
|
||||
let query = graph_ql::build_commit_metadata_query(
|
||||
repo.owner.as_ref(),
|
||||
repo.name.as_ref(),
|
||||
commit_shas.iter().copied(),
|
||||
);
|
||||
let query = serde_json::json!({ "query": query });
|
||||
self.graphql::<graph_ql::CommitMetadataResponse>(&query)
|
||||
.await
|
||||
.map(|response| response.repository)
|
||||
}
|
||||
|
||||
async fn get_commit_files(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
sha: &CommitSha,
|
||||
) -> Result<Vec<CommitFileChange>> {
|
||||
let response = self
|
||||
.client
|
||||
.commits(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.get(sha.as_str())
|
||||
.await?;
|
||||
|
||||
Ok(response
|
||||
.files
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|file| CommitFileChange {
|
||||
filename: file.filename,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn check_repo_write_permission(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
login: &GithubLogin,
|
||||
) -> Result<bool> {
|
||||
// Check org membership first - we save ourselves a few request that way
|
||||
let page = self
|
||||
.client
|
||||
.orgs(repo.owner.as_ref())
|
||||
.list_members()
|
||||
.per_page(PAGE_SIZE)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let members = self.get_all(page).await?;
|
||||
|
||||
if members
|
||||
.into_iter()
|
||||
.any(|member| member.login == login.as_str())
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// TODO: octocrab fails to deserialize the permission response and
|
||||
// does not adhere to the scheme laid out at
|
||||
// https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2026-03-10#get-repository-permissions-for-a-user
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum RepoPermission {
|
||||
Admin,
|
||||
Write,
|
||||
Read,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RepositoryPermissions {
|
||||
permission: RepoPermission,
|
||||
}
|
||||
|
||||
self.client
|
||||
.get::<RepositoryPermissions, _, _>(
|
||||
format!(
|
||||
"/repos/{owner}/{repo}/collaborators/{user}/permission",
|
||||
owner = repo.owner.as_ref(),
|
||||
repo = repo.name.as_ref(),
|
||||
user = login.as_str()
|
||||
),
|
||||
None::<&()>,
|
||||
)
|
||||
.await
|
||||
.map(|response| {
|
||||
matches!(
|
||||
response.permission,
|
||||
RepoPermission::Write | RepoPermission::Admin
|
||||
)
|
||||
})
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn add_label_to_issue(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
label: &str,
|
||||
issue_number: u64,
|
||||
) -> Result<()> {
|
||||
self.client
|
||||
.issues(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.add_labels(issue_number, &[label.to_owned()])
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "octo-client")]
|
||||
pub use octo_client::OctocrabClient;
|
||||
4
tooling/compliance/src/lib.rs
Normal file
4
tooling/compliance/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod checks;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod report;
|
||||
493
tooling/compliance/src/report.rs
Normal file
493
tooling/compliance/src/report.rs
Normal file
@@ -0,0 +1,493 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use derive_more::Display;
|
||||
use itertools::{Either, Itertools};
|
||||
|
||||
use crate::{
|
||||
checks::{ReviewFailure, ReviewResult, ReviewSuccess},
|
||||
git::CommitDetails,
|
||||
};
|
||||
|
||||
const PULL_REQUEST_BASE_URL: &str = "https://github.com/zed-industries/zed/pull";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReportEntry<R> {
|
||||
pub commit: CommitDetails,
|
||||
reason: R,
|
||||
}
|
||||
|
||||
impl<R> ReportEntry<R> {
|
||||
pub fn new(commit: CommitDetails, reason: R) -> Self {
|
||||
Self { commit, reason }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ToString> ReportEntry<R> {
|
||||
fn commit_cell(&self) -> String {
|
||||
let title = escape_markdown_link_text(self.commit.title());
|
||||
|
||||
match self.commit.pr_number() {
|
||||
Some(pr_number) => format!("[{title}]({PULL_REQUEST_BASE_URL}/{pr_number})"),
|
||||
None => escape_markdown_table_text(self.commit.title()),
|
||||
}
|
||||
}
|
||||
|
||||
fn pull_request_cell(&self) -> String {
|
||||
self.commit
|
||||
.pr_number()
|
||||
.map(|pr_number| format!("#{pr_number}"))
|
||||
.unwrap_or_else(|| "—".to_owned())
|
||||
}
|
||||
|
||||
fn author_cell(&self) -> String {
|
||||
escape_markdown_table_text(&self.commit.author().to_string())
|
||||
}
|
||||
|
||||
fn reason_cell(&self) -> String {
|
||||
escape_markdown_table_text(&self.reason.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl ReportEntry<ReviewResult> {
|
||||
pub fn is_unknown_error(&self) -> bool {
|
||||
matches!(self.reason, Err(ReviewFailure::Other(_)))
|
||||
}
|
||||
}
|
||||
|
||||
impl ReportEntry<ReviewFailure> {
|
||||
fn issue_kind(&self) -> IssueKind {
|
||||
match self.reason {
|
||||
ReviewFailure::Other(_) => IssueKind::Error,
|
||||
_ => IssueKind::NotReviewed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReportEntry<ReviewSuccess> {
|
||||
fn reviewers_cell(&self) -> String {
|
||||
match &self.reason.reviewers() {
|
||||
Ok(reviewers) => escape_markdown_table_text(&reviewers),
|
||||
Err(_) => "—".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ReportSummary {
|
||||
pub pull_requests: usize,
|
||||
pub reviewed_prs: usize,
|
||||
pub other_checked: usize,
|
||||
pub not_reviewed: usize,
|
||||
pub errors: usize,
|
||||
}
|
||||
|
||||
pub enum ReportReviewSummary {
|
||||
MissingReviews,
|
||||
MissingReviewsWithErrors,
|
||||
NoIssuesFound,
|
||||
}
|
||||
|
||||
impl ReportSummary {
|
||||
fn from_entries(entries: &[ReportEntry<ReviewResult>]) -> Self {
|
||||
Self {
|
||||
pull_requests: entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.commit.pr_number())
|
||||
.unique()
|
||||
.count(),
|
||||
reviewed_prs: entries
|
||||
.iter()
|
||||
.filter(|entry| entry.reason.is_ok() && entry.commit.pr_number().is_some())
|
||||
.count(),
|
||||
other_checked: entries
|
||||
.iter()
|
||||
.filter(|entry| entry.reason.is_ok() && entry.commit.pr_number().is_none())
|
||||
.count(),
|
||||
not_reviewed: entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
entry.reason,
|
||||
Err(ReviewFailure::NoPullRequestFound
|
||||
| ReviewFailure::Unreviewed
|
||||
| ReviewFailure::UnexpectedZippyAction(_))
|
||||
)
|
||||
})
|
||||
.count(),
|
||||
errors: entries
|
||||
.iter()
|
||||
.filter(|entry| entry.is_unknown_error())
|
||||
.count(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn review_summary(&self) -> ReportReviewSummary {
|
||||
match self.not_reviewed {
|
||||
0 if self.errors == 0 => ReportReviewSummary::NoIssuesFound,
|
||||
1.. if self.errors == 0 => ReportReviewSummary::MissingReviews,
|
||||
_ => ReportReviewSummary::MissingReviewsWithErrors,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_errors(&self) -> bool {
|
||||
self.errors > 0
|
||||
}
|
||||
|
||||
pub fn prs_with_errors(&self) -> usize {
|
||||
self.pull_requests.saturating_sub(self.reviewed_prs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Display, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum IssueKind {
|
||||
#[display("Error")]
|
||||
Error,
|
||||
#[display("Not reviewed")]
|
||||
NotReviewed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
entries: Vec<ReportEntry<ReviewResult>>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn from_entries(entries: impl IntoIterator<Item = ReportEntry<ReviewResult>>) -> Self {
|
||||
Self {
|
||||
entries: entries.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, commit: CommitDetails, result: ReviewResult) {
|
||||
self.entries.push(ReportEntry {
|
||||
commit,
|
||||
reason: result,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn errors(&self) -> impl Iterator<Item = &ReportEntry<ReviewResult>> {
|
||||
self.entries.iter().filter(|entry| entry.reason.is_err())
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> ReportSummary {
|
||||
ReportSummary::from_entries(&self.entries)
|
||||
}
|
||||
|
||||
pub fn write_markdown(self, path: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
let path = path.as_ref();
|
||||
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"Failed to create parent directory for markdown report at {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let summary = self.summary();
|
||||
let (successes, mut issues): (Vec<_>, Vec<_>) =
|
||||
self.entries
|
||||
.into_iter()
|
||||
.partition_map(|entry| match entry.reason {
|
||||
Ok(success) => Either::Left(ReportEntry {
|
||||
reason: success,
|
||||
commit: entry.commit,
|
||||
}),
|
||||
Err(fail) => Either::Right(ReportEntry {
|
||||
reason: fail,
|
||||
commit: entry.commit,
|
||||
}),
|
||||
});
|
||||
|
||||
issues.sort_by_key(|entry| entry.issue_kind());
|
||||
|
||||
let file = File::create(path)
|
||||
.with_context(|| format!("Failed to create markdown report at {}", path.display()))?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
writeln!(writer, "# Compliance report")?;
|
||||
writeln!(writer)?;
|
||||
writeln!(writer, "## Overview")?;
|
||||
writeln!(writer)?;
|
||||
writeln!(writer, "- PRs: {}", summary.pull_requests)?;
|
||||
writeln!(writer, "- Reviewed: {}", summary.reviewed_prs)?;
|
||||
writeln!(writer, "- Not reviewed: {}", summary.not_reviewed)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"- Differently validated commits: {}",
|
||||
summary.other_checked
|
||||
)?;
|
||||
if summary.has_errors() {
|
||||
writeln!(writer, "- Errors: {}", summary.errors)?;
|
||||
}
|
||||
writeln!(writer)?;
|
||||
|
||||
write_issue_table(&mut writer, &issues, &summary)?;
|
||||
write_success_table(&mut writer, &successes)?;
|
||||
|
||||
writer
|
||||
.flush()
|
||||
.with_context(|| format!("Failed to flush markdown report to {}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_issue_table(
|
||||
writer: &mut impl Write,
|
||||
issues: &[ReportEntry<ReviewFailure>],
|
||||
summary: &ReportSummary,
|
||||
) -> std::io::Result<()> {
|
||||
if summary.has_errors() {
|
||||
writeln!(writer, "## Errors and unreviewed commits")?;
|
||||
} else {
|
||||
writeln!(writer, "## Unreviewed commits")?;
|
||||
}
|
||||
writeln!(writer)?;
|
||||
|
||||
if issues.is_empty() {
|
||||
if summary.has_errors() {
|
||||
writeln!(writer, "No errors or unreviewed commits found.")?;
|
||||
} else {
|
||||
writeln!(writer, "No unreviewed commits found.")?;
|
||||
}
|
||||
writeln!(writer)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
writeln!(writer, "| Commit | PR | Author | Outcome | Reason |")?;
|
||||
writeln!(writer, "| --- | --- | --- | --- | --- |")?;
|
||||
|
||||
for entry in issues {
|
||||
let issue_kind = entry.issue_kind();
|
||||
writeln!(
|
||||
writer,
|
||||
"| {} | {} | {} | {} | {} |",
|
||||
entry.commit_cell(),
|
||||
entry.pull_request_cell(),
|
||||
entry.author_cell(),
|
||||
issue_kind,
|
||||
entry.reason_cell(),
|
||||
)?;
|
||||
}
|
||||
|
||||
writeln!(writer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_success_table(
|
||||
writer: &mut impl Write,
|
||||
successful_entries: &[ReportEntry<ReviewSuccess>],
|
||||
) -> std::io::Result<()> {
|
||||
writeln!(writer, "## Successful commits")?;
|
||||
writeln!(writer)?;
|
||||
|
||||
if successful_entries.is_empty() {
|
||||
writeln!(writer, "No successful commits found.")?;
|
||||
writeln!(writer)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
writeln!(writer, "| Commit | PR | Author | Reviewers | Reason |")?;
|
||||
writeln!(writer, "| --- | --- | --- | --- | --- |")?;
|
||||
|
||||
for entry in successful_entries {
|
||||
writeln!(
|
||||
writer,
|
||||
"| {} | {} | {} | {} | {} |",
|
||||
entry.commit_cell(),
|
||||
entry.pull_request_cell(),
|
||||
entry.author_cell(),
|
||||
entry.reviewers_cell(),
|
||||
entry.reason_cell(),
|
||||
)?;
|
||||
}
|
||||
|
||||
writeln!(writer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn escape_markdown_link_text(input: &str) -> String {
|
||||
escape_markdown_table_text(input)
|
||||
.replace('[', r"\[")
|
||||
.replace(']', r"\]")
|
||||
}
|
||||
|
||||
fn escape_markdown_table_text(input: &str) -> String {
|
||||
input
|
||||
.replace('\\', r"\\")
|
||||
.replace('|', r"\|")
|
||||
.replace('\r', "")
|
||||
.replace('\n', "<br>")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
checks::{ReviewFailure, ReviewSuccess},
|
||||
git::{AutomatedChangeKind, CommitDetails, CommitList},
|
||||
github::{AuthorAssociation, GithubLogin, GithubUser, PullRequestReview, ReviewState},
|
||||
};
|
||||
|
||||
use super::{Report, ReportReviewSummary};
|
||||
|
||||
fn make_commit(
|
||||
sha: &str,
|
||||
author_name: &str,
|
||||
author_email: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) -> CommitDetails {
|
||||
let formatted = format!(
|
||||
"{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|{title}|body-delimiter|{body}|commit-delimiter|"
|
||||
);
|
||||
CommitList::from_str(&formatted)
|
||||
.expect("test commit should parse")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("should have one commit")
|
||||
}
|
||||
|
||||
fn reviewed() -> ReviewSuccess {
|
||||
ReviewSuccess::PullRequestReviewed(vec![PullRequestReview {
|
||||
user: Some(GithubUser {
|
||||
login: "reviewer".to_owned(),
|
||||
}),
|
||||
state: Some(ReviewState::Approved),
|
||||
body: None,
|
||||
author_association: Some(AuthorAssociation::Member),
|
||||
}])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_counts_are_accurate() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit(
|
||||
"aaa",
|
||||
"Alice",
|
||||
"alice@test.com",
|
||||
"Reviewed commit (#100)",
|
||||
"",
|
||||
),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Unreviewed commit (#200)", ""),
|
||||
Err(ReviewFailure::Unreviewed),
|
||||
);
|
||||
report.add(
|
||||
make_commit("ccc", "Carol", "carol@test.com", "No PR commit", ""),
|
||||
Err(ReviewFailure::NoPullRequestFound),
|
||||
);
|
||||
report.add(
|
||||
make_commit("ddd", "Dave", "dave@test.com", "Error commit (#300)", ""),
|
||||
Err(ReviewFailure::Other(anyhow::anyhow!("some error"))),
|
||||
);
|
||||
report.add(
|
||||
make_commit("ddd", "Dave", "dave@test.com", "Bump Version", ""),
|
||||
Ok(ReviewSuccess::ZedZippyCommit(
|
||||
AutomatedChangeKind::VersionBump,
|
||||
GithubLogin::new("dave".to_string()),
|
||||
)),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert_eq!(summary.pull_requests, 3);
|
||||
assert_eq!(summary.reviewed_prs, 1);
|
||||
assert_eq!(summary.other_checked, 1);
|
||||
assert_eq!(summary.not_reviewed, 2);
|
||||
assert_eq!(summary.errors, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_all_reviewed_is_no_issues() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit("aaa", "Alice", "alice@test.com", "First (#100)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Second (#200)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert!(matches!(
|
||||
summary.review_summary(),
|
||||
ReportReviewSummary::NoIssuesFound
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_missing_reviews_only() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit("aaa", "Alice", "alice@test.com", "Reviewed (#100)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Unreviewed (#200)", ""),
|
||||
Err(ReviewFailure::Unreviewed),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert!(matches!(
|
||||
summary.review_summary(),
|
||||
ReportReviewSummary::MissingReviews
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_errors_and_missing_reviews() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit("aaa", "Alice", "alice@test.com", "Unreviewed (#100)", ""),
|
||||
Err(ReviewFailure::Unreviewed),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Errored (#200)", ""),
|
||||
Err(ReviewFailure::Other(anyhow::anyhow!("check failed"))),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert!(matches!(
|
||||
summary.review_summary(),
|
||||
ReportReviewSummary::MissingReviewsWithErrors
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_deduplicates_pull_requests() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit("aaa", "Alice", "alice@test.com", "First change (#100)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Second change (#100)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert_eq!(summary.pull_requests, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user