use serde_json::{Map, Value, json};
use vgi_forge::{CheckSourceGuard, Drift, ProtectionGap, RepoState};
use crate::bridge::Bridge;
use crate::store::{NamespaceRecord, PendingFlow, RepoRecord, Table};
pub const EXT_KEY: &str = "org.openvtc.git-ns";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Guard {
RequiredWorkflow,
CodeOwnerReview,
BridgePostedCheck,
ProtectedFiles,
None,
}
impl Guard {
pub fn as_str(self) -> &'static str {
match self {
Guard::RequiredWorkflow => "requiredWorkflow",
Guard::CodeOwnerReview => "codeOwnerReview",
Guard::BridgePostedCheck => "bridgePostedCheck",
Guard::ProtectedFiles => "protectedFiles",
Guard::None => "none",
}
}
pub fn in_force(state: &RepoState, drift: &[Drift]) -> Guard {
let weakened = drift.iter().any(|d| match d {
Drift::ProtectionWeakened { gaps } => gaps.iter().any(|g| {
matches!(
g,
ProtectionGap::CheckSourceUnprotected { .. }
| ProtectionGap::UnprotectedPaths { .. }
)
}),
_ => false,
});
let p = &state.protection;
let observed = match &p.check_source_guard {
CheckSourceGuard::BridgePosted => Guard::BridgePostedCheck,
CheckSourceGuard::RequiredWorkflow => Guard::RequiredWorkflow,
CheckSourceGuard::OwnerReview { .. } => Guard::CodeOwnerReview,
CheckSourceGuard::Unreviewed => Guard::None,
_ if p.present && !p.protected_paths.is_empty() => Guard::ProtectedFiles,
_ => Guard::None,
};
if weakened && observed != Guard::BridgePostedCheck {
Guard::None
} else {
observed
}
}
}
pub(crate) fn namespace_report(bridge: &Bridge, ns: &NamespaceRecord) -> Map<String, Value> {
let mut out = Map::new();
let host = ns.resource.host();
let adapter = bridge.adapters.for_resource(&ns.resource);
let github = bridge.cfg.github_for(host, ns.resource.owner());
if let Some(g) = github {
out.insert("appName".into(), json!(g.app_name));
#[cfg(feature = "forge-github")]
if let Some(slug) = adapter
.as_ref()
.and_then(|a| a.github())
.map(|f| f.config().app_slug.clone())
{
out.insert("appSlug".into(), json!(slug));
}
let registration = if adapter.is_some() {
"registered"
} else if manifest_pending(bridge, host, &g.app_owner) {
"pending"
} else {
"unregistered"
};
out.insert("appRegistration".into(), json!(registration));
}
let Some(binding) = ns.binding.as_ref() else {
return out;
};
if let Some(id) = binding.namespace.installation_id {
out.insert("installationId".into(), json!(id.to_string()));
}
if github.is_some() && binding.namespace.installation_id.is_some() {
if !binding.missing_permissions.is_empty() {
out.insert(
"missingPermissions".into(),
json!(binding.missing_permissions),
);
}
let checks_wanted = github.is_some_and(|g| g.bridge_checks);
let pending = if !binding.missing_permissions.is_empty() {
Some(true)
} else if checks_wanted {
ns.bridge_checks.map(|ready| !ready)
} else {
Some(false)
};
if let Some(p) = pending {
out.insert("permissionUpgradePending".into(), json!(p));
}
if let Some(available) = ns.required_workflow {
out.insert("orgRulesets".into(), json!(available));
}
}
if let Some(a) = &adapter {
let caps = a.forge().capabilities(&binding.namespace);
if caps.automation {
out.insert("requiredWorkflow".into(), json!(caps.required_workflow));
out.insert("bridgePostedCheck".into(), json!(caps.bridge_posted_check));
}
}
out
}
fn manifest_pending(bridge: &Bridge, host: &str, owner: &str) -> bool {
let now = crate::bridge::now();
bridge
.store
.list::<PendingFlow>(Table::Pending)
.unwrap_or_default()
.into_iter()
.any(|(_, f)| {
matches!(f, PendingFlow::Manifest { host: h, owner: o, expires_at }
if h == host && expires_at > now
&& o.as_deref().is_none_or(|o| o.eq_ignore_ascii_case(owner)))
})
}
pub(crate) fn repo_report(rec: &RepoRecord) -> Map<String, Value> {
let mut out = Map::new();
if let Some(g) = &rec.guard {
out.insert("guard".into(), json!(g));
}
if let Some(c) = &rec.last_check {
out.insert(
"lastCheck".into(),
json!({
"sha": c.sha,
"conclusion": c.conclusion,
"at": crate::flows::rfc3339(c.at),
}),
);
}
out
}
pub(crate) fn ext(bridge: &Bridge, ns_id: &str, repo: Option<(&str, u64)>) -> Option<Value> {
let mut report = Map::new();
if let Ok(Some(ns)) = bridge
.store
.get::<NamespaceRecord>(Table::Namespaces, ns_id)
{
let n = namespace_report(bridge, &ns);
if !n.is_empty() {
report.insert("namespace".into(), Value::Object(n));
}
}
if let Some((host, id)) = repo
&& let Ok(Some(rec)) = bridge
.store
.get::<RepoRecord>(Table::Repos, &crate::store::repo_key(host, id))
{
let r = repo_report(&rec);
if !r.is_empty() {
report.insert("repo".into(), Value::Object(r));
}
}
(!report.is_empty()).then(|| json!({ EXT_KEY: report }))
}
pub(crate) fn attach<P: serde::de::DeserializeOwned>(payload: Value, ext: Option<Value>) -> Value {
let Some(ext) = ext else {
return payload;
};
let mut with = payload.clone();
with["ext"] = ext;
match serde_json::from_value::<P>(with.clone()) {
Ok(_) => with,
Err(e) => {
tracing::error!(error = %e, "the status report does not fit the payload; sent without it");
payload
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use vgi_forge::{ForgeAccount, ProtectionState, Resource};
fn state(guard: CheckSourceGuard, protected: &[&str]) -> RepoState {
let mut p = ProtectionState::default();
p.present = true;
p.check_source_guard = guard;
p.protected_paths = protected.iter().map(|s| s.to_string()).collect();
let mut s = RepoState::new(Resource::parse("github.com/acme/widgets").unwrap(), 7);
s.protection = p;
s
}
#[test]
fn the_guard_in_force_is_named_as_the_vtc_names_it() {
let weakened = [Drift::ProtectionWeakened {
gaps: vec![ProtectionGap::CheckSourceUnprotected {
detail: "CODEOWNERS gone".into(),
}],
}];
let review = CheckSourceGuard::OwnerReview {
reviewers: vec![ForgeAccount::new(1, "bob")],
issues: vec![],
};
let cases = [
(
state(CheckSourceGuard::RequiredWorkflow, &[]),
&[][..],
"requiredWorkflow",
),
(state(review.clone(), &[]), &[][..], "codeOwnerReview"),
(state(review, &[]), &weakened[..], "none"),
(
state(CheckSourceGuard::BridgePosted, &[]),
&[][..],
"bridgePostedCheck",
),
(state(CheckSourceGuard::Unreviewed, &[]), &[][..], "none"),
(
state(CheckSourceGuard::Unknown, &[".forgejo/workflows/**"]),
&[][..],
"protectedFiles",
),
(state(CheckSourceGuard::Unknown, &[]), &[][..], "none"),
];
for (s, drift, want) in cases {
assert_eq!(Guard::in_force(&s, drift).as_str(), want, "{s:?}");
}
}
}