GitLab automation with file hook rules

Posted on June 6, 2020

Suppose you'd like to automate your GitLab server:

Every time a user forks a project from the DevOps GitLab group, add the CodeReviewers group as a member of the fork.

Or perhaps:

Every time a new user account is created, add them to the Newcomers GitLab group.

Or even maybe:

Every time a pushed commit fails a CI pipeline, raise a new Issue ticket against the project.

This is possible with GitLab hooks.

GitLab hooks allow 3rd party software to react to GitLab events. GitLab generates JSON data when events occur, which it sends to your 3rd party software. Hooks can be web services or executable file hooks uploaded to the GitLab server.

This post show how to write GitLab file hooks, using Haskell. Here's an example rule:

add_joe_to_webdev_projects :: Rule
add_joe_to_webdev_projects =
    matchIf
      "adds Joe Bloggs to projects forked from the web development group"
      (\event@ProjectCreate {} -> do
          isForkFromNamespace (projectCreate_project_id event) "WebDev")
      (\event@ProjectCreate {} -> do
          addUserToProject (projectCreate_project_id event) "joe_bloggs")

Event-Condition-Action Rules

Event-Condition-Action (ECA) rules were initially designed for Active Database Systems (Dittrich et al, 1995) and event driven architectures.

An ECA rule has the general declarative syntax:

on event if condition do event

Some GitLab event ECA examples are:

on event if condition do event
A new GitLab project created a GitLab project's name is "foo" add user "jane1" to the project
A GitLab group has been created always true add user "jane1" to the group
A GitLab group has been renamed A GitLab project's new name is "bar" add user "jane1" to the project

ECA GitLab Hooks API

The gitlab-haskell library includes an ECA API for GitLab Hooks.

Reactive GitLab programs are constructed using a receive function:

receive :: [Rule] -> GitLab ()

Rules are constructed with match or matchIf:

class (FromJSON a) => SystemHook a where
    match   :: String -> (a -> GitLab ()) -> Rule
    matchIf :: String -> (a -> GitLab Bool) -> (a -> GitLab ()) -> Rule
rule event condition GitLab action
match An instance of SystemHook e.g. UserCreate n/a (a -> GitLab ())
matchIf An instance of SystemHook e.g. ProjectUpdate (a -> GitLab Bool) (a -> GitLab ())

The match rule fires if the a argument is a GitLab event that matches the event described in the JSON received from the GitLab server.

Firing a matchIf rule also requires the additional condition function to return True.

SystemHook typeclass instances

The a type parameter for match and matchIf must be an instance of the SystemHook typeclass.

The instances are: ProjectCreate, ProjectDestroy, ProjectRename, ProjectTransfer, ProjectUpdate, UserAddToTeam, UserUpdateForTeam, UserRemoveFromTeam, UserCreate, UserRemove, KeyCreate, KeyRemove, GroupCreate, GroupRemove, GroupRename, NewGroupMember, GroupMemberRemove, GroupMemberUpdate, Push and TagPush.

Executing GitLab actions

The GitLab condition function (a -> GitLab Bool) and the action function (a -> GitLab ()) can perform any GitLab operations that the gitlab-haskell library provides.

Some examples of GitLab functions in gitlab-haskell are:

userProjects        :: User -> GitLab (Maybe [Project]) 
branches            :: Project -> GitLab [Branch]
projectCommits      :: Project -> GitLab [Commit]
addUserToGroup      :: Text -> AccessLevel -> User -> GitLab (Either Status Member)
projectOpenedIssues :: Project -> GitLab [Issue]
mergeRequests       :: Project -> GitLab [MergeRequest]
repositoryFiles     :: Project -> FilePath -> Text -> GitLab (Maybe RepositoryFile)

More about the gitlab-haskell library is explained in this blog post.

Combining ECA rules with GitLab actions

An example of match is:

rule1 :: Rule
rule1 =
  match "add Jane to every created project"
   (\event@ProjectCreate{} -> do
     Just jane <- searchUser "jane1"
     addMemberToProject'
       (projectCreate_project_id event)
       Reporter
       (user_id jane))

The matchIf predicate function is applied to the received GitLab event data. If the condition function returns True, the GitLab action function is applied to the same event data.

An example of matchIf if:

rule2 :: Rule
rule2 =
  matchIf "add Jane to web-dev projects"
   (\event@ProjectCreate{} -> do
      (Right (Just project)) <- searchProjectId (projectCreate_project_id event)
      let name_space = namespace_path (namespace project)
      return (name_space == "web-dev")) 
   (\event@ProjectCreate{} -> do
     Just jane <- searchUser "jane1"
     addMemberToProject'
       (projectCreate_project_id event)
       Reporter
       (user_id jane))

The rule based API is documented here:

Combining ECA GitLab rules

Multiple rules can be combined. You can combine match and matchIf rules, and they can match on different SystemHook instances:

myRules :: GitLab ()
myRules = receive [rule1, rule2]

To implement an executable file hook, use the runGitLab function:

main :: IO ()
main =
  runGitLab
    ( defaultGitLabServer
        { url = "https://gitlab.example.com",
          token = "abcde12345"
        } )
    myRules

When GitLab creates JSON for an event, it will run this program and the receive function reads the JSON from standard input and will then try to fire each rule in sequence.

Documentation for runGitLab is:

http://hackage.haskell.org/package/gitlab-haskell/docs/GitLab.html

Deploying the GitLab File Hook

Once you've compiled your Haskell code to an executable file, on the GitLab server copy it to /opt/gitlab/embedded/service/gitlab-rails/file_hooks

I recommend compiling the Haskell source on the GitLab server to ensure all linked shared libraries can be found at runtime.

Navigate to: https://<your GitLab domain>/admin/hooks, you should see your file hook listed.

For example, below shows a file hook called gitlab-hooks-hw-exe in operation: