Radish alpha
r
rad:zwTxygwuz5LDGBq255RA2CbNGrz8
Radicle CI broker
Radicle
Git
Add tag events
Merged liw opened 1 year ago
9 files changed +500 -102 6dc057e2 756d7ac8
modified ci-broker.md
@@ -908,6 +908,44 @@ sed -i "s/NODEID/$rid/g" "$yaml"
~~~


+
## Filter predicate `Tag`
+

+
_Want:_ We can allow an event that is about a specific tag.
+

+
_Why:_ We want to constrain CI to specific tags, such as for releases.
+

+
~~~scenario
+
given a Radicle node, with CI configured with broker.yaml and adapter dummy.sh
+
given a Git repository xyzzy in the Radicle node
+

+
given file config.yaml from filter-tag.yaml
+

+
when I run cibtool --db ci-broker.db trigger --repo xyzzy
+
when I run ./env.sh cib --config config.yaml queued
+
when I run cibtool --db ci-broker.db run list --json
+
then stdout doesn't contain ""repo_name": "xyzzy""
+

+
when I try to run cibtool --db ci-broker.db trigger --repo xyzzy --commit v1.0
+
then command fails
+

+
when I run env -C xyzzy git tag -am "version 1.0" v1.0
+
when I run cibtool --db ci-broker.db trigger --repo xyzzy
+
when I run ./env.sh cib --config config.yaml queued
+
when I run cibtool --db ci-broker.db run list --json
+
then stdout doesn't contain ""repo_name": "xyzzy""
+
~~~
+

+
~~~{#filter-tag.yaml .file .json}
+
db: ci-broker.db
+
adapters:
+
  default:
+
    command: ./adapter.sh
+
triggers:
+
  - adapter: default
+
    filters:
+
      - !Tag "v\\d+(\\.\\d+)"
+
~~~
+

## Filter predicate `Branch`

_Want:_ We can allow an event that is about a specific branch.
modified doc/userguide.md
@@ -261,6 +261,40 @@ A branch has been deleted.
|                 | `branch`    | `BranchName`|
|                 | `tip`       | `Oid`       |

+
## `TagCreated`
+

+
An annotated Git tag has been created.
+

+
| Event           | fields              | field types |
+
|:----------------|:--------------------|:------------|
+
| `TagCreated`    | `from_node`         | `NodeId`    |
+
|                 | `repo`              | `RepoId`    |
+
|                 | `tag_name`          | `RefString` |
+
|                 | `tip`               | `Oid`       |
+

+
## `TagUpdated`
+

+
An annotated Git tag has been updated.
+

+
| Event           | fields      | field types |
+
|:----------------|:------------|:------------|
+
| `TagUpdated`    | `from_node` | `NodeId`    |
+
|                 | `repo`      | `RepoId`    |
+
|                 | `tag_name`  | `RefString` |
+
|                 | `tip`       | `Oid`       |
+
|                 | `old_tip`   | `Oid`       |
+

+
## `TagDeleted`
+

+
An annotated Git tag has been deleted.
+

+
| Event           | fields      | field types |
+
|:----------------|:------------|:------------|
+
| `TagDeleted`    | `from_node` | `NodeId`    |
+
|                 | `repo`      | `RepoId`    |
+
|                 | `tag_name`  | `RefString` |
+
|                 | `tip`       | `Oid`       |
+

## `PatchCreated`

A patch has been created.
@@ -298,6 +332,9 @@ Otherwise it is discarded and does not trigger a CI run.
| `BranchCreated`   | Branch was created                                        |
| `BranchDeleted`   | Branch was deleted                                        |
| `BranchUpdated`   | Branch was updated                                        |
+
| `TagCreated`      | Annotated tag was created                                 |
+
| `TagDeleted`      | Annotated tag was deleted                                 |
+
| `TagUpdated`      | Annotated tag was updated                                 |
| `Branch`          | Event refers to a specific Git branch                     |
| `DefaultBranch`   | Event refers to a default branch of the repository        |
| `Deny`            | Changes is not allowed                                    |
modified src/ci_event.rs
@@ -12,7 +12,7 @@ use radicle::{
    storage::RefUpdate,
};

-
use crate::refs::{branch_from_namespaced, namespaced_from_str};
+
use crate::refs::ref_string;

#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
#[non_exhaustive]
@@ -43,6 +43,25 @@ pub enum CiEventV1 {
        branch: BranchName,
        tip: Oid,
    },
+
    TagCreated {
+
        from_node: NodeId,
+
        repo: RepoId,
+
        tag: RefString,
+
        tip: Oid,
+
    },
+
    TagUpdated {
+
        from_node: NodeId,
+
        repo: RepoId,
+
        tag: RefString,
+
        tip: Oid,
+
        old_tip: Oid,
+
    },
+
    TagDeleted {
+
        from_node: NodeId,
+
        repo: RepoId,
+
        tag: RefString,
+
        tip: Oid,
+
    },
    PatchCreated {
        from_node: NodeId,
        repo: RepoId,
@@ -64,6 +83,9 @@ impl CiEvent {
            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),
        }
@@ -75,6 +97,9 @@ impl CiEvent {
            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),
        }
@@ -86,6 +111,23 @@ impl CiEvent {
            Self::V1(CiEventV1::BranchCreated { branch, .. }) => Some(branch),
            Self::V1(CiEventV1::BranchUpdated { branch, .. }) => Some(branch),
            Self::V1(CiEventV1::BranchDeleted { branch, .. }) => Some(branch),
+
            Self::V1(CiEventV1::TagCreated { .. }) => None,
+
            Self::V1(CiEventV1::TagUpdated { .. }) => None,
+
            Self::V1(CiEventV1::TagDeleted { .. }) => None,
+
            Self::V1(CiEventV1::PatchCreated { .. }) => None,
+
            Self::V1(CiEventV1::PatchUpdated { .. }) => None,
+
        }
+
    }
+

+
    pub fn tag(&self) -> Option<&RefString> {
+
        match self {
+
            Self::V1(CiEventV1::Shutdown) => None,
+
            Self::V1(CiEventV1::BranchCreated { .. }) => None,
+
            Self::V1(CiEventV1::BranchUpdated { .. }) => None,
+
            Self::V1(CiEventV1::BranchDeleted { .. }) => None,
+
            Self::V1(CiEventV1::TagCreated { tag, .. }) => Some(tag),
+
            Self::V1(CiEventV1::TagUpdated { tag, .. }) => Some(tag),
+
            Self::V1(CiEventV1::TagDeleted { tag, .. }) => Some(tag),
            Self::V1(CiEventV1::PatchCreated { .. }) => None,
            Self::V1(CiEventV1::PatchUpdated { .. }) => None,
        }
@@ -97,6 +139,9 @@ impl CiEvent {
            Self::V1(CiEventV1::BranchCreated { .. }) => None,
            Self::V1(CiEventV1::BranchUpdated { .. }) => None,
            Self::V1(CiEventV1::BranchDeleted { .. }) => None,
+
            Self::V1(CiEventV1::TagCreated { .. }) => None,
+
            Self::V1(CiEventV1::TagUpdated { .. }) => None,
+
            Self::V1(CiEventV1::TagDeleted { .. }) => None,
            Self::V1(CiEventV1::PatchCreated { patch, .. }) => Some(patch),
            Self::V1(CiEventV1::PatchUpdated { patch, .. }) => Some(patch),
        }
@@ -108,6 +153,9 @@ impl CiEvent {
            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),
        }
@@ -160,6 +208,53 @@ impl CiEvent {
        }))
    }

+
    pub fn tag_created(
+
        from_node: NodeId,
+
        repo: RepoId,
+
        tag: &RefString,
+
        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: &RefString,
+
        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: &RefString,
+
        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,
@@ -191,40 +286,45 @@ impl CiEvent {
                    let e = match update {
                        RefUpdate::Created { name, oid } => {
                            let origin = originator(name.to_namespaced().unwrap())?;
-
                            if let Ok(patch_id) = patch_id(name) {
-
                                Self::patch_created(origin, *rid, patch_id, *oid)
-
                            } else if let Ok(branch) = namespaced_from_str(name) {
-
                                if let Ok(branch) = branch_from_namespaced(&branch) {
+
                            match ParsedRef::parse_ref(name) {
+
                                Some(ParsedRef::Branch(branch)) => {
                                    Self::branch_created(origin, *rid, &branch, *oid)?
-
                                } else {
-
                                    continue;
                                }
-
                            } else {
-
                                continue;
+
                                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())?;
-
                            if let Ok(patch_id) = patch_id(name) {
-
                                Self::patch_updated(origin, *rid, patch_id, *new)
-
                            } else if let Ok(branch) = namespaced_from_str(name) {
-
                                if let Ok(branch) = branch_from_namespaced(&branch) {
+
                            match ParsedRef::parse_ref(name) {
+
                                Some(ParsedRef::Branch(branch)) => {
                                    Self::branch_updated(origin, *rid, &branch, *new, *old)?
-
                                } else {
-
                                    continue;
                                }
-
                            } else {
-
                                continue;
+
                                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())?;
-
                            let branch = namespaced_from_str(name)
-
                                .map_err(|err| CiEventError::branch_name(name, err))?;
-
                            if let Ok(branch) = branch_from_namespaced(&branch) {
-
                                Self::branch_deleted(origin, *rid, &branch, *oid)?
-
                            } else {
-
                                continue;
+
                            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,
@@ -296,16 +396,9 @@ pub enum CiEventError {

    #[error("failed to encode CI event as JSON")]
    ToJson(#[source] serde_json::Error),
-

-
    #[error(transparent)]
-
    Parwe(#[from] ParseError),
}

impl CiEventError {
-
    fn branch_name(name: &str, err: crate::refs::RefError) -> Self {
-
        Self::BranchName(name.into(), err)
-
    }
-

    fn read_file(filename: &Path, err: std::io::Error) -> Self {
        Self::ReadFile(filename.into(), err)
    }
@@ -330,7 +423,7 @@ mod test {
    use radicle::{prelude::NodeId, storage::RefUpdate};
    use std::str::FromStr;

-
    use crate::refs::ref_string;
+
    use crate::refs::{branch_from_namespaced, ref_string};

    const MAIN_BRANCH_REF_NAME: &str =
        "refs/namespaces/z6MkiB8T5cBEQHnrs2MgjMVqvpSVj42X81HjKfFi2XBoMbtr/refs/heads/main";
@@ -569,86 +662,102 @@ mod test {
    }
}

-
fn patch_id(refname: &str) -> Result<PatchId, ParseError> {
-
    const PAT_PATCH: &str = r"^refs/namespaces/[^/]+/refs/heads/patches/([^/]+)$";
-
    let patch_re = Regex::new(PAT_PATCH).map_err(|e| ParseError::regex(PAT_PATCH, e))?;
-
    if let Some(patch_captures) = patch_re.captures(refname) {
-
        if let Some(patch_id) = patch_captures.get(1) {
-
            let oid = Oid::try_from(patch_id.as_str())
-
                .map_err(|e| ParseError::oid(patch_id.as_str(), e))?;
-
            return Ok(oid.into());
-
        }
-
    }
-

-
    Err(ParseError::not_patch(refname))
+
#[derive(Debug, Eq, PartialEq)]
+
#[allow(dead_code)]
+
enum ParsedRef {
+
    Branch(BranchName),
+
    Patch(PatchId),
+
    Tag(RefString),
}

-
#[derive(Debug, thiserror::Error)]
-
pub enum ParseError {
-
    #[error("programming error: unacceptable regular expression {0:?}")]
-
    Regex(&'static str, regex::Error),
-

-
    #[error("Git ref name without name space: {0:?}")]
-
    NotBranch(String),
-

-
    #[error("unacceptable Git ref for patch: {0:?}")]
-
    NotPatch(String),
-

-
    #[error("Git ref name includes unacceptable Git object id: {0:?}")]
-
    Oid(String, radicle::git::raw::Error),
-

-
    #[error("failed to create a Git branch name from {0:?}")]
-
    BranchName(String, #[source] crate::refs::RefError),
-
}
+
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
+
        }

-
impl ParseError {
-
    fn regex(pattern: &'static str, err: regex::Error) -> Self {
-
        Self::Regex(pattern, err)
-
    }
+
        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 not_patch(refname: &str) -> Self {
-
        Self::NotPatch(refname.into())
-
    }
+
        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));
+
                    }
+
                }
+
            }
+
            None
+
        }

-
    fn oid(refname: &str, err: radicle::git::raw::Error) -> Self {
-
        Self::Oid(refname.into(), err)
+
        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_patch_id {
-
    use super::{patch_id, Oid, ParseError};
+
mod test_parsed_ref {
+
    use std::str::FromStr;

-
    #[test]
-
    fn empty() {
-
        assert!(matches!(patch_id(""), Err(ParseError::NotPatch(_))));
-
    }
+
    use crate::refs::{branch_ref, ref_string};
+

+
    use super::*;

    #[test]
-
    fn lacks_namespace() {
-
        assert!(matches!(patch_id(""), Err(ParseError::NotPatch(_))));
+
    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 has_namespace() {
-
        let x = patch_id(
-
            "refs/namespaces/DID/refs/heads/patches/f9fa90725474de9002be503ae3cda4670c9a1741",
-
        );
-
        assert!(x.is_ok());
+
    fn patch() {
+
        let actual = ref_string(
+
            "refs/namespaces/NID/refs/heads/patches/9d1a97571e86caafa86df7bc1692d305710a596e",
+
        )
+
        .unwrap();
+
        let wanted = PatchId::from_str("9d1a97571e86caafa86df7bc1692d305710a596e").unwrap();
        assert_eq!(
-
            x.unwrap(),
-
            Oid::try_from("f9fa90725474de9002be503ae3cda4670c9a1741")
-
                .unwrap()
-
                .into()
+
            ParsedRef::parse_ref(&actual),
+
            Some(ParsedRef::Patch(wanted))
        );
    }

    #[test]
-
    fn has_namespace_with_path() {
-
        assert!(matches!(
-
            patch_id("refs/namespaces/DID/refs/heads/patches/coffee/beef"),
-
            Err(ParseError::NotPatch(_))
-
        ));
+
    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)));
    }
}
modified src/filter.rs
@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf};

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

use radicle::{
@@ -13,6 +14,7 @@ use radicle::{
use crate::{
    ci_event::{CiEvent, CiEventV1},
    config::TriggerConfig,
+
    logger,
    refs::ref_string,
};

@@ -54,6 +56,9 @@ pub enum EventFilter {
    /// Event is for a specific branch.
    Branch(BranchName),

+
    /// Event is for a tag whose name matches a regular expression.
+
    Tag(String),
+

    /// Event if for the default branch for the repository.
    DefaultBranch,

@@ -151,6 +156,24 @@ impl EventFilter {
                    format!("wanted={wanted} actual={actual:?}"),
                )
            }
+
            Self::Tag(wanted) => match Regex::new(wanted) {
+
                Ok(re) => {
+
                    let actual = event.tag();
+
                    let allowed = match &actual {
+
                        Some(actual) => re.is_match_at(actual.as_str(), 0),
+
                        _ => false,
+
                    };
+
                    Decision::string(
+
                        "Tag",
+
                        allowed,
+
                        format!("wanted={wanted:?} actual={actual:?}"),
+
                    )
+
                }
+
                Err(err) => {
+
                    logger::queueproc_filter_regex_error(wanted, err);
+
                    Decision::new("Tag", false, "regex syntax error")
+
                }
+
            },
            Self::DefaultBranch => {
                let repo = event.repository();
                let actual = event.branch();
@@ -211,6 +234,7 @@ impl EventFilter {

    pub fn allows(&self, event: &CiEvent) -> bool {
        let dec = self.decide(event);
+
        logger::queueproc_filter_decision(event, self, dec.allowed);
        dec.allowed
    }

@@ -298,6 +322,7 @@ pub fn get_default_branch(repo_id: &RepoId) -> Result<BranchName, Box<dyn std::e
    Ok(proj.default_branch().clone())
}

+
#[allow(clippy::unwrap_used)]
fn has_file(repo_id: &RepoId, oid: &Oid, filename: &Path) -> bool {
    fn helper(
        repo_id: &RepoId,
@@ -307,8 +332,24 @@ fn has_file(repo_id: &RepoId, oid: &Oid, filename: &Path) -> bool {
        let profile = Profile::load()?;
        let repo = Repository::open(profile.storage.path().join(repo_id.canonical()), *repo_id)?;

-
        let obj = repo.backend.find_object(*oid, Some(ObjectType::Commit))?;
-
        let commit = if let Ok(commit) = obj.into_commit() {
+
        let obj = repo.backend.find_object(*oid, None);
+
        let obj = obj?;
+
        let commit = match obj.kind() {
+
            None => return Ok(false),
+
            Some(ObjectType::Any) => return Ok(false),
+
            Some(ObjectType::Tree) => return Ok(false),
+
            Some(ObjectType::Blob) => return Ok(false),
+

+
            Some(ObjectType::Commit) => obj.into_commit(),
+
            Some(ObjectType::Tag) => {
+
                let tag = obj.into_tag().unwrap();
+
                repo.backend
+
                    .find_object(tag.target_id(), None)
+
                    .unwrap()
+
                    .into_commit()
+
            }
+
        };
+
        let commit = if let Ok(commit) = commit {
            commit
        } else {
            return Ok(false);
@@ -321,6 +362,7 @@ fn has_file(repo_id: &RepoId, oid: &Oid, filename: &Path) -> bool {
        };
        let obj = entry.to_object(&repo.backend)?;
        let ok = obj.into_blob().is_ok();
+

        Ok(ok)
    }

modified src/logger.rs
@@ -444,6 +444,15 @@ pub fn queueproc_queue_length(len: usize) {
    );
}

+
pub fn queueproc_filter_regex_error(pattern: &str, err: regex::Error) {
+
    warn!(
+
        msg_id = ?Id::QueueProcFilterDecision,
+
        kind = %Kind::FilterDecision,
+
        ?pattern,
+
        ?err,
+
        "regular expression syntax error");
+
}
+

pub fn queueproc_filter_decision(event: &CiEvent, filter: &EventFilter, allowed: bool) {
    info!(
        msg_id = ?Id::QueueProcFilterDecision,
@@ -513,12 +522,13 @@ pub fn queueproc_remove_event(id: &QueueId) {
    );
}

-
pub fn queueproc_action_run(rid: &RepoId, oid: &Oid) {
+
pub fn queueproc_action_run(rid: &RepoId, oid: &Oid, msg: &str) {
    info!(
        msg_id = ?Id::QueueProcActionRun,
        kind = %Kind::Debug,
        ?rid,
        ?oid,
+
        msg = msg,
        "Action: run"
    );
}
modified src/msg.rs
@@ -332,6 +332,68 @@ impl<'a> RequestBuilder<'a> {
                    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))?;
+
                let mut commits = commits(&git_repo, *tip, *old_tip)?;
+
                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,
@@ -433,7 +495,7 @@ pub enum Request {
        #[serde(flatten)]
        common: EventCommonFields,

-
        /// The push event, if any.
+
        /// The push event, if any. `branch` may tag name if tag event.
        #[serde(flatten)]
        push: Option<PushEvent>,

@@ -543,6 +605,9 @@ pub enum EventType {

    /// A new or changed patch.
    Patch,
+

+
    /// A new or changed tag.
+
    Tag,
}

/// Common fields in all variations of a [`Request`] message.
modified src/pages.rs
@@ -255,6 +255,22 @@ impl PageData {
                CiEvent::V1(CiEventV1::BranchDeleted {
                    repo, branch, tip, ..
                }) => render_event(repo, self.repo_alias(*repo), branch, tip),
+
                CiEvent::V1(CiEventV1::TagCreated {
+
                    from_node: _,
+
                    repo,
+
                    tag,
+
                    tip,
+
                }) => render_event(repo, self.repo_alias(*repo), tag, tip),
+
                CiEvent::V1(CiEventV1::TagUpdated {
+
                    from_node: _,
+
                    repo,
+
                    tag,
+
                    tip,
+
                    old_tip: _,
+
                }) => render_event(repo, self.repo_alias(*repo), tag, tip),
+
                CiEvent::V1(CiEventV1::TagDeleted { repo, tag, tip, .. }) => {
+
                    render_event(repo, self.repo_alias(*repo), tag, tip)
+
                }
                CiEvent::V1(CiEventV1::PatchCreated {
                    from_node: _,
                    repo,
modified src/queueproc.rs
@@ -135,7 +135,7 @@ impl QueueProcessor {
                            first_error = Some(err);
                        }
                    }
-
                    Ok(None) => self.drop_event(qe.id())?,
+
                    Ok(None) => (), // We already removed event from queue.
                    Ok(Some(adapters)) => {
                        for adapter in adapters {
                            match self.run_adapter(&qe, &adapter) {
@@ -250,7 +250,7 @@ impl QueueProcessor {
                branch: _,
                tip,
            }) => {
-
                logger::queueproc_action_run(repo, tip);
+
                logger::queueproc_action_run(repo, tip, "branch created");
                let trigger = RequestBuilder::default()
                    .profile(&self.profile)
                    .ci_event(event)
@@ -268,7 +268,7 @@ impl QueueProcessor {
                tip,
                old_tip: _,
            }) => {
-
                logger::queueproc_action_run(repo, tip);
+
                logger::queueproc_action_run(repo, tip, "branch updated");
                let trigger = RequestBuilder::default()
                    .profile(&self.profile)
                    .ci_event(event)
@@ -285,7 +285,61 @@ impl QueueProcessor {
                branch: _,
                tip,
            }) => {
-
                logger::queueproc_action_run(repo, tip);
+
                logger::queueproc_action_run(repo, tip, "branch deleted");
+
                let trigger = RequestBuilder::default()
+
                    .profile(&self.profile)
+
                    .ci_event(event)
+
                    .build_trigger_from_ci_event()
+
                    .map_err(|e| QueueError::build_trigger(event, e))?;
+
                self.broker
+
                    .execute_ci(adapter, &trigger, &self.run_tx)
+
                    .map_err(QueueError::execute_ci)?;
+
                Ok(false)
+
            }
+
            CiEvent::V1(CiEventV1::TagCreated {
+
                from_node: _,
+
                repo,
+
                tag: _,
+
                tip,
+
            }) => {
+
                logger::queueproc_action_run(repo, tip, "tag created");
+
                let result = RequestBuilder::default()
+
                    .profile(&self.profile)
+
                    .ci_event(event)
+
                    .build_trigger_from_ci_event()
+
                    .map_err(|e| QueueError::build_trigger(event, e));
+
                logger::queueproc_trigger(&result);
+
                let trigger = result?;
+
                self.broker
+
                    .execute_ci(adapter, &trigger, &self.run_tx)
+
                    .map_err(QueueError::execute_ci)?;
+
                Ok(false)
+
            }
+
            CiEvent::V1(CiEventV1::TagUpdated {
+
                from_node: _,
+
                repo,
+
                tag: _,
+
                tip,
+
                old_tip: _,
+
            }) => {
+
                logger::queueproc_action_run(repo, tip, "tag updated");
+
                let trigger = RequestBuilder::default()
+
                    .profile(&self.profile)
+
                    .ci_event(event)
+
                    .build_trigger_from_ci_event()
+
                    .map_err(|e| QueueError::build_trigger(event, e))?;
+
                self.broker
+
                    .execute_ci(adapter, &trigger, &self.run_tx)
+
                    .map_err(QueueError::execute_ci)?;
+
                Ok(false)
+
            }
+
            CiEvent::V1(CiEventV1::TagDeleted {
+
                from_node: _,
+
                repo,
+
                tag: _,
+
                tip,
+
            }) => {
+
                logger::queueproc_action_run(repo, tip, "tag deleted");
                let trigger = RequestBuilder::default()
                    .profile(&self.profile)
                    .ci_event(event)
@@ -302,7 +356,7 @@ impl QueueProcessor {
                patch: _,
                new_tip,
            }) => {
-
                logger::queueproc_action_run(repo, new_tip);
+
                logger::queueproc_action_run(repo, new_tip, "patch created");
                let trigger = RequestBuilder::default()
                    .profile(&self.profile)
                    .ci_event(event)
@@ -319,7 +373,7 @@ impl QueueProcessor {
                patch: _,
                new_tip,
            }) => {
-
                logger::queueproc_action_run(repo, new_tip);
+
                logger::queueproc_action_run(repo, new_tip, "patch updated");
                let trigger = RequestBuilder::default()
                    .profile(&self.profile)
                    .ci_event(event)
modified src/refs.rs
@@ -1,6 +1,11 @@
//! Git reference names, namespaced and not.

-
use radicle::git::{BranchName, Component, Namespaced, Qualified, RefStr, RefString};
+
use std::str::FromStr;
+

+
use radicle::{
+
    cob::patch::PatchId,
+
    git::{BranchName, Component, Namespaced, Qualified, RefStr, RefString},
+
};

/// Convert a plain branch name (`main`) from a Git ref
pub fn branch_ref(name: &RefStr) -> Result<BranchName, RefError> {
@@ -65,6 +70,13 @@ pub fn branch_from_namespaced(ns: &Namespaced) -> Result<BranchName, RefError> {
    ))
}

+
/// Create a [`PatchId`] from a string slice.
+
pub fn patch_from_str(s: &str) -> Result<PatchId, RefError> {
+
    let res = PatchId::from_str(s).map_err(|_| RefError::PatchIdFromStr(s.into()));
+
    eprintln!("{res:#?}");
+
    res
+
}
+

/// All errors from Git reference manipulation.
#[derive(Debug, thiserror::Error, Eq, PartialEq)]
pub enum RefError {
@@ -91,11 +103,17 @@ pub enum RefError {
    /// Can't create a [`Component`] from a name space name.
    #[error("failed to create a name space component from its name {0:?}")]
    NamespaceName(RefString),
+

+
    /// Can't create a patch id from string.
+
    #[error("failed to create a patch id from string: {0:?}")]
+
    PatchIdFromStr(String),
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
+
    use std::str::FromStr;
+

    use super::*;

    #[test]
@@ -139,4 +157,13 @@ mod test {
        let extracted = branch_from_namespaced(&name);
        assert_eq!(extracted.map(|x| x.to_string()), Ok("main".into()));
    }
+

+
    #[test]
+
    fn creates_patch_from_str() {
+
        let oid = PatchId::from_str("e76d814f6934f24d45f628f8ff9533dcdefc1bd8").unwrap();
+
        assert_eq!(
+
            patch_from_str("e76d814f6934f24d45f628f8ff9533dcdefc1bd8"),
+
            Ok(oid)
+
        );
+
    }
}