use std::collections::BTreeSet;
use std::sync::atomic::{AtomicBool, Ordering};
use serde_json::{Value, json};
use vgi_forge::{EffectiveRights, Forge, ForgeRole, Namespace, Resource, Right, RoleMap};
use crate::bridge::Bridge;
use crate::jobs::Ctx;
use crate::store::{NamespaceRecord, NamespaceState, RepoRecord, Table};
pub const OUTBOX_PREFIX: &str = "event:roleMap:";
pub fn outbox_key(ns_id: &str) -> String {
format!("{OUTBOX_PREFIX}{ns_id}")
}
pub fn effective_for(forge: &dyn Forge, ns: &Namespace, map: &RoleMap) -> Option<RoleMap> {
let role = |r: Right| forge.map_role(ns, EffectiveRights::from_granted([r]), map);
match RoleMap::new(
role(Right::RepoOwn),
role(Right::RepoMaintain),
role(Right::CommitSign),
) {
Ok(m) => Some(m),
Err(e) => {
tracing::error!(
namespace = %ns.resource,
error = %e,
"the forge adapter rounds the configured role map to an unordered one; \
the role map is not reported"
);
None
}
}
}
pub(crate) fn effective(ctx: &Ctx, map: &RoleMap) -> Option<RoleMap> {
effective_for(ctx.adapter.forge(), &ctx.namespace, map)
}
pub fn ladder_for(forge: &dyn Forge, ns: &Namespace) -> Vec<ForgeRole> {
let mut levels: Vec<ForgeRole> = forge
.capabilities(ns)
.role_levels
.into_iter()
.filter(|l| *l != ForgeRole::None)
.collect();
levels.sort();
levels.dedup();
levels
}
pub(crate) fn event(bridge: &Bridge, ns: &NamespaceRecord) -> Option<Value> {
if ns.state != NamespaceState::Bound {
return None;
}
let namespace = &ns.binding.as_ref()?.namespace;
let adapter = bridge.adapters.for_resource(&ns.resource)?;
let forge = adapter.forge();
let at = |r: &Resource| effective_for(forge, namespace, &bridge.cfg.role_map(r));
let base = at(&ns.resource)?;
let mut repos: Vec<Value> = Vec::new();
for r in bridge.cfg.role_map_repos(&ns.resource) {
let m = at(&r)?;
if m != base {
repos.push(json!({ "resource": r.to_string(), "roleMap": m }));
}
}
let default = effective_for(forge, namespace, &RoleMap::default())?;
let stale: BTreeSet<String> = bridge
.store
.list::<RepoRecord>(Table::Repos)
.unwrap_or_default()
.into_iter()
.map(|(_, rec)| rec)
.filter(|rec| rec.namespace == ns.id && rec.roles_known && !rec.archived)
.filter(|rec| ns.resource.contains(&rec.resource))
.filter(|rec| Some(rec.role_map.unwrap_or(default)) != at(&rec.resource))
.map(|rec| rec.resource.to_string())
.collect();
let mut ev = json!({
"type": "roleMapReported",
"roleMap": base,
"ladder": ladder_for(forge, namespace),
});
if !repos.is_empty() {
ev["repos"] = Value::Array(repos);
}
if !stale.is_empty() {
ev["stale"] = json!(stale);
}
Some(ev)
}
static SAID_NO_REPORT: AtomicBool = AtomicBool::new(false);
impl Bridge {
pub async fn report_role_map(&self, ns_id: &str) {
if !self.cfg.event_version.reports_role_map() {
let _ = self.store.delete(Table::Outbox, &outbox_key(ns_id));
if !SAID_NO_REPORT.swap(true, Ordering::Relaxed) {
tracing::info!(
"event_version is below 0.3: the VTC is not told this bridge's role map, \
and assumes the default"
);
}
return;
}
let Ok(Some(ns)) = self.store.get::<NamespaceRecord>(Table::Namespaces, ns_id) else {
return;
};
let Some(ev) = event(self, &ns) else {
return;
};
if let Err(e) = self
.send_event_keyed(ns_id, ev, None, Some(outbox_key(ns_id)))
.await
{
tracing::error!(namespace = ns_id, error = %e, "could not report the role map");
}
}
pub async fn started_serving(&self, ns_id: &str) {
self.report_role_map(ns_id).await;
}
pub async fn link_recovered(&self) {
self.link_recovered.notified().await;
}
pub async fn report_role_maps(&self) {
let Ok(all) = self.store.list::<NamespaceRecord>(Table::Namespaces) else {
return;
};
for (_, ns) in all {
if ns.state == NamespaceState::Bound {
self.report_role_map(&ns.id).await;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use vgi_forge::{
ApplyReport, BindCallback, BindRequest, BindStep, BootstrapStep, Capabilities,
ForgeAccount, ForgeEvent, ForgeKind, LinkCallback, LinkStep, NamespaceBinding,
NamespaceKind, RepoSpec, RepoState, RoleAssignment, StepOutcome, Unlisted, VgiConfig,
async_trait,
};
struct Defective;
#[async_trait]
impl Forge for Defective {
fn kind(&self) -> ForgeKind {
ForgeKind::Forgejo
}
fn host(&self) -> &str {
"codeberg.org"
}
fn capabilities(&self, _ns: &Namespace) -> Capabilities {
let mut c = Capabilities::default();
c.role_levels = vec![ForgeRole::Read, ForgeRole::Write, ForgeRole::Admin];
c
}
fn map_role(&self, _ns: &Namespace, rights: EffectiveRights, _map: &RoleMap) -> ForgeRole {
if rights.holds(Right::RepoMaintain) {
ForgeRole::Read
} else {
ForgeRole::Admin
}
}
async fn begin_bind(&self, _req: BindRequest) -> vgi_forge::Result<BindStep> {
unimplemented!()
}
async fn complete_bind(&self, _cb: BindCallback) -> vgi_forge::Result<NamespaceBinding> {
unimplemented!()
}
async fn begin_account_link(&self, _member: &str) -> vgi_forge::Result<LinkStep> {
unimplemented!()
}
async fn complete_account_link(
&self,
_cb: LinkCallback,
) -> vgi_forge::Result<ForgeAccount> {
unimplemented!()
}
async fn inspect(&self, _repo: &Resource) -> vgi_forge::Result<RepoState> {
unimplemented!()
}
async fn create_repo(&self, _spec: &RepoSpec) -> vgi_forge::Result<RepoState> {
unimplemented!()
}
async fn archive_repo(&self, _repo: &Resource) -> vgi_forge::Result<()> {
unimplemented!()
}
async fn apply_roles(
&self,
_repo: &Resource,
_desired: &[RoleAssignment],
_unlisted: Unlisted,
) -> vgi_forge::Result<ApplyReport> {
unimplemented!()
}
fn bootstrap_plan(
&self,
_repo: &RepoSpec,
_cfg: &VgiConfig,
) -> vgi_forge::Result<Vec<BootstrapStep>> {
unimplemented!()
}
async fn run_step(
&self,
_repo: &Resource,
_step: &BootstrapStep,
) -> vgi_forge::Result<StepOutcome> {
unimplemented!()
}
fn parse_event(
&self,
_headers: &http::HeaderMap,
_body: &[u8],
) -> vgi_forge::Result<Option<ForgeEvent>> {
Ok(None)
}
}
fn ns() -> Namespace {
Namespace::new(
Resource::parse("codeberg.org/acme").unwrap(),
NamespaceKind::Organization,
)
}
#[test]
fn a_map_that_rounds_unordered_is_not_reported() {
assert_eq!(effective_for(&Defective, &ns(), &RoleMap::default()), None);
}
#[test]
fn the_ladder_is_the_forges_levels_lowest_first_without_none() {
assert_eq!(
ladder_for(&Defective, &ns()),
vec![ForgeRole::Read, ForgeRole::Write, ForgeRole::Admin]
);
}
}