//! Messages for communicating with the adapter.
//!
//! The broker spawns an adapter child process, and sends it a request
//! via the child's stdin. The child sends responses via its stdout,
//! which the broker reads and processes. These messages are
//! represented using the types in this module.
//!
//! The types in this module are meant to be useful for anyone writing
//! a Radicle CI adapter.

#![deny(missing_docs)]

use std::str::FromStr;

pub use radicle_ci_adapter::{
    Author, EventCommonFields, EventType, PatchAction, PatchEvent, PushEvent, Repository, Revision,
    State,
};

use radicle::{
    Profile,
    cob::store::access,
    identity::Did,
    node::AliasStore,
    patch,
    storage::{ReadRepository, ReadStorage, git::paths},
};
pub use radicle::{
    cob::patch::PatchId,
    prelude::{NodeId, RepoId},
};
pub use radicle_surf::Commit;

pub use radicle_ci_adapter::{Patch, Request, Response, RunId, RunResult};

use crate::{
    ci_event::{CiEvent, CiEventV1},
    ergo::Oid,
    logger,
};

// This gets put into every [`Request`] message so the adapter can
// detect its getting a message it knows how to handle.
const PROTOCOL_VERSION: usize = 1;

/// Build a [`Request`].
#[derive(Debug, Default)]
pub struct RequestBuilder<'a> {
    profile: Option<&'a Profile>,
    ci_event: Option<&'a CiEvent>,
}

impl<'a> RequestBuilder<'a> {
    /// Set the node profile to use.
    pub fn profile(mut self, profile: &'a Profile) -> Self {
        self.profile = Some(profile);
        self
    }

    /// Set the CI event to use.
    pub fn ci_event(mut self, event: &'a CiEvent) -> Self {
        self.ci_event = Some(event);
        self
    }

    /// Create a [`Request::Trigger``] message from a [`crate::ci_event::CiEvent`].
    pub fn build_trigger_from_ci_event(self) -> Result<Request, MessageError> {
        fn repository(repo: &RepoId, profile: &Profile) -> Result<Repository, MessageError> {
            let rad_repo = match profile.storage.repository(*repo) {
                Err(err) => {
                    return Err(MessageError::repository_error(err));
                }
                Ok(rad_repo) => rad_repo,
            };

            let project_info = match rad_repo.project() {
                Err(err) => {
                    return Err(MessageError::repository_error(err));
                }
                Ok(x) => x,
            };
            let identity = rad_repo
                .identity()
                .map_err(MessageError::repository_error)?;
            let delegates = rad_repo
                .delegates()
                .map_err(MessageError::repository_error)?;
            Ok(Repository {
                id: *repo,
                name: project_info.name().to_string(),
                description: project_info.description().to_string(),
                private: !identity.visibility().is_public(),
                default_branch: project_info.default_branch().to_string(),
                delegates: delegates.iter().copied().collect(),
            })
        }

        fn common_fields(
            event_type: EventType,
            repo: &RepoId,
            profile: &Profile,
        ) -> Result<EventCommonFields, MessageError> {
            let repository = match repository(repo, profile) {
                Err(err) => {
                    return Err(err)?;
                }
                Ok(x) => x,
            };
            Ok(EventCommonFields {
                version: PROTOCOL_VERSION,
                event_type,
                repository,
            })
        }

        fn author(node: &NodeId, profile: &Profile) -> Result<Author, MessageError> {
            let did = Did::from(*node);
            did_to_author(profile, &did)
        }

        fn commits(
            git_repo: &radicle_surf::Repository,
            tip: Oid,
            base: Oid,
        ) -> Result<Vec<Oid>, radicle_surf::Error> {
            // We have an object ID from the `radicle` crate. We need to
            // convert into a value of the type `radicle-surf` wants, which
            // is from `radicle-git-ext`. As of 2026-01-16, we have multiple
            // versions of `radicle-git-ext` in our dependency graph: the latest
            // version of `radicle-surf` depends on an older version of `radicle-git-ext`
            // thatn `radicle` itself does.

            // Unwrapping is OK here, because we know `tip` is OK.
            #[allow(clippy::unwrap_used)]
            let commit = {
                let ext_oid = radicle_surf::Oid::try_from(tip.as_ref()).unwrap();
                git_repo.commit(ext_oid).unwrap()
            };
            git_repo
                .history(commit)?
                .take_while(|c| {
                    if let Ok(c) = c {
                        c.id.as_bytes() != base.as_ref()
                    } else {
                        false
                    }
                })
                .filter_map(|result| {
                    if let Ok(commit) = result {
                        if let Ok(oid) = Oid::from_str(&commit.id.to_string()) {
                            Some(Ok(oid))
                        } else {
                            None
                        }
                    } else {
                        None
                    }
                })
                .collect::<Result<Vec<Oid>, _>>()
        }

        fn patch_cob(
            rad_repo: &radicle::storage::git::Repository,
            patch_id: &PatchId,
        ) -> Result<radicle::cob::patch::Patch, MessageError> {
            let x = match patch::Patches::open(rad_repo, access::ReadOnly) {
                Err(err) => {
                    return Err(MessageError::repository_error(err))?;
                }
                Ok(x) => x,
            };

            let x = match x.get(patch_id) {
                Err(err) => {
                    return Err(MessageError::cob_store_error(err))?;
                }
                Ok(x) => x,
            };

            let x = match x {
                None => {
                    logger::patch_cob_lookup(&rad_repo.id, patch_id);
                    return Err(MessageError::PatchCob(*patch_id));
                }
                Some(x) => x,
            };

            Ok(x)
        }

        fn revisions(
            patch_cob: &radicle::cob::patch::Patch,
            author: &Author,
        ) -> Result<Vec<Revision>, MessageError> {
            patch_cob
                .revisions()
                .map(|(rid, r)| {
                    Ok::<Revision, MessageError>(Revision {
                        id: rid.into(),
                        author: author.clone(),
                        description: r.description().to_string(),
                        base: *r.base(),
                        oid: r.head(),
                        timestamp: r.timestamp().as_secs(),
                    })
                })
                .collect::<Result<Vec<Revision>, MessageError>>()
        }

        fn patch_base(
            patch_cob: &radicle::cob::patch::Patch,
            patch_id: &PatchId,
            author: &Author,
        ) -> Result<Oid, MessageError> {
            let author_pk = radicle::crypto::PublicKey::from(author.id);
            let (_id, revision) = match patch_cob.latest_by(&author_pk) {
                None => {
                    return Err(MessageError::LatestPatchRevision(*patch_id));
                }
                Some(x) => x,
            };
            Ok(*revision.base())
        }

        let profile = self.profile.ok_or(MessageError::NoProfile)?;

        match self.ci_event {
            None => Err(MessageError::CiEventNotSet),
            Some(CiEvent::V1(CiEventV1::BranchCreated {
                from_node,
                repo,
                branch,
                tip,
            })) => {
                Ok(Request::Trigger {
                    common: common_fields(EventType::Push, repo, profile)?,
                    push: Some(PushEvent {
                        pusher: author(from_node, profile)?,
                        before: *tip, // Branch created: we only use the tip
                        after: *tip,
                        branch: branch.as_str().to_string(),
                        commits: vec![*tip], // Branch created, only use tip.
                    }),
                    patch: None,
                })
            }
            Some(CiEvent::V1(CiEventV1::BranchUpdated {
                from_node,
                repo,
                branch,
                tip,
                old_tip,
            })) => {
                let git_repo =
                    radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
                        .map_err(MessageError::radicle_surf_error)?;
                let mut commits =
                    commits(&git_repo, *tip, *old_tip).map_err(MessageError::radicle_surf_error)?;
                if commits.is_empty() {
                    commits = vec![*old_tip];
                }

                Ok(Request::Trigger {
                    common: common_fields(EventType::Push, repo, profile)?,
                    push: Some(PushEvent {
                        pusher: author(from_node, profile)?,
                        before: *tip, // Branch created: we only use the tip
                        after: *tip,
                        branch: branch.as_str().to_string(),
                        commits,
                    }),
                    patch: None,
                })
            }
            Some(CiEvent::V1(CiEventV1::BranchDeleted {
                from_node,
                repo,
                branch,
                tip,
            })) => {
                Ok(Request::Trigger {
                    common: common_fields(EventType::Push, repo, profile)?,
                    push: Some(PushEvent {
                        pusher: author(from_node, profile)?,
                        before: *tip, // Branch created: we only use the tip
                        after: *tip,
                        branch: branch.as_str().to_string(),
                        commits: vec![*tip],
                    }),
                    patch: None,
                })
            }
            Some(CiEvent::V1(CiEventV1::TagCreated {
                from_node,
                repo,
                tag,
                tip,
            })) => {
                Ok(Request::Trigger {
                    common: common_fields(EventType::Push, repo, profile)?,
                    push: Some(PushEvent {
                        pusher: author(from_node, profile)?,
                        before: *tip, // Branch created: we only use the tip
                        after: *tip,
                        branch: tag.as_str().to_string(),
                        commits: vec![*tip], // Branch created, only use tip.
                    }),
                    patch: None,
                })
            }
            Some(CiEvent::V1(CiEventV1::TagUpdated {
                from_node,
                repo,
                tag,
                tip,
                old_tip,
            })) => {
                let git_repo =
                    radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
                        .map_err(MessageError::radicle_surf_error)?;
                let mut commits =
                    commits(&git_repo, *tip, *old_tip).map_err(MessageError::radicle_surf_error)?;
                if commits.is_empty() {
                    commits = vec![*old_tip];
                }

                Ok(Request::Trigger {
                    common: common_fields(EventType::Push, repo, profile)?,
                    push: Some(PushEvent {
                        pusher: author(from_node, profile)?,
                        before: *tip, // Branch created: we only use the tip
                        after: *tip,
                        branch: tag.as_str().to_string(),
                        commits,
                    }),
                    patch: None,
                })
            }
            Some(CiEvent::V1(CiEventV1::TagDeleted {
                from_node,
                repo,
                tag,
                tip,
            })) => {
                Ok(Request::Trigger {
                    common: common_fields(EventType::Push, repo, profile)?,
                    push: Some(PushEvent {
                        pusher: author(from_node, profile)?,
                        before: *tip, // Branch created: we only use the tip
                        after: *tip,
                        branch: tag.as_str().to_string(),
                        commits: vec![*tip],
                    }),
                    patch: None,
                })
            }
            Some(CiEvent::V1(CiEventV1::PatchCreated {
                from_node,
                repo,
                patch: patch_id,
                new_tip,
            })) => {
                let rad_repo = profile
                    .storage
                    .repository(*repo)
                    .map_err(MessageError::repository_error)?;
                let git_repo =
                    radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
                        .map_err(MessageError::radicle_surf_error)?;
                let author = author(from_node, profile)?;
                let patch_cob = patch_cob(&rad_repo, patch_id)?;
                let revisions = revisions(&patch_cob, &author)?;
                let patch_base = patch_base(&patch_cob, patch_id, &author)?;
                let commits = commits(&git_repo, *new_tip, patch_base)
                    .map_err(MessageError::radicle_surf_error)?;

                Ok(Request::Trigger {
                    common: common_fields(EventType::Patch, repo, profile)?,
                    push: None,
                    patch: Some(PatchEvent {
                        action: PatchAction::Created,
                        patch: Patch {
                            id: **patch_id,
                            author,
                            title: patch_cob.title().to_string(),
                            state: State {
                                status: patch_cob.state().to_string(),
                                conflicts: match patch_cob.state() {
                                    patch::State::Open { conflicts, .. } => conflicts.to_vec(),
                                    _ => vec![],
                                },
                            },
                            before: patch_base,
                            after: *new_tip,
                            commits,
                            target: patch_cob
                                .target()
                                .head(&rad_repo)
                                .map_err(MessageError::repository_error)?,
                            labels: patch_cob.labels().map(|l| l.name().to_string()).collect(),
                            assignees: patch_cob.assignees().collect(),
                            revisions,
                        },
                    }),
                })
            }
            Some(CiEvent::V1(CiEventV1::PatchUpdated {
                from_node,
                repo,
                patch: patch_id,
                new_tip,
            })) => {
                let rad_repo = profile
                    .storage
                    .repository(*repo)
                    .map_err(MessageError::repository_error)?;
                let git_repo =
                    radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
                        .map_err(MessageError::radicle_surf_error)?;
                let author = author(from_node, profile)?;
                let patch_cob = patch_cob(&rad_repo, patch_id)?;
                let revisions = revisions(&patch_cob, &author)?;
                let patch_base = patch_base(&patch_cob, patch_id, &author)?;
                let commits = commits(&git_repo, *new_tip, patch_base)
                    .map_err(MessageError::radicle_surf_error)?;

                Ok(Request::Trigger {
                    common: common_fields(EventType::Patch, repo, profile)?,
                    push: None,
                    patch: Some(PatchEvent {
                        action: PatchAction::Updated,
                        patch: Patch {
                            id: **patch_id,
                            author,
                            title: patch_cob.title().to_string(),
                            state: State {
                                status: patch_cob.state().to_string(),
                                conflicts: match patch_cob.state() {
                                    patch::State::Open { conflicts, .. } => conflicts.to_vec(),
                                    _ => vec![],
                                },
                            },
                            before: patch_base,
                            after: *new_tip,
                            commits,
                            target: patch_cob
                                .target()
                                .head(&rad_repo)
                                .map_err(MessageError::repository_error)?,
                            labels: patch_cob.labels().map(|l| l.name().to_string()).collect(),
                            assignees: patch_cob.assignees().collect(),
                            revisions,
                        },
                    }),
                })
            }
            Some(event) => Err(MessageError::UnknownCiEvent(event.clone())),
        }
    }
}

fn did_to_author(profile: &Profile, did: &Did) -> Result<Author, MessageError> {
    let alias = profile.aliases().alias(did);
    Ok(Author { id: *did, alias })
}

/// All possible errors from the CI broker messages.
#[derive(Debug, thiserror::Error)]
pub enum MessageError {
    /// [`RequestBuilder`] does not have profile set.
    #[error("RequestBuilder must have profile set")]
    NoProfile,

    /// [`RequestBuilder`] does not have event set.
    #[error("RequestBuilder must have broker event set")]
    NoEvent,

    /// [`RequestBuilder`] does not have event handler set.
    #[error("RequestBuilder has no event handler set")]
    NoEventHandler,

    /// We got a CI event we don't know what to do with.
    #[error("programming error: unknown CI event {0:?}")]
    UnknownCiEvent(CiEvent),

    /// CI event was not set for [`RequestBuilder`].
    #[error("programming error: CI event was not set for request builder")]
    CiEventNotSet,

    /// Request message lacks commits to run CI on.
    #[error("unacceptable request message: lacks Git commits to run CI on")]
    NoCommits,

    /// Request message is neither a "push" nor a "patch"
    #[error("unacceptable request message: neither 'push' nor 'patch'")]
    UnknownRequest,

    /// Failed to serialize a request message as JSON. This should
    /// never happen and likely indicates a programming failure.
    #[error("failed to serialize a request into JSON to a file handle")]
    SerializeRequest(#[source] Box<dyn std::error::Error + Send + 'static>),

    /// Failed to serialize a response message as JSON. This should never
    /// happen and likely indicates a programming failure.
    #[error("failed to serialize a request into JSON to a file handle")]
    SerializeResponse(#[source] Box<dyn std::error::Error + Send + 'static>),

    /// Failed to write the serialized request message to an open file.
    #[error("failed to write JSON to file handle")]
    WriteRequest(#[source] std::io::Error),

    /// Failed to write the serialized response message to an open
    /// file.
    #[error("failed to write JSON to file handle")]
    WriteResponse(#[source] std::io::Error),

    /// Failed to read a line of JSON from an open file.
    #[error("failed to read line from file handle")]
    ReadLine(#[source] std::io::Error),

    /// Failed to parse JSON as a request or a response.
    #[error("failed to read a JSON request from a file handle")]
    DeserializeRequest(#[source] Box<dyn std::error::Error + Send + 'static>),

    /// Failed to parse JSON as a response or a response.
    #[error("failed to read a JSON response from a file handle")]
    DeserializeResponse(#[source] Box<dyn std::error::Error + Send + 'static>),

    /// Error retrieving context to generate trigger.
    #[error("could not generate trigger from event")]
    Trigger,

    /// Error looking up the patch COB.
    #[error("could look up patch COB {0}: not found?")]
    PatchCob(PatchId),

    /// Error looking up latest revision for a patch COB.
    #[error("failed to look up latest revision for patch {0}")]
    LatestPatchRevision(PatchId),

    /// Error from Radicle repository.
    #[error("error from Radicle repository")]
    RepositoryError(#[source] Box<dyn std::error::Error + Send + 'static>),

    /// Error from Radicle COB.
    #[error("error from Radicle collaborative object")]
    CobStoreError(#[source] Box<dyn std::error::Error + Send + 'static>),

    /// Error from `radicle-surf` crate.
    #[error("error from radicle-surf")]
    RadicleSurfError(#[source] Box<dyn std::error::Error + Send + 'static>),

    /// Trying to create a PatchAction from an invalid value.
    #[error("invalid patch action {0:?}")]
    UnknownPatchAction(String),
}

impl MessageError {
    fn repository_error(err: radicle::storage::RepositoryError) -> Self {
        Self::RepositoryError(Box::new(err))
    }

    fn cob_store_error(err: radicle::cob::store::Error) -> Self {
        Self::CobStoreError(Box::new(err))
    }

    fn radicle_surf_error(err: radicle_surf::Error) -> Self {
        Self::RadicleSurfError(Box::new(err))
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)] // OK in tests: panic is fine
#[allow(missing_docs)]
pub mod trigger_from_ci_event_tests {
    use crate::ci_event::{CiEvent, CiEventV1};
    use crate::msg::{EventType, Request, RequestBuilder};
    use git_ref_format_core::RefString;
    use radicle::cob::Title;
    use radicle::cob::store::access;
    use radicle::patch::{MergeTarget, Patches};
    use radicle::prelude::Did;
    use radicle::storage::ReadRepository;

    use crate::test::{MockNode, TestResult};

    #[test]
    fn trigger_push_from_branch_created() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;

        let project = mock_node.node().project();
        let (_, repo_head) = project.repo.head()?;
        let cmt = radicle::test::fixtures::commit(
            "my test commit",
            &[repo_head.into()],
            &project.backend,
        );

        let ci_event = CiEvent::V1(CiEventV1::BranchCreated {
            from_node: *profile.id(),
            repo: project.id,
            branch: RefString::try_from("master")?,
            tip: cmt,
        });

        let req = RequestBuilder::default()
            .profile(&profile)
            .ci_event(&ci_event)
            .build_trigger_from_ci_event()?;
        let Request::Trigger {
            common,
            push,
            patch,
        } = req
        else {
            panic!();
        };

        assert!(patch.is_none());
        assert!(push.is_some());
        assert_eq!(common.event_type, EventType::Push);
        assert_eq!(common.repository.id, project.id);
        assert_eq!(common.repository.name, project.repo.project()?.name());

        let push = push.unwrap();
        assert_eq!(push.after, cmt);
        assert_eq!(push.before, cmt); // in this case of branch creation
        assert_eq!(
            push.branch,
            "master".replace("$nid", &profile.id().to_string())
        );
        assert_eq!(push.commits, vec![cmt]);
        assert_eq!(push.pusher.id, Did::from(profile.id()));

        Ok(())
    }

    #[test]
    fn trigger_push_from_branch_updated() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;

        let project = mock_node.node().project();
        let (_, repo_head) = project.repo.head()?;
        let cmt = radicle::test::fixtures::commit(
            "my test commit",
            &[repo_head.into()],
            &project.backend,
        );

        let ci_event = CiEvent::V1(CiEventV1::BranchUpdated {
            from_node: *profile.id(),
            repo: project.id,
            branch: RefString::try_from("master")?,
            old_tip: repo_head,
            tip: cmt,
        });

        let req = RequestBuilder::default()
            .profile(&profile)
            .ci_event(&ci_event)
            .build_trigger_from_ci_event()?;
        let Request::Trigger {
            common,
            push,
            patch,
        } = req
        else {
            panic!();
        };

        assert!(patch.is_none());
        assert!(push.is_some());
        assert_eq!(common.event_type, EventType::Push);
        assert_eq!(common.repository.id, project.id);
        assert_eq!(common.repository.name, project.repo.project()?.name());

        let push = push.unwrap();
        assert_eq!(push.after, cmt);
        assert_eq!(push.before, cmt); // in this case of branch creation
        assert_eq!(
            push.branch,
            "master".replace("$nid", &profile.id().to_string())
        );
        assert_eq!(push.commits, vec![cmt]);
        assert_eq!(push.pusher.id, Did::from(profile.id()));

        Ok(())
    }

    #[test]
    fn trigger_patch_from_patch_created() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;

        let project = mock_node.node().project();
        let (_, repo_head) = project.repo.head()?;
        let cmt = radicle::test::fixtures::commit(
            "my test commit",
            &[repo_head.into()],
            &project.backend,
        );

        let node = mock_node.node();

        let mut patches = Patches::open(&project.repo, access::WriteAs::new(&node.signer))?;
        let mut cache = radicle::cob::cache::NoCache;
        let patch_cob = patches.create(
            Title::new("my patch title").unwrap(),
            "my patch description",
            MergeTarget::Delegates,
            repo_head,
            cmt,
            &[],
            &mut cache,
        )?;

        let ci_event = CiEvent::V1(CiEventV1::PatchCreated {
            from_node: *profile.id(),
            repo: project.id,
            patch: *patch_cob.id(),
            new_tip: cmt,
        });

        let req = RequestBuilder::default()
            .profile(&profile)
            .ci_event(&ci_event)
            .build_trigger_from_ci_event()?;
        let Request::Trigger {
            common,
            push,
            patch,
        } = req
        else {
            panic!();
        };

        assert!(patch.is_some());
        assert!(push.is_none());
        assert_eq!(common.event_type, EventType::Patch);
        assert_eq!(common.repository.id, project.id);
        assert_eq!(common.repository.name, project.repo.project()?.name());

        let patch = patch.unwrap();
        // FIXME: assert_eq!(patch.action.as_str(), "created");
        assert_eq!(patch.patch.id.to_string(), patch_cob.id.to_string());
        assert_eq!(patch.patch.title, patch_cob.title());
        assert_eq!(patch.patch.state.status, patch_cob.state().to_string());
        assert_eq!(patch.patch.target, repo_head);
        assert_eq!(patch.patch.revisions.len(), 1);
        let rev = patch.patch.revisions.first().unwrap();
        assert_eq!(rev.id.to_string(), patch_cob.id.to_string());
        assert_eq!(rev.base, repo_head);
        assert_eq!(rev.oid, cmt);
        assert_eq!(rev.author.id, Did::from(profile.id()));
        assert_eq!(rev.description, patch_cob.description());
        assert_eq!(rev.timestamp, patch_cob.timestamp().as_secs());
        assert_eq!(patch.patch.after, cmt);
        assert_eq!(patch.patch.before, repo_head);
        assert_eq!(patch.patch.commits, vec![cmt]);

        Ok(())
    }

    #[test]
    fn trigger_patch_from_patch_updated() -> TestResult<()> {
        let mock_node = MockNode::new()?;
        let profile = mock_node.profile()?;

        let project = mock_node.node().project();
        let (_, repo_head) = project.repo.head()?;
        let cmt = radicle::test::fixtures::commit(
            "my test commit",
            &[repo_head.into()],
            &project.backend,
        );

        let node = mock_node.node();

        let mut patches = Patches::open(&project.repo, access::WriteAs::new(&node.signer))?;
        let mut cache = radicle::cob::cache::NoCache;
        let patch_cob = patches.create(
            Title::new("my patch title").unwrap(),
            "my patch description",
            MergeTarget::Delegates,
            repo_head,
            cmt,
            &[],
            &mut cache,
        )?;

        let ci_event = CiEvent::V1(CiEventV1::PatchUpdated {
            from_node: *profile.id(),
            repo: project.id,
            patch: *patch_cob.id(),
            new_tip: cmt,
        });

        let req = RequestBuilder::default()
            .profile(&profile)
            .ci_event(&ci_event)
            .build_trigger_from_ci_event()?;
        let Request::Trigger {
            common,
            push,
            patch,
        } = req
        else {
            panic!();
        };

        assert!(patch.is_some());
        assert!(push.is_none());
        assert_eq!(common.event_type, EventType::Patch);
        assert_eq!(common.repository.id, project.id);
        assert_eq!(common.repository.name, project.repo.project()?.name());

        let patch = patch.unwrap();
        // FIXME: assert_eq!(patch.action.as_str(), "updated");
        assert_eq!(patch.patch.id.to_string(), patch_cob.id.to_string());
        assert_eq!(patch.patch.title, patch_cob.title());
        assert_eq!(patch.patch.state.status, patch_cob.state().to_string());
        assert_eq!(patch.patch.target, repo_head);
        assert_eq!(patch.patch.revisions.len(), 1);
        let rev = patch.patch.revisions.first().unwrap();
        assert_eq!(rev.id.to_string(), patch_cob.id.to_string());
        assert_eq!(rev.base, repo_head);
        assert_eq!(rev.oid, cmt);
        assert_eq!(rev.author.id, Did::from(profile.id()));
        assert_eq!(rev.description, patch_cob.description());
        assert_eq!(rev.timestamp, patch_cob.timestamp().as_secs());
        assert_eq!(patch.patch.after, cmt);
        assert_eq!(patch.patch.before, repo_head);
        assert_eq!(patch.patch.commits, vec![cmt]);

        Ok(())
    }
}
