Radish alpha
r
rad:zwTxygwuz5LDGBq255RA2CbNGrz8
Radicle CI broker
Radicle
Git
radicle-ci-broker src ci_event.rs
use std::{
    collections::BTreeMap,
    path::{Path, PathBuf},
    str::FromStr,
};

use regex::Regex;
use serde::{Deserialize, Serialize};

use git_ref_format_core::{Namespaced, Qualified, RefString};
use radicle::{
    Profile,
    cob::patch::PatchId,
    crypto::PublicKey,
    git::{BranchName, Oid},
    node::{Event, NodeId},
    prelude::RepoId,
    storage::{
        ReadStorage, RefUpdate, RepositoryError,
        refs::{Refs, RefsAt},
    },
};

use crate::{
    logger,
    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, profile: &Profile) -> Result<Vec<Self>, CiEventError> {
        let (rid, updates) = match event {
            Event::RefsFetched {
                remote: _,
                rid,
                updated,
            } => (*rid, updated),
            Event::LocalRefsAnnounced {
                rid,
                refs,
                timestamp: _,
            } => {
                let repo = profile
                    .storage
                    .repository(*rid)
                    .map_err(CiEventError::Repository)?;
                let parent = repo
                    .backend
                    .find_commit(refs.at.into())
                    .map_err(CiEventError::Git)?
                    .parent(0)
                    .map_err(CiEventError::Git)?
                    .id();
                let parent_refs = RefsAt {
                    remote: refs.remote,
                    at: parent.into(),
                };

                let signed_refs_new = refs
                    .load(&repo)
                    .map_err(|err| CiEventError::StorageRefs(err.into()))?
                    .sigrefs;
                let signed_refs_old = parent_refs
                    .load(&repo)
                    .map_err(|err| CiEventError::StorageRefs(err.into()))?
                    .sigrefs;

                let updates = diff_refs(signed_refs_old.refs(), signed_refs_new.refs())
                    .into_iter()
                    .filter_map(|mut ref_update| {
                        let name = ref_update_name_mut(&mut ref_update);
                        *name = Qualified::from_refstr(&*name)?
                            .with_namespace(profile.public_key.to_component())
                            .into_qualified()
                            .into_refstring();
                        Some(ref_update)
                    });

                (*rid, &updates.collect())
            }
            _ => {
                logger::node_event_not_handled(event);
                return Ok(vec![]);
            }
        };

        let mut events = vec![];
        for update in updates {
            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)
    }

    pub fn to_pretty_json(&self) -> Result<String, CiEventError> {
        serde_json::to_string_pretty(self).map_err(CiEventError::to_json)
    }
}

fn originator(name: Namespaced) -> Result<PublicKey, CiEventError> {
    PublicKey::from_namespaced(&name).map_err(|err| CiEventError::key_from_namespaced(&name, err))
}

fn diff_refs(old_refs: &Refs, new_refs: &Refs) -> Vec<RefUpdate> {
    let mut updates = Vec::new();
    let mut old_refs = old_refs
        .iter()
        .map(|(old_ref, &old_oid)| (old_ref, old_oid))
        .collect::<BTreeMap<_, _>>();
    let mut new_refs = new_refs
        .iter()
        .map(|(new_ref, &new_oid)| (new_ref, new_oid))
        .collect::<BTreeMap<_, _>>();

    old_refs.retain(|old_ref, old_oid| match new_refs.remove_entry(old_ref) {
        Some((new_ref, new_oid)) => {
            if new_oid != *old_oid {
                updates.push(RefUpdate::Updated {
                    name: new_ref.clone(),
                    old: *old_oid,
                    new: new_oid,
                })
            }
            false
        }
        None => true,
    });
    updates.extend(
        old_refs
            .into_iter()
            .map(|(old_ref, oid)| RefUpdate::Deleted {
                name: old_ref.clone(),
                oid,
            }),
    );
    updates.extend(
        new_refs
            .into_iter()
            .map(|(new_ref, oid)| RefUpdate::Created {
                name: new_ref.clone(),
                oid,
            }),
    );

    updates
}

fn ref_update_name_mut(ref_update: &mut RefUpdate) -> &mut RefString {
    match ref_update {
        RefUpdate::Updated { name, .. } => name,
        RefUpdate::Created { name, .. } => name,
        RefUpdate::Deleted { name, .. } => name,
        RefUpdate::Skipped { name, .. } => name,
    }
}

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] Box<dyn std::error::Error + Send + 'static>,
    ),

    #[error("failed to convert name spaced Git ref into node public key: {0}")]
    KeyFromNamespaced(
        RefString,
        #[source] Box<dyn std::error::Error + Send + 'static>,
    ),

    #[error("failed to encode CI event as JSON")]
    ToJson(#[source] Box<dyn std::error::Error + Send + 'static>),

    #[error(transparent)]
    Repository(RepositoryError),

    #[error(transparent)]
    StorageRefs(radicle::storage::refs::Error),

    #[error(transparent)]
    Git(radicle::git::raw::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(), Box::new(err))
    }

    fn to_json(err: serde_json::Error) -> Self {
        Self::ToJson(Box::new(err))
    }

    fn key_from_namespaced(name: &Namespaced, err: radicle_crypto::PublicKeyError) -> Self {
        Self::KeyFromNamespaced(name.to_ref_string(), Box::new(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},
        test::{MockNode, TestResult},
    };

    const MAIN_BRANCH_REF_NAME: &str =
        "refs/namespaces/z6MkiB8T5cBEQHnrs2MgjMVqvpSVj42X81HjKfFi2XBoMbtr/refs/heads/main";

    const PATCH_REF_NAME: &str = "refs/namespaces/z6MkiB8T5cBEQHnrs2MgjMVqvpSVj42X81HjKfFi2XBoMbtr/refs/heads/patches/f9fa90725474de9002be503ae3cda4670c9a1740";
    const PATCH_ID: &str = "f9fa90725474de9002be503ae3cda4670c9a1740";

    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::from_str(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() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;
        let event = Event::RefsFetched {
            remote: nid(),
            rid: rid(),
            updated: vec![],
        };
        let result = CiEvent::from_node_event(&event, &profile);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), vec![]);
        Ok(())
    }

    #[test]
    fn skipped() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;
        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, &profile);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), vec![]);
        Ok(())
    }

    #[test]
    fn branch_created() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;
        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, &profile);
        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"),
        }
        Ok(())
    }

    #[test]
    fn branch_updated() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;
        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, &profile);
        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"),
        }
        Ok(())
    }

    #[test]
    fn branch_deleted() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;
        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, &profile);
        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"),
        }
        Ok(())
    }

    #[test]
    fn patch_created() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;
        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, &profile);
        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"),
        }
        Ok(())
    }

    #[test]
    fn patch_updated() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;
        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, &profile);
        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"),
        }
        Ok(())
    }

    #[test]
    fn diff_refs() {
        let oid1 = oid_from("c474d78c37a5c664aba2042db78ec235c0a5d569");
        let oid2 = oid_from("5b6886f63ff90d61afaeec2f3569a9a1a0984bdb");
        let foo = RefString::try_from("refs/heads/foo").unwrap();
        let bar = RefString::try_from("refs/heads/bar").unwrap();
        let baz = RefString::try_from("refs/heads/baz").unwrap();
        let xyz = RefString::try_from("refs/heads/xyz").unwrap();

        let old = Refs::from(
            [
                (foo.clone(), oid1),
                (bar.clone(), oid1),
                (xyz.clone(), oid1),
            ]
            .into_iter(),
        );
        let new = Refs::from(
            [
                (foo.clone(), oid2),
                (baz.clone(), oid2),
                (xyz.clone(), oid1),
            ]
            .into_iter(),
        );
        let updates = super::diff_refs(&old, &new);
        assert_eq!(
            updates,
            [
                RefUpdate::Updated {
                    name: foo,
                    old: oid1,
                    new: oid2
                },
                RefUpdate::Deleted {
                    name: bar,
                    oid: oid1,
                },
                RefUpdate::Created {
                    name: baz,
                    oid: oid2,
                }
            ]
        );
    }
}

#[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)
                && let Some(patch_id) = captures.get(1)
                && let Ok(oid) = Oid::from_str(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)
                && let Some(branch_name) = captures.get(1)
                && 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)
                && let Some(tag_name) = captures.get(1)
                && 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()))
        );
    }
}