ScriptRunner allows global administrators to apply hooks, merge checks, and event handlers. You can either write your own hooks and handlers in groovy, or use the provided content. Most of these can be used in conjunction with conditions, which will give you high level of flexibility, and control.

You can apply the hooks to all repositories, or just particular repositories or projects.

choose repositories

Conditions

Most content (hooks, merge checks, event handlers) has a condition option. Typically you will combine a condition with the built-in content, for example mail out only when a branch that matches a regex is created.

Click the Expand examples link under the Condition field to display a list of examples.

expand examples

Clicking any of these will overwrite the current condition with the sample code. You will probably need to modify any string literals, such as references to project keys or groups. The result of the last line of any groovy script is returned as the result of the condition, but feel free to use the return keyword to make it clearer.

Take as an example a contrived scenario - you want to prevent creation of tags in all publicly accessible repositories. Start with the condition that matches creation of tags:

import com.atlassian.bitbucket.repository.RefChangeType

refChanges.any { it.ref.id.startsWith("refs/tags/") &&
    it.type == RefChangeType.ADD
}

Now test the condition for publicly accessible projects:

import com.atlassian.sal.api.component.ComponentLocator
import com.atlassian.bitbucket.repository.Repository
import com.atlassian.bitbucket.permission.PermissionService

Repository repository = repository

def permissionService = ComponentLocator.getComponent(PermissionService)
permissionService.isPubliclyAccessible(repository.project)

Given that they both work, we can combine the two like so:

Repository repository = repository
Collection<RefChange> refChanges = refChanges

def permissionService = ComponentLocator.getComponent(PermissionService)
def isPublic = permissionService.isPubliclyAccessible(repository.project)

def isTagCreate = refChanges.any { it.ref.id.startsWith("refs/tags/") &&
    it.type == RefChangeType.ADD
}

return isPublic && isTagCreate

Condition Methods

There are a couple of methods that can be used in conditions which simplify writing conditions. They can be used either as though they were global functions, in which case they operate on whatever is in the binding, or as if they were methods of e.g. PullRequest. They are:

pathsMatch

boolean pathsMatch(String syntaxAndPattern)

This will return true if the push, pull request, or merge check etc contains changes in the specific path. The syntaxAndPattern argument has two possible forms: glob and regex.

The glob method takes ant globs (an Ant-style path matching method), for instance the following matches a push that contains java files in the directory some/path.

refChanges.pathsMatch("glob:some/path/**.java")

In a merge request, this will return true if any of the files modified are under ssh/keys:

mergeRequest.pullRequest.pathsMatch("glob:ssh/keys/**")
This will go through each commit and check if their are any changes in the specific path. Except for a pull request where only the changes from the diff are considered. This means if you rebase or merge in changes from the target branch into the source branch of a pull request to bring it up-to-date then these changes will be ignored as they are already in the branch you are merging to.

pathsMatch (against commits for pull requests)

boolean pathsMatch(String syntaxAndPattern, Iterable<Commit> commits)

This is the same as pathsMatch except that it will match against the suppplied commits for a pull request rather than changes in the diff. This can be useful when preventing sensitive files from being merged if they exist in the commit history of the pull request using merge checks.

commits are an Atlassian Commit.

In a merge request, this will return true if any of the files modified are under ssh/keys:

def pullRequest = mergeRequest.pullRequest
mergeRequest.pullRequest.pathsMatch("glob:ssh/keys/**", pullRequest.getCommits())

If using an event handler condition you should use:

def pullRequest = event.pullRequest
pullRequest.pathsMatch("glob:ssh/keys/**", pullRequest.getCommits())
If you click on the Expand examples link under the condition you will see various examples of how to pathsMatch against commits.

pathsMatch - collecting all changes that match a path (against ref changes only)

boolean pathsMatch(String syntaxAndPattern, Closure matchingChangesCollector)

This is the same as pathsMatch except that you now can collect the changes that match the specific path. After this you can perform some logic based on these changes. You need to pass in a matchingChangesCollector closure to pathsMatch that contains the logic you require.

For example using them to generate a hook error message to show users specifically what changes and paths were blocked in a push using the protect git references hook.

import com.atlassian.bitbucket.content.Change

def matchingChangesCollector = { Iterable<Change> matchingChanges ->
    hookMessage << "The following changes containing private ssh keys were blocked:\n"

    matchingChanges.each { change ->
        def message = "Id: ${shortId(change.contentId)}, Path: ${change.path.toString()}\n"

        hookMessage << message
    }
}

refChanges.pathsMatch("glob:id_rsa", matchingChangesCollector)

private String shortId(String contentId) {
    contentId.substring(0, 11)
}

Rather than appending to the hook error message you could also log the matched changes by using the following collector:

def matchingChangesCollector = { Iterable<Change> matchingChanges ->

    matchingChanges.each { change ->
        def message = "Change containing private ssh key was blocked > Id: ${shortId(change.contentId)}, Path: ${change.path.toString()}\n"

        log.info message
    }
}
The matchingChangesCollector closure will only be called once if the push contained at least one change which had a matching path.
You are limited by what you can do with the collector in a per repository hook due to the sandboxing on the condition which is explained here. The main use case is using the condition to build up diagnostic information which is still achievable in the sandboxed environment.

pathsMatchExcludingDeletes

boolean pathsMatchExcludingDeletes(String syntaxAndPattern)

Same as pathsMatch, except it will ignore deleted files. This can be useful when blocking certain file names using protect git refs, or via merge checks.

This will work in the same way for a pull request as pathsMatch but will ignore deleted files in the diff.

pathsMatchExcludingDeletes (against commits for pull requests)

boolean pathsMatchExcludingDeletes(String syntaxAndPattern, Iterable<Commit> commits)

Same as pathsMatch (against commits for pull requests), except it will ignore deleted files in the supplied commits for the pull request. This can be useful when preventing sensitive files from being merged if they exist in the commit history of the pull request using merge checks.

If you click on the Expand examples link under the condition you will see various examples of how to pathsMatchExcludingDeletes against commits.

pathsMatchExcludingDeletes - collecting all changes that match a path (against ref changes only)

boolean pathsMatchExcludingDeletes(String syntaxAndPattern, Closure matchingChangesCollector)

Same as pathsMatch (collecting all changes that match a path), except it will ignore deleted files. This can be useful when blocking certain file names using protect git refs, or via merge checks.

pathsMatcher

Currently this is only available for hook conditions.

Same as pathsMatch and pathsMatchExcludingDeletes, except it is more expressive about what changes are being excluded.

The exclusions are:

  • files that have been deleted

  • files that are already tracked by Git LFS

The example below shows how you can adapt your condition to use this.

def matchingChangesCollector = { Iterable<Change> matchingChanges -> }

pathsMatcher (1)
    .excludingDeletes() (2)
    .excludingLfsFiles() (3)
    .withMatchingChangesCollector(matchingChangesCollector) (4)
    .matches("glob:**.jar") (5)
1 use pathsMatcher which is provided by the condition in the binding
2 optionally exclude deletes so we get the same behaviour as pathsMatchExcludingDeletes
3 optionally exclude any files already tracked by Git LFS
4 optionally specify a matchingChangesCollector if we have a self-diagnosing condition (the provided example above does nothing)
5 specify our pattern to match on (in this case any file with a jar extension)
You can remove lines 2, 3 and 5 from the example above if they are not relevant to the changes you want to match against.

If you want to be explicit about what your including as well, there are methods available on pathsMatcher to perform inclusions. For example below we can include both deletes and LFS file changes.

pathsMatcher
    .includingDeletes()
    .includingLfsFiles()
    .matches("glob:**.jar")

getCommitAuthors

Similarly, you can get the commit authors for all changes in a pull request or a push using this:

Set<Person> getCommitAuthors()

For example, in an event handler where you are listening for an event related to pull requests:

event.getCommitAuthors()

This will return a Set of Person objects.

A Person may not actually correspond with a registered Bitbucket user.

Per Repository Hooks

Most, but not all, of the hooks, event handlers and merge checks can be used by project and repository admins. In this case, system administrators will be able to define a base policy that applies globally (or to specific groups of projects or repositories), and project and repo administrators can overlay that with their own specific policy.

For per repository hooks you don’t select which repositories to apply the hook to, it will only apply to the current repository. Custom scripts are not available to per repository hooks.

Per-repository hooks can be applied from Repository Settings, then select one of the links in the Workflow section.

Hooks are not available under the Hooks section as you might expect, because Bitbucket doesn’t allow multiple versions of the same hook to be applied. However, it is quite reasonable to for instance define multiple protect refs hooks, for example one to prevent deletion of release tags, and another to restrict creation of hotfix branches to a list of trusted users.

Conditions compile to in-memory classes. As project and repository admins generally do not have system admin permission, the condition code is restricted:

  • you can only call methods on a whitelist of certain Bitbucket API classes. The whitelist is sufficient that variations of the example code will run, but not much else will.

  • you cannot define methods.

  • certain other constructs are not allowed.

So, a hook that is defined by an admin and applied to a single repository, is functionally the same as a per-repository hook, other than that the condition code runs in a restricted sandboxed environment.

In addition, per-repository hook conditions are compiled statically, which will prevent usage of certain groovy tricks, such as:

("java.lang.System" as Class).exit()

Per-repository conditions are validated at the time that you create the item (event handler, hook, merge check etc). For example, an attempt to call System.exit(0) results in:

per repo condition fail
Hooks defined globally, even if they are applied to just one repository, do not have their condition checked at the time of creation. This is because to validate the code requires static typing, which removes much of the power from groovy.
Entering the same condition in a global hook will result in Bitbucket exiting, at the time that it’s invoked.

For how-to questions please ask on Atlassian Answers where there is a very active community. Adaptavist staff are also likely to respond there.

Ask a question about ScriptRunner for JIRA, for for Bitbucket Server, or for Confluence.