use serde::{Deserialize, Serialize};
use crate::bootstrap::MergeMethod;
use crate::model::{
ForgeAccount, Projection, ProtectionState, RepoState, RoleAssignment, Visibility,
};
use crate::resource::Resource;
use crate::rights::ForgeRole;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ForgeEvent {
pub delivery_id: Option<String>,
pub kind: ForgeEventKind,
}
impl ForgeEvent {
pub fn new(delivery_id: Option<String>, kind: ForgeEventKind) -> Self {
ForgeEvent { delivery_id, kind }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum MemberChange {
Added,
Removed,
Edited,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum InstallationChange {
Created,
Deleted,
Suspended,
Unsuspended,
PermissionsAccepted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[non_exhaustive]
pub enum ForgeEventKind {
RepoCreated {
repo: Resource,
forge_id: u64,
},
RepoDeleted {
repo: Resource,
forge_id: u64,
},
RepoRenamed {
forge_id: u64,
from: Resource,
to: Resource,
},
RepoTransferred {
forge_id: u64,
from_namespace: Option<Resource>,
to: Resource,
},
RepoArchived {
repo: Resource,
forge_id: u64,
archived: bool,
},
RepoVisibilityChanged {
repo: Resource,
forge_id: u64,
visibility: Visibility,
},
CollaboratorChanged {
repo: Resource,
forge_id: u64,
account: ForgeAccount,
change: MemberChange,
},
OrgMembershipChanged {
namespace: Resource,
account: ForgeAccount,
change: MemberChange,
},
TeamMembershipChanged {
namespace: Resource,
team: String,
account: ForgeAccount,
change: MemberChange,
},
ProtectionChanged {
repo: Option<Resource>,
namespace: Resource,
action: String,
},
InstallationChanged {
namespace: Resource,
installation_id: u64,
change: InstallationChange,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[non_exhaustive]
pub enum ProtectionGap {
Missing,
NotEnforced,
DefaultBranchNotCovered,
PullRequestNotRequired,
CheckNotRequired {
check: String,
},
ForcePushAllowed,
DeletionAllowed,
BypassActors {
actors: Vec<String>,
},
CheckSourceUnprotected {
detail: String,
},
UnprotectedPaths {
paths: Vec<String>,
},
MergeMethodAllowed {
method: MergeMethod,
},
CiDisabled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[non_exhaustive]
pub enum Drift {
UnexpectedRole {
account: ForgeAccount,
observed: ForgeRole,
},
MissingRole {
account: ForgeAccount,
expected: ForgeRole,
},
RoleMismatch {
account: ForgeAccount,
expected: ForgeRole,
observed: ForgeRole,
},
ProtectionWeakened {
gaps: Vec<ProtectionGap>,
},
Renamed {
expected: Resource,
observed: Resource,
},
Replaced {
expected: u64,
observed: u64,
},
ArchiveMismatch {
expected: bool,
observed: bool,
},
VisibilityMismatch {
expected: Visibility,
observed: Visibility,
},
ReplanNeeded {
reason: String,
},
}
impl Drift {
pub fn is_critical(&self) -> bool {
matches!(
self,
Drift::ProtectionWeakened { .. } | Drift::Replaced { .. } | Drift::Renamed { .. }
)
}
}
pub fn protection_gaps(observed: &ProtectionState, check: &str) -> Vec<ProtectionGap> {
if !observed.present {
let mut gaps = vec![ProtectionGap::Missing];
gaps.extend(observed.other_gaps.iter().cloned());
return gaps;
}
let mut gaps = Vec::new();
if !observed.enforced {
gaps.push(ProtectionGap::NotEnforced);
}
if !observed.covers_default_branch {
gaps.push(ProtectionGap::DefaultBranchNotCovered);
}
if !observed.requires_pull_request {
gaps.push(ProtectionGap::PullRequestNotRequired);
}
if !observed.required_checks.iter().any(|c| c == check) {
gaps.push(ProtectionGap::CheckNotRequired {
check: check.to_string(),
});
}
if !observed.blocks_force_push {
gaps.push(ProtectionGap::ForcePushAllowed);
}
if !observed.blocks_deletion {
gaps.push(ProtectionGap::DeletionAllowed);
}
if !observed.bypass_actors.is_empty() {
gaps.push(ProtectionGap::BypassActors {
actors: observed.bypass_actors.clone(),
});
}
gaps.extend(observed.other_gaps.iter().cloned());
gaps
}
pub fn default_diff(observed: &RepoState, desired: &Projection) -> Vec<Drift> {
let mut drift = Vec::new();
if let Some(expected) = desired.forge_id
&& expected != observed.forge_id
{
return vec![Drift::Replaced {
expected,
observed: observed.forge_id,
}];
}
if observed.resource != desired.resource {
drift.push(Drift::Renamed {
expected: desired.resource.clone(),
observed: observed.resource.clone(),
});
}
if observed.archived != desired.archived {
drift.push(Drift::ArchiveMismatch {
expected: desired.archived,
observed: observed.archived,
});
}
if let Some(expected) = desired.visibility
&& expected != observed.visibility
{
drift.push(Drift::VisibilityMismatch {
expected,
observed: observed.visibility,
});
}
if let Some(check) = &desired.required_check {
let gaps = protection_gaps(&observed.protection, check);
if !gaps.is_empty() {
drift.push(Drift::ProtectionWeakened { gaps });
}
}
drift.extend(role_drift(observed, &desired.roles));
drift
}
fn role_drift(observed: &RepoState, desired: &[RoleAssignment]) -> Vec<Drift> {
let mut drift = Vec::new();
for want in desired {
let have = observed
.collaborators
.iter()
.find(|c| c.account.id == want.account.id);
match have {
None if want.role != ForgeRole::None => drift.push(Drift::MissingRole {
account: want.account.clone(),
expected: want.role,
}),
Some(c) if want.role == ForgeRole::None => drift.push(Drift::UnexpectedRole {
account: c.account.clone(),
observed: c.role,
}),
Some(c) if c.role != want.role => drift.push(Drift::RoleMismatch {
account: c.account.clone(),
expected: want.role,
observed: c.role,
}),
_ => {}
}
}
for c in &observed.collaborators {
if c.role != ForgeRole::None && !desired.iter().any(|d| d.account.id == c.account.id) {
drift.push(Drift::UnexpectedRole {
account: c.account.clone(),
observed: c.role,
});
}
}
drift
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Collaborator;
fn res(s: &str) -> Resource {
Resource::parse(s).unwrap()
}
fn protected(check: &str) -> ProtectionState {
ProtectionState {
present: true,
enforced: true,
covers_default_branch: true,
requires_pull_request: true,
required_checks: vec![check.into()],
blocks_force_push: true,
blocks_deletion: true,
bypass_actors: vec![],
..ProtectionState::default()
}
}
fn alice() -> ForgeAccount {
ForgeAccount::new(1, "alice")
}
fn bob() -> ForgeAccount {
ForgeAccount::new(2, "bob")
}
#[test]
fn a_matching_repo_has_no_drift() {
let mut state = RepoState::new(res("github.com/acme/w"), 9);
state.protection = protected("Verify commit trust");
state.collaborators = vec![
Collaborator::new(ForgeAccount::new(1, "alice-renamed"), ForgeRole::Admin),
Collaborator::invited(bob(), ForgeRole::Maintain),
];
let mut want = Projection::new(res("github.com/acme/w"));
want.forge_id = Some(9);
want.required_check = Some("Verify commit trust".into());
want.roles = vec![
RoleAssignment::new(alice(), ForgeRole::Admin),
RoleAssignment::new(bob(), ForgeRole::Maintain),
];
assert_eq!(default_diff(&state, &want), vec![]);
}
#[test]
fn role_drift_is_matched_by_id() {
let mut state = RepoState::new(res("github.com/acme/w"), 9);
state.collaborators = vec![
Collaborator::new(alice(), ForgeRole::Write),
Collaborator::new(ForgeAccount::new(3, "mallory"), ForgeRole::Admin),
];
let mut want = Projection::new(res("github.com/acme/w"));
want.roles = vec![
RoleAssignment::new(alice(), ForgeRole::Admin),
RoleAssignment::new(bob(), ForgeRole::Maintain),
];
let drift = default_diff(&state, &want);
assert_eq!(
drift,
vec![
Drift::RoleMismatch {
account: alice(),
expected: ForgeRole::Admin,
observed: ForgeRole::Write
},
Drift::MissingRole {
account: bob(),
expected: ForgeRole::Maintain
},
Drift::UnexpectedRole {
account: ForgeAccount::new(3, "mallory"),
observed: ForgeRole::Admin
},
]
);
assert!(!drift.iter().any(Drift::is_critical));
}
#[test]
fn weakened_protection_lists_every_gap() {
let mut state = RepoState::new(res("github.com/acme/w"), 9);
let mut p = protected("something else");
p.enforced = false;
p.blocks_force_push = false;
p.bypass_actors = vec!["OrganizationAdmin".into()];
state.protection = p;
let mut want = Projection::new(res("github.com/acme/w"));
want.required_check = Some("Verify commit trust".into());
let drift = default_diff(&state, &want);
assert_eq!(
drift,
vec![Drift::ProtectionWeakened {
gaps: vec![
ProtectionGap::NotEnforced,
ProtectionGap::CheckNotRequired {
check: "Verify commit trust".into()
},
ProtectionGap::ForcePushAllowed,
ProtectionGap::BypassActors {
actors: vec!["OrganizationAdmin".into()]
},
]
}]
);
assert!(drift[0].is_critical());
state.protection = ProtectionState::default();
assert_eq!(
default_diff(&state, &want),
vec![Drift::ProtectionWeakened {
gaps: vec![ProtectionGap::Missing]
}]
);
}
#[test]
fn gaps_in_what_guards_the_workflow_are_reported_too() {
let mut state = RepoState::new(res("github.com/acme/w"), 9);
let unprotected = ProtectionGap::CheckSourceUnprotected {
detail: "the org ruleset is missing".into(),
};
state.protection = protected("Verify commit trust");
state.protection.other_gaps = vec![unprotected.clone()];
let mut want = Projection::new(res("github.com/acme/w"));
want.required_check = Some("Verify commit trust".into());
let drift = default_diff(&state, &want);
assert_eq!(
drift,
vec![Drift::ProtectionWeakened {
gaps: vec![unprotected.clone()]
}]
);
assert!(drift[0].is_critical());
state.protection = ProtectionState {
other_gaps: vec![unprotected.clone()],
..ProtectionState::default()
};
assert_eq!(
protection_gaps(&state.protection, "Verify commit trust"),
vec![ProtectionGap::Missing, unprotected]
);
}
#[test]
fn rename_and_replacement_are_told_apart_by_forge_id() {
let state = RepoState::new(res("github.com/acme/new-name"), 9);
let mut want = Projection::new(res("github.com/acme/w"));
want.forge_id = Some(9);
assert_eq!(
default_diff(&state, &want),
vec![Drift::Renamed {
expected: res("github.com/acme/w"),
observed: res("github.com/acme/new-name")
}]
);
let imposter = RepoState::new(res("github.com/acme/w"), 10);
assert_eq!(
default_diff(&imposter, &want),
vec![Drift::Replaced {
expected: 9,
observed: 10
}]
);
}
}