use std::path::{Path, PathBuf};
use regex::Regex;
use serde::{Deserialize, Serialize};
use radicle::{
cob::patch::PatchId,
crypto::PublicKey,
git::{BranchName, Namespaced, Oid, RefString},
node::{Event, NodeId},
prelude::RepoId,
storage::RefUpdate,
};
use crate::{
msg::RunId,
refs::{GenericRefName, TagName, ref_string},
};
#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum CiEvent {
V1(CiEventV1),
}
#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum CiEventV1 {
Shutdown,
Terminate(RunId),
BranchCreated {
from_node: NodeId,
repo: RepoId,
branch: BranchName,
tip: Oid,
},
BranchUpdated {
from_node: NodeId,
repo: RepoId,
branch: BranchName,
tip: Oid,
old_tip: Oid,
},
BranchDeleted {
from_node: NodeId,
repo: RepoId,
branch: BranchName,
tip: Oid,
},
TagCreated {
from_node: NodeId,
repo: RepoId,
tag: TagName,
tip: Oid,
},
TagUpdated {
from_node: NodeId,
repo: RepoId,
tag: TagName,
tip: Oid,
old_tip: Oid,
},
TagDeleted {
from_node: NodeId,
repo: RepoId,
tag: TagName,
tip: Oid,
},
PatchCreated {
from_node: NodeId,
repo: RepoId,
patch: PatchId,
new_tip: Oid,
},
PatchUpdated {
from_node: NodeId,
repo: RepoId,
patch: PatchId,
new_tip: Oid,
},
CanonicalRefUpdated {
from_node: NodeId,
repo: RepoId,
refname: GenericRefName,
target: Oid,
},
}
impl CiEvent {
pub fn from_node(&self) -> Option<&NodeId> {
match self {
Self::V1(CiEventV1::Shutdown) => None,
Self::V1(CiEventV1::Terminate(_)) => None,
Self::V1(CiEventV1::BranchCreated { from_node, .. }) => Some(from_node),
Self::V1(CiEventV1::BranchUpdated { from_node, .. }) => Some(from_node),
Self::V1(CiEventV1::BranchDeleted { from_node, .. }) => Some(from_node),
Self::V1(CiEventV1::TagCreated { from_node, .. }) => Some(from_node),
Self::V1(CiEventV1::TagUpdated { from_node, .. }) => Some(from_node),
Self::V1(CiEventV1::TagDeleted { from_node, .. }) => Some(from_node),
Self::V1(CiEventV1::PatchCreated { from_node, .. }) => Some(from_node),
Self::V1(CiEventV1::PatchUpdated { from_node, .. }) => Some(from_node),
Self::V1(CiEventV1::CanonicalRefUpdated { from_node, .. }) => Some(from_node),
}
}
pub fn repository(&self) -> Option<&RepoId> {
match self {
Self::V1(CiEventV1::Shutdown) => None,
Self::V1(CiEventV1::Terminate(_)) => None,
Self::V1(CiEventV1::BranchCreated { repo, .. }) => Some(repo),
Self::V1(CiEventV1::BranchUpdated { repo, .. }) => Some(repo),
Self::V1(CiEventV1::BranchDeleted { repo, .. }) => Some(repo),
Self::V1(CiEventV1::TagCreated { repo, .. }) => Some(repo),
Self::V1(CiEventV1::TagUpdated { repo, .. }) => Some(repo),
Self::V1(CiEventV1::TagDeleted { repo, .. }) => Some(repo),
Self::V1(CiEventV1::PatchCreated { repo, .. }) => Some(repo),
Self::V1(CiEventV1::PatchUpdated { repo, .. }) => Some(repo),
Self::V1(CiEventV1::CanonicalRefUpdated { repo, .. }) => Some(repo),
}
}
pub fn branch(&self) -> Option<&BranchName> {
match self {
Self::V1(CiEventV1::Shutdown) => None,
Self::V1(CiEventV1::BranchCreated { branch, .. }) => Some(branch),
Self::V1(CiEventV1::BranchUpdated { branch, .. }) => Some(branch),
Self::V1(CiEventV1::BranchDeleted { branch, .. }) => Some(branch),
_ => None,
}
}
pub fn tag(&self) -> Option<&TagName> {
match self {
Self::V1(CiEventV1::Shutdown) => None,
Self::V1(CiEventV1::TagCreated { tag, .. }) => Some(tag),
Self::V1(CiEventV1::TagUpdated { tag, .. }) => Some(tag),
Self::V1(CiEventV1::TagDeleted { tag, .. }) => Some(tag),
_ => None,
}
}
pub fn patch_id(&self) -> Option<&PatchId> {
match self {
Self::V1(CiEventV1::PatchCreated { patch, .. }) => Some(patch),
Self::V1(CiEventV1::PatchUpdated { patch, .. }) => Some(patch),
_ => None,
}
}
pub fn tip(&self) -> Option<&Oid> {
match self {
Self::V1(CiEventV1::Shutdown) => None,
Self::V1(CiEventV1::Terminate(_)) => None,
Self::V1(CiEventV1::BranchCreated { tip, .. }) => Some(tip),
Self::V1(CiEventV1::BranchUpdated { tip, .. }) => Some(tip),
Self::V1(CiEventV1::BranchDeleted { tip, .. }) => Some(tip),
Self::V1(CiEventV1::TagCreated { tip, .. }) => Some(tip),
Self::V1(CiEventV1::TagUpdated { tip, .. }) => Some(tip),
Self::V1(CiEventV1::TagDeleted { tip, .. }) => Some(tip),
Self::V1(CiEventV1::PatchCreated { new_tip, .. }) => Some(new_tip),
Self::V1(CiEventV1::PatchUpdated { new_tip, .. }) => Some(new_tip),
Self::V1(CiEventV1::CanonicalRefUpdated { target, .. }) => Some(target),
}
}
pub fn branch_created(
from_node: NodeId,
repo: RepoId,
branch: &BranchName,
tip: Oid,
) -> Result<Self, CiEventError> {
assert!(!branch.starts_with("refs/"));
Ok(Self::V1(CiEventV1::BranchCreated {
from_node,
repo,
branch: branch.clone(),
tip,
}))
}
pub fn branch_updated(
from_node: NodeId,
repo: RepoId,
branch: &BranchName,
tip: Oid,
old_tip: Oid,
) -> Result<Self, CiEventError> {
assert!(!branch.starts_with("refs/"));
Ok(Self::V1(CiEventV1::BranchUpdated {
from_node,
repo,
branch: branch.clone(),
tip,
old_tip,
}))
}
pub fn branch_deleted(
from_node: NodeId,
repo: RepoId,
branch: &BranchName,
tip: Oid,
) -> Result<Self, CiEventError> {
assert!(!branch.starts_with("refs/"));
Ok(Self::V1(CiEventV1::BranchDeleted {
from_node,
repo,
branch: branch.clone(),
tip,
}))
}
pub fn tag_created(
from_node: NodeId,
repo: RepoId,
tag: &TagName,
tip: Oid,
) -> Result<Self, CiEventError> {
assert!(!tag.starts_with("refs/"));
Ok(Self::V1(CiEventV1::TagCreated {
from_node,
repo,
tag: tag.clone(),
tip,
}))
}
pub fn tag_updated(
from_node: NodeId,
repo: RepoId,
tag: &TagName,
tip: Oid,
old_tip: Oid,
) -> Result<Self, CiEventError> {
assert!(!tag.starts_with("refs/"));
Ok(Self::V1(CiEventV1::TagUpdated {
from_node,
repo,
tag: tag.clone(),
tip,
old_tip,
}))
}
pub fn tag_deleted(
from_node: NodeId,
repo: RepoId,
tag: &TagName,
tip: Oid,
) -> Result<Self, CiEventError> {
assert!(!tag.starts_with("refs/"));
Ok(Self::V1(CiEventV1::TagDeleted {
from_node,
repo,
tag: tag.clone(),
tip,
}))
}
pub fn patch_created(from_node: NodeId, repo: RepoId, patch: PatchId, tip: Oid) -> Self {
Self::V1(CiEventV1::PatchCreated {
from_node,
repo,
patch,
new_tip: tip,
})
}
pub fn patch_updated(from_node: NodeId, repo: RepoId, patch: PatchId, new_tip: Oid) -> Self {
Self::V1(CiEventV1::PatchUpdated {
from_node,
repo,
patch,
new_tip,
})
}
#[allow(clippy::unwrap_used)]
pub fn from_node_event(event: &Event) -> Result<Vec<Self>, CiEventError> {
match event {
Event::RefsFetched {
remote: _,
rid,
updated,
} => {
let mut events = vec![];
for update in updated {
let e = match update {
RefUpdate::Created { name, oid } => {
let origin = originator(name.to_namespaced().unwrap())?;
match ParsedRef::parse_ref(name) {
Some(ParsedRef::Branch(branch)) => {
Self::branch_created(origin, *rid, &branch, *oid)?
}
Some(ParsedRef::Patch(patch_id)) => {
Self::patch_created(origin, *rid, patch_id, *oid)
}
Some(ParsedRef::Tag(tag_name)) => {
Self::tag_created(origin, *rid, &tag_name, *oid)?
}
None => continue,
}
}
RefUpdate::Updated { name, old, new } => {
let origin = originator(name.to_namespaced().unwrap())?;
match ParsedRef::parse_ref(name) {
Some(ParsedRef::Branch(branch)) => {
Self::branch_updated(origin, *rid, &branch, *new, *old)?
}
Some(ParsedRef::Patch(patch_id)) => {
Self::patch_updated(origin, *rid, patch_id, *new)
}
Some(ParsedRef::Tag(tag_name)) => {
Self::tag_updated(origin, *rid, &tag_name, *new, *old)?
}
None => continue,
}
}
RefUpdate::Deleted { name, oid } => {
let origin = originator(name.to_namespaced().unwrap())?;
match ParsedRef::parse_ref(name) {
Some(ParsedRef::Branch(branch)) => {
Self::branch_deleted(origin, *rid, &branch, *oid)?
}
Some(ParsedRef::Patch(_patch_id)) => continue,
Some(ParsedRef::Tag(tag_name)) => {
Self::tag_deleted(origin, *rid, &tag_name, *oid)?
}
None => continue,
}
}
RefUpdate::Skipped { .. } => continue,
};
events.push(e);
}
Ok(events)
}
Event::RefsSynced { .. }
| Event::RefsAnnounced { .. }
| Event::NodeAnnounced { .. }
| Event::SeedDiscovered { .. }
| Event::SeedDropped { .. }
| Event::PeerConnected { .. }
| Event::PeerDisconnected { .. }
| Event::LocalRefsAnnounced { .. }
| Event::UploadPack { .. }
| Event::InventoryAnnounced { .. } => Ok(vec![]),
}
}
pub fn to_pretty_json(&self) -> Result<String, CiEventError> {
serde_json::to_string_pretty(self).map_err(CiEventError::ToJson)
}
}
fn originator(name: Namespaced) -> Result<PublicKey, CiEventError> {
PublicKey::from_namespaced(&name).map_err(|err| CiEventError::key_from_namespaced(&name, err))
}
pub struct CiEvents {
events: Vec<CiEvent>,
}
impl CiEvents {
pub fn from_file(filename: &Path) -> Result<Self, CiEventError> {
let events = std::fs::read(filename).map_err(|e| CiEventError::read_file(filename, e))?;
let events = String::from_utf8(events).map_err(|e| CiEventError::not_utf8(filename, e))?;
let events: Result<Vec<CiEvent>, _> = events.lines().map(serde_json::from_str).collect();
let events = events.map_err(|e| CiEventError::not_json(filename, e))?;
Ok(Self { events })
}
pub fn iter(&self) -> impl Iterator<Item = &CiEvent> {
self.events.iter()
}
}
#[derive(Debug, thiserror::Error)]
pub enum CiEventError {
#[error("updated ref name has no name space: {0:?})")]
WithoutNamespace2(String),
#[error("failed to create a branch name from {0:?}")]
BranchName(String, crate::refs::RefError),
#[error("failed to read broker events file {0}")]
ReadFile(PathBuf, #[source] std::io::Error),
#[error("broker events file is not UTF8: {0}")]
NotUtf8(PathBuf, #[source] std::string::FromUtf8Error),
#[error("broker events file is not valid JSON: {0}")]
NotJson(PathBuf, #[source] serde_json::Error),
#[error("failed to convert name spaced Git ref into node public key: {0}")]
KeyFromNamespaced(RefString, #[source] radicle_crypto::PublicKeyError),
#[error("failed to encode CI event as JSON")]
ToJson(#[source] serde_json::Error),
}
impl CiEventError {
fn read_file(filename: &Path, err: std::io::Error) -> Self {
Self::ReadFile(filename.into(), err)
}
fn not_utf8(filename: &Path, err: std::string::FromUtf8Error) -> Self {
Self::NotUtf8(filename.into(), err)
}
fn not_json(filename: &Path, err: serde_json::Error) -> Self {
Self::NotJson(filename.into(), err)
}
fn key_from_namespaced(name: &Namespaced, err: radicle_crypto::PublicKeyError) -> Self {
Self::KeyFromNamespaced(name.to_ref_string(), err)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use super::*;
use radicle::{prelude::NodeId, storage::RefUpdate};
use std::str::FromStr;
use crate::refs::{branch_from_namespaced, ref_string};
const MAIN_BRANCH_REF_NAME: &str =
"refs/namespaces/z6MkiB8T5cBEQHnrs2MgjMVqvpSVj42X81HjKfFi2XBoMbtr/refs/heads/main";
const PATCH_REF_NAME: &str = "refs/namespaces/z6MkiB8T5cBEQHnrs2MgjMVqvpSVj42X81HjKfFi2XBoMbtr/refs/heads/patches/f9fa90725474de9002be503ae3cda4670c9a174";
const PATCH_ID: &str = "f9fa90725474de9002be503ae3cda4670c9a174";
fn nid() -> NodeId {
const NID: &str = "z6MkgEMYod7Hxfy9qCvDv5hYHkZ4ciWmLFgfvm3Wn1b2w2FV";
NodeId::from_str(NID).unwrap()
}
fn rid() -> RepoId {
const RID: &str = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5";
RepoId::from_urn(RID).unwrap()
}
fn oid_from(oid: &str) -> Oid {
Oid::try_from(oid).unwrap()
}
fn oid() -> Oid {
const OID: &str = "ff3099ba5de28d954c41d0b5a84316f943794ea4";
oid_from(OID)
}
fn namespaced_main<'a>() -> Namespaced<'a> {
ref_string(MAIN_BRANCH_REF_NAME)
.unwrap()
.to_namespaced()
.unwrap()
.to_owned()
}
fn plain_main() -> BranchName {
branch_from_namespaced(&namespaced_main()).unwrap()
}
#[test]
fn nothing_updated() {
let event = Event::RefsFetched {
remote: nid(),
rid: rid(),
updated: vec![],
};
let result = CiEvent::from_node_event(&event);
assert!(result.is_ok());
assert_eq!(result.unwrap(), vec![]);
}
#[test]
fn skipped() {
let event = Event::RefsFetched {
remote: nid(),
rid: rid(),
updated: vec![RefUpdate::Skipped {
name: ref_string(MAIN_BRANCH_REF_NAME).unwrap(),
oid: oid(),
}],
};
let result = CiEvent::from_node_event(&event);
assert!(result.is_ok());
assert_eq!(result.unwrap(), vec![]);
}
#[test]
fn branch_created() {
let rid = rid();
let oid = oid();
let event = Event::RefsFetched {
remote: nid(),
rid,
updated: vec![RefUpdate::Created {
name: namespaced_main().to_ref_string(),
oid,
}],
};
let x = CiEvent::from_node_event(&event);
eprintln!("result: {x:#?}");
match x {
Err(_) => panic!("should succeed"),
Ok(events) if !events.is_empty() => {
for e in events {
match e {
CiEvent::V1(CiEventV1::BranchCreated {
from_node: _,
repo,
branch,
tip,
}) if repo == rid && branch == plain_main() && tip == oid => {}
_ => panic!("should not succeed that way"),
}
}
}
Ok(_) => panic!("empty list of events should not happen"),
}
}
#[test]
fn branch_updated() {
let rid = rid();
let oid = oid();
let event = Event::RefsFetched {
remote: nid(),
rid,
updated: vec![RefUpdate::Updated {
name: namespaced_main().to_ref_string(),
old: oid,
new: oid,
}],
};
let x = CiEvent::from_node_event(&event);
eprintln!("result: {x:#?}");
match x {
Err(_) => panic!("should succeed"),
Ok(events) if !events.is_empty() => {
for e in events {
match e {
CiEvent::V1(CiEventV1::BranchUpdated {
from_node: _,
repo,
branch,
tip,
old_tip,
}) if repo == rid
&& branch == plain_main()
&& tip == oid
&& old_tip == oid => {}
_ => panic!("should not succeed that way"),
}
}
}
Ok(_) => panic!("empty list of events should not happen"),
}
}
#[test]
fn branch_deleted() {
let rid = rid();
let oid = oid();
let event = Event::RefsFetched {
remote: nid(),
rid,
updated: vec![RefUpdate::Deleted {
name: namespaced_main().to_ref_string(),
oid,
}],
};
let x = CiEvent::from_node_event(&event);
eprintln!("result: {x:#?}");
match x {
Err(_) => panic!("should succeed"),
Ok(events) if !events.is_empty() => {
for e in events {
match e {
CiEvent::V1(CiEventV1::BranchDeleted {
repo, branch, tip, ..
}) if repo == rid && branch == plain_main() && tip == oid => {}
_ => panic!("should not succeed that way"),
}
}
}
Ok(_) => panic!("empty list of events should not happen"),
}
}
#[test]
fn patch_created() {
let rid = rid();
let patch_id = oid_from(PATCH_ID).into();
let oid = oid();
let event = Event::RefsFetched {
remote: nid(),
rid,
updated: vec![RefUpdate::Created {
name: ref_string(PATCH_REF_NAME).unwrap(),
oid,
}],
};
let x = CiEvent::from_node_event(&event);
eprintln!("result: {x:#?}");
match x {
Err(_) => panic!("should succeed"),
Ok(events) if !events.is_empty() => {
for e in events {
match e {
CiEvent::V1(CiEventV1::PatchCreated {
from_node: _,
repo,
patch,
new_tip,
}) if repo == rid && patch == patch_id && new_tip == oid => {}
_ => panic!("should not succeed that way"),
}
}
}
Ok(_) => panic!("empty list of events should not happen"),
}
}
#[test]
fn patch_updated() {
let rid = rid();
let patch_id = oid_from(PATCH_ID).into();
let oid = oid();
let event = Event::RefsFetched {
remote: nid(),
rid,
updated: vec![RefUpdate::Updated {
name: ref_string(PATCH_REF_NAME).unwrap(),
old: oid,
new: oid,
}],
};
let x = CiEvent::from_node_event(&event);
eprintln!("result: {x:#?}");
match x {
Err(_) => panic!("should succeed"),
Ok(events) if !events.is_empty() => {
for e in events {
match e {
CiEvent::V1(CiEventV1::PatchUpdated {
from_node: _,
repo,
patch,
new_tip,
}) if repo == rid && patch == patch_id && new_tip == oid => {}
_ => panic!("should not succeed that way"),
}
}
}
Ok(_) => panic!("empty list of events should not happen"),
}
}
}
#[derive(Debug, Eq, PartialEq)]
#[allow(dead_code)]
enum ParsedRef {
Branch(BranchName),
Patch(PatchId),
Tag(TagName),
}
impl ParsedRef {
#[allow(clippy::unwrap_used)]
fn parse_ref(refname: &RefString) -> Option<Self> {
use crate::refs::branch_from_str;
fn parse_patch_id(refname: &RefString) -> Option<ParsedRef> {
const PATTERN: &str = r"^refs/namespaces/[^/]+/refs/heads/patches/([^/]+)$";
let re = Regex::new(PATTERN).unwrap();
if let Some(captures) = re.captures(refname) {
if let Some(patch_id) = captures.get(1) {
if let Ok(oid) = Oid::try_from(patch_id.as_str()) {
let patch_id = PatchId::from(oid);
return Some(ParsedRef::Patch(patch_id));
}
}
}
None
}
fn parse_branch_name(refname: &RefString) -> Option<ParsedRef> {
const PATTERN: &str = r"^refs/namespaces/[^/]+/refs/heads/(.+)$";
let re = Regex::new(PATTERN).unwrap();
if let Some(captures) = re.captures(refname) {
if let Some(branch_name) = captures.get(1) {
if let Ok(branch_name) = branch_from_str(branch_name.as_str()) {
return Some(ParsedRef::Branch(branch_name));
}
}
}
None
}
fn parse_tag_name(refname: &RefString) -> Option<ParsedRef> {
const PATTERN: &str = r"^refs/namespaces/[^/]+/refs/tags/(.+)$";
let re = Regex::new(PATTERN).unwrap();
if let Some(captures) = re.captures(refname) {
if let Some(tag_name) = captures.get(1) {
if let Ok(tag_name) = ref_string(tag_name.as_str()) {
return Some(ParsedRef::Tag(tag_name.into()));
}
}
}
None
}
parse_patch_id(refname)
.or_else(|| parse_branch_name(refname))
.or_else(|| parse_tag_name(refname))
.or(None)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test_parsed_ref {
use std::str::FromStr;
use crate::refs::{branch_ref, ref_string};
use super::*;
#[test]
fn branch() {
let actual = ref_string("refs/namespaces/NID/refs/heads/main").unwrap();
let wanted = branch_ref(&ref_string("main").unwrap()).unwrap();
assert_eq!(
ParsedRef::parse_ref(&actual),
Some(ParsedRef::Branch(wanted))
);
}
#[test]
fn patch() {
let actual = ref_string(
"refs/namespaces/NID/refs/heads/patches/9d1a97571e86caafa86df7bc1692d305710a596e",
)
.unwrap();
let wanted = PatchId::from_str("9d1a97571e86caafa86df7bc1692d305710a596e").unwrap();
assert_eq!(
ParsedRef::parse_ref(&actual),
Some(ParsedRef::Patch(wanted))
);
}
#[test]
fn tag() {
let actual = ref_string("refs/namespaces/NID/refs/tags/v0.0.0").unwrap();
let wanted = ref_string("v0.0.0").unwrap();
assert_eq!(
ParsedRef::parse_ref(&actual),
Some(ParsedRef::Tag(wanted.into()))
);
}
}