Repository Hooks and Merge Checks Guide
Repository hooks and merge checks can be used to enforce policies on your commits and branches, or to trigger actions after a change has been made to a branch or a tag. Bitbucket's repository hooks are integrated with the git hook system, and allows plugins to validate and possibly reject pushes. In addition, repository hooks are called whenever an update to a branch or tag is made by Bitbucket Server. For instance, hooks are called to determine whether a pull request can be merged, when a branch is created from browser or when a file is edited in the browser.
Hook types
There are three types of hooks: PreRepositoryHook
, RepositoryMergeCheck
and PostRepositoryHook
. As the name implies, a PreRepositoryHook
is called just before a change is made to one or more refs (branches and/or tags). The hook receives information about the proposed change, can then verify whether the changes should be allowed and choose to accept or reject the change. RepositoryMergeCheck
is a specialization of PreRepositoryHook
that is only called for pull request merges.
A PostRepositoryHook
is invoked after the refs have been updated and receives the same information about the now completed change. The hook can use this information to perform further processing or notify an external system.
A basic example
Let's use a simple PostRepositoryHook
to explore the hooks mechanism. The following hook simply logs what changes have been made.
x1/**
2* Example hook that logs what changes have been made to a set of refs
3*/
4public class LoggingPostRepositoryHook implements PostRepositoryHook<RepositoryHookRequest> {
5
6private static final Logger log = LoggerFactory.getLogger(LoggingPostRepositoryHook.class);
7
8
9public void postUpdate( PostRepositoryHookContext context,
10RepositoryHookRequest hookRequest) {
11log.info("[{}] {} updated [{}]",
12hookRequest.getRepository(),
13hookRequest.getTrigger().getId(),
14hookRequest.getRefChanges().stream()
15.map(change -> change.getRef().getId())
16.collect(Collectors.joining(", ")));
17}
18}
atlassian-plugin.xml fragment
71<!-- Simple logging hook -->
2<repository-hook key="logging-hook" name="Logging Post Hook"
3i18n-name-key="hook.guide.logginghook.name"
4class="com.atlassian.bitbucket.server.examples.LoggingPostRepositoryHook">
5<description key="hook.guide.logginghook.description" />
6<icon>icons/example.png</icon>
7</repository-hook>
The example shows a PostRepositoryHook
, which means that it only gets called after one or more refs have been updated. The hook logs the repository name, what triggered the change (e.g. 'push' or 'pull-request-merge') and the list of branches or tags that were updated. As can be seen from the example, all information about change is available from the RepositoryHookRequest
. The PostRepositoryHookContext
provides the hook settings, if the hook is configurable.
Finally, the hook can register a callback with the context if it needs to inspect the commits that were added or removed as part of the change.
By defining the repository-hook
element in atlassian-plugin.xml
, the hook is added to Repository > Settings > Hooks, where it can be enabled.
Here's the logging output from pushing a change to a single branch
2017-04-26 13:46:02,761 INFO [AtlassianEvent::thread-2] admin @QJIZ9x826x475x0 1d63qb0 127.0.0.1 SSH - git-receive-pack '/bb/example.git' c.a.b.s.e.LoggingPostRepositoryHook [BB/example[1]] push updated [refs/heads/feature/PROJECT-1234-perform-magic-tricks]
Hook scopes
As of 5.2, there are two scopes a hook can be applied to: project
and repository
. Scopes determine which levels a hook can be enabled and configured at, although all hooks only fire for repository level events (ref changes and pull request merges). At the project level an admin can set a hook to enabled or disabled. However, at the repository level hooks can be set to enabled, disabled, or inherited. If a repository is set to inherited then it will automatically use the project's state (enabled or disabled) and configuration when the hook is run. (Note that in order to maintain backwards compatibility if a hook doesn't have any scopes defined it will fall back to repository
level scoping.)
Let's continue with the example.
atlassian-plugin.xml fragment
101<repository-hook key="logging-hook" name="Logging Post Hook"
2i18n-name-key="hook.guide.logginghook.name"
3class="com.atlassian.bitbucket.server.examples.LoggingPostRepositoryHook">
4<description key="hook.guide.logginghook.description" />
5<icon>icons/example.png</icon>
6<scopes>
7<scope>project</scope>
8<scope>repository</scope>
9</scopes>
10</repository-hook>
We have updated the Logging Post Hook example to include scopes at both the project
and repository
level. The hook will work the same way it did before, but project admins will now be able to enable and configure the hook at the project level. Note that the project level configuration does not override the repository-level configuration, but instead allows the repository admin to inherit the configuration from the project if they wish to do so.
We can navigate to Project > Settings > Hooks and see the hook is configurable at the project level, but is initially disabled.
Repository admins can also enable and configure the hook by going to Repository > Settings > Hooks. When a hook is first installed it is initially set to inherit the project's settings (effectively disabled). However, repository admins can choose to enable the hook at the repository level (and overwrite any project level configuration that had been set), or disable at the repository level (and ignore the project level setting).
Note: If a hook is missing a particular scope it will still display on the hooks configuration page, but will be disabled. Here is the project settings page when the hook only has the repository
scope.
Choosing hook trigger(s)
LoggingPostRepositoryHook
in the previous example implements PostRepositoryHook<RepositoryHookRequest>
, which means that it gets invoked after one or more refs have been updated. Hooks can select when they should be called by choosing the type of hook request they respond to. The example hook is a hook of RepositoryHookRequest
, which is the top-level request interface. As a result, the hook will be called for all triggers such as pushes, pull request merges, in-browser file edits, etc. If a hook is only interested in a specific trigger, the hook can pick the corresponding request type. For instance, a PostRepositoryHook<RepositoryPushHookRequest>
will only be called for pushes. If the hook needs to be called for in multiple triggers, it should use the generic RepositoryHookRequest
and check RepositoryHookRequest.getTrigger
to decide whether to respond to the hook invocation or not.
The following triggers are supported (see StandardRepositoryHookTrigger
):
Trigger | Corresponding request type | Description |
---|---|---|
BRANCH_CREATE |
BranchCreationHookRequest |
Called when a branch is created from the browser or REST API |
BRANCH_DELETE |
BranchDeletionHookRequest |
Called when a branch is deleted from the browser or REST API |
FILE_EDIT |
FileEditHookRequest |
Called when a file is edited or created in the browser or REST API |
MERGE |
MergeHookRequest |
Low-level hook that is called when a branch is merged by the system, such as automatic branch merging. Most hooks should not attempt to block these types of requests. |
PULL_REQUEST_MERGE |
PullRequestMergeHookRequest |
Called to check whether a pull request can be merged (as a dryRun request) and when a pull request is actually merged |
REPO_PUSH |
RepositoryPushHookRequest |
Called when a client pushes changes to the repository |
TAG_CREATE |
TagCreationHookRequest |
Called when a tag is created from the browser or REST API |
TAG_DELETE |
TagDeletionHookRequest |
Called when a tag is deleted from the browser or REST API |
UNKNOWN |
? | Called when a change is detected for which the trigger is not known, as a result of a plugin raising a RepositoryRefsChangedEvent . There is no specific request type for this trigger. Instead, the generic RepositoryHookRequest is used. |
Here's an example hook that only logs newly created tags:
231/**
2* Hook that logs who created a new tag through the REST API
3*/
4public class TagCreationLoggingHook implements PostRepositoryHook<TagCreationHookRequest> {
5
6private static final Logger log = LoggerFactory.getLogger(TagCreationLoggingHook.class);
7
8private final AuthenticationContext authenticationContext;
9
10public TagCreationLoggingHook(AuthenticationContext authenticationContext) {
11this.authenticationContext = authenticationContext;
12}
13
14
15public void postUpdate( PostRepositoryHookContext context,
16TagCreationHookRequest request) {
17ApplicationUser user = authenticationContext.getCurrentUser();
18String username = user != null ? user.getName() : "<unknown>";
19Tag tag = request.getTag();
20log.info("[{}] {} created a new tag: {}, which references {}",
21request.getRepository(), username, tag.getDisplayId(), tag.getLatestCommit());
22}
23}
TagCreationLoggingHook
is a hook for TagCreationHookRequest
and will only be called when a tag is created using the REST API. TagCreationHookRequest
makes the newly created tag easily available through getTag().
Blocking changes
So far, the examples have been focused on PostRepositoryHook
. Many hooks however enforce some policy and need to be able to prevent changes from being made. The following example shows a PreRepositoryHook
that prevents the deletion of branches that have open pull requests.
461public class BranchInReviewHook implements PreRepositoryHook<RepositoryHookRequest> {
2
3private final I18nService i18nService;
4private final PullRequestService pullRequestService;
5
6public BranchInReviewHook(I18nService i18nService, PullRequestService pullRequestService) {
7this.i18nService = i18nService;
8this.pullRequestService = pullRequestService;
9}
10
11
12
13public RepositoryHookResult preUpdate( PreRepositoryHookContext context,
14RepositoryHookRequest request) {
15
16// Find all refs that are about to be deleted
17Set<String> deletedRefIds = request.getRefChanges().stream()
18.filter(refChange -> refChange.getType() == RefChangeType.DELETE)
19.map(refChange -> refChange.getRef().getId())
20.collect(Collectors.toSet());
21
22if (deletedRefIds.isEmpty()) {
23// nothing is going to be deleted, no problem
24return RepositoryHookResult.accepted();
25}
26
27// verify whether any of the refs are already in review
28PullRequestSearchRequest searchRequest = new PullRequestSearchRequest.Builder()
29.state(PullRequestState.OPEN)
30.fromRefIds(deletedRefIds)
31.fromRepositoryId(request.getRepository().getId())
32.build();
33
34Page<PullRequest> found = pullRequestService.search(searchRequest, PageUtils.newRequest(0, 1));
35if (found.getSize() > 0) {
36// found at least 1 open pull request from one of the refs that are about to be deleted
37PullRequest pullRequest = found.getValues().iterator().next();
38return RepositoryHookResult.rejected(
39i18nService.getMessage("hook.guide.branchinreview.summary"),
40i18nService.getMessage("hook.guide.branchinreview.details",
41pullRequest.getFromRef().getDisplayId(), pullRequest.getId()));
42}
43
44return RepositoryHookResult.accepted();
45}
46}
This hook demonstrates how a PreRepositoryHook
can block a change from being made. Just like the PostRepositoryHook
, the preUpdate
method is called with the RepositoryHookRequest
which provides information about the proposed change and a PreRepositoryHookContext
which provides the optional settings. Where the postUpdate
method just received information about the change, the preUpdate
method needs to return a RepositoryHookResult
to instruct the system to accept or reject the proposed change. If the hook rejects the change, the system uses the provided messages to provide feedback to the user, either in the browser or on the command line.
In the example, the helper method accepted()
and rejected(String summary, String details)
are used to construct the result. A hook can return multiple veto messages:
41return new RepositoryHookResult.Builder()
2.veto("summary 1", "details 1")
3.veto("summary 2", "details 2")
4.build();
Here's the output for the BranchInReviewHook
:
71~/tmp/example (feature/PROJECT-1234-perform-magic-tricks ✔) ᐅ git push origin :feature/PROJECT-1234-perform-magic-tricks
2
3remote: Branch is in review
4remote: Offending branch: feature/PROJECT-1234-perform-magic-tricks is in review in pull request #5 and cannot be deleted
5To ssh://bitbucket.dev.local:7999/bb/example.git
6! [remote rejected] feature/PROJECT-1234-perform-magic-tricks (pre-receive hook declined)
7error: failed to push some refs to 'ssh://git@bitbucket.dev.local:7999/bb/example.git'
Validating commits
Many hooks not only need to validate the branches and tags that are updated but also need to validate the commits that are about to be added or removed. Examples are hooks that verify the committer or the commit message. Bitbucket's hook API allows hooks to register callbacks for these commit details. The system then determines which commits were added or removed for each updated branch and tag and notifies all registered callbacks.
Here's an example hook that logs a warning for each detected force-push:
601/**
2* Hook that logs when a user performs a force-push
3*/
4public class ForcePushLoggingHook implements PostRepositoryHook<RepositoryPushHookRequest> {
5
6private static final Logger log = LoggerFactory.getLogger(ForcePushLoggingHook.class);
7
8
9public void postUpdate( PostRepositoryHookContext context,
10RepositoryPushHookRequest request) {
11
12// Only an UPDATE can be a force push, so ignore ADD and DELETE changes
13Map<String, RefChange> updates = request.getRefChanges().stream()
14.filter(refChange -> refChange.getType() == RefChangeType.UPDATE)
15.collect(Collectors.toMap(
16refChange -> refChange.getRef().getId(),
17refChange -> refChange));
18
19if (!updates.isEmpty()) {
20// register a callback to receive any commit that was removed from any ref. This hook is
21// not interested in newly introduced commits, so it only registers for removed commits.
22context.registerCommitCallback(
23new ForcePushDetectingCallback(request.getRepository(), updates),
24RepositoryHookCommitFilter.REMOVED_FROM_ANY_REF);
25}
26}
27
28private static class ForcePushDetectingCallback implements RepositoryHookCommitCallback {
29
30private final Repository repository;
31private final Map<String, RefChange> refChangeById;
32
33private ForcePushDetectingCallback(Repository repository,
34Map<String, RefChange> refChangeById) {
35this.repository = repository;
36this.refChangeById = refChangeById;
37}
38
39
40public boolean onCommitRemoved( CommitRemovedDetails commitDetails) {
41// The callback may be called for a commit that was removed from the repository when a branch is
42// deleted. Check whether the provided ref is updated before logging
43MinimalRef ref = commitDetails.getRef();
44// Remove the change because each change should be logged once, even if multiple commits were removed
45RefChange change = refChangeById.remove(ref.getId());
46if (change != null) {
47forcePushes.add(change);
48}
49// The hook only needs to receive more commits if there are RefChanges that have not yet been logged
50return !refChangeById.isEmpty();
51}
52
53// onEnd is called after the last commit has been provided
54
55public void onEnd() {
56forcePushes.forEach(change ->
57log.warn("[{}] {} was force pushed from {} to {}", repository,
58change.getRef().getDisplayId(), change.getFromHash(), change.getToHash()));
59}
60}
The ForcePushLoggingHook
registers a callback for REMOVED_FROM_ANY_REF
commits. For each updated branch or tag, Bitbucket will determine which commits have been removed from the branch or tag and provide it to the registered callback. The CommitRemovedDetails
specifies what commit (getCommit()
) was removed from which ref (getRef()
) and whether the commit was completely removed from the repository (isRemovedFromRepository()
).
The onCommitRemoved
method returns a boolean that indicates whether the callback wants to receive more commits, if available. Returning false
when the hook is done allows Bitbucket to stop streaming commits early. RepositoryHookCommitCallback
also provides a onCommitAdded(CommitAddedDetails commitDetails)
method, but this example hook is not interested in added commits, so it does not implement the onCommitAdded
method.
When registering a callback for commit details, one or more of the following filters can be provided:
Filter | Description |
---|---|
ADDED_TO_ANY_REF |
Any commit added to any ref. This includes commits that were newly added to the repository, but also commits that were already on another branch, but are now added to a branch or tag |
ADDED_TO_REPOSITORY |
Any commit that is newly added to the repository |
REMOVED_FROM_ANY_REF |
Any commit removed from any ref. This includes commits that were completely removed from the repository, but also commits that are still on another branch. |
REMOVED_FROM_REPOSITORY |
Any commit that is completely removed from the repository |
If a commit is added to or removed from multiple branches in a single change, the callback will receive the commit details multiple times; once for relevant branch.
PreReceiveHooks
can also register callbacks to inspect commits and reject the change if they don't meet the hook's policy. As an example, here's a hook that rejects any push that contains commits that have the "Work in progress" as their commit message:
461/**
2* Hook that blocks any newly introduced commits that have "Work in progress" in the commit message
3*/
4public class WorkInProgressHook implements PreRepositoryHook<RepositoryHookRequest> {
5
6
7public RepositoryHookResult preUpdate( PreRepositoryHookContext context,
8RepositoryHookRequest request) {
9
10// hook only wants commits added to the repository
11context.registerCommitCallback(
12new WorkInProgressCallback(),
13RepositoryHookCommitFilter.ADDED_TO_REPOSITORY);
14
15// return accepted() here, the callback gets a chance to reject the change when getResult() is called
16return RepositoryHookResult.accepted();
17}
18
19private static class WorkInProgressCallback implements PreRepositoryHookCommitCallback {
20
21private RepositoryHookResult result = RepositoryHookResult.accepted();
22
23
24
25public RepositoryHookResult getResult() {
26return result;
27}
28
29
30public boolean onCommitAdded( CommitAddedDetails commitDetails) {
31Commit commit = commitDetails.getCommit();
32String message = commit.getMessage().toLowerCase();
33if (message.startsWith("work in progress")) {
34// use the i18nService to internationalize the messages for public plugins
35// (that are published to the marketplace)
36result = RepositoryHookResult.rejected(
37"Don't push 'in progress' commits!",
38"Offending commit " + commit.getId() + " on " + commitDetails.getRef().getDisplayId());
39// this will block the change, so no need to inspect further commits
40return false;
41}
42
43return true;
44}
45}
46}
Global Hooks
Hooks are listed on the Repository > Settings > Hooks page where they can be enabled or disabled (and configured) on a per-repository basis. However, some hooks need to be enabled for all repositories, without the option of disabling them. This can be achieved by adding the configurable="false"
attribute to the repository-hook
element in atlassian-plugin.xml
:
41<!-- Hook that logs all tags created through the REST API. This hook is marked configurable="false" to
2enable it globally. The hook won't be listed in Repository > Settings > Hooks and cannot be disabled -->
3<repository-hook key="tag-creation-hook" name="Tag Creation Logging Hook" configurable="false"
4class="com.atlassian.bitbucket.server.examples.TagCreationLoggingHook" />
Hooks marked as configurable="false"
are enabled for all repositories and will not be listed on Repository > Settings > Hooks.
Merge checks
Merge checks are special hooks that only target pull request merges. There's a special RepositoryMergeCheck
interface to save you some typing and merge checks should be registered as <repository-merge-check>
in atlassian-plugin.xml
but other than that, they work identically to the regular PreReceiveHook
.
Registering merge checks as repository-merge-check
allows Bitbucket to display all merge checks in the "Merge checks" section of the project and repository settings.
221public class EnforceApprovalsMergeCheck implements RepositoryMergeCheck {
2
3/**
4* Vetoes a pull-request if there aren't enough approvals.
5*/
6
7
8public RepositoryHookResult preUpdate( PreRepositoryHookContext context,
9PullRequestMergeHookRequest request) {
10int requiredApprovals = context.getSettings().getInt("approvals", 0);
11int acceptedCount = 0;
12for (PullRequestParticipant reviewer : request.getPullRequest().getReviewers()) {
13acceptedCount = acceptedCount + (reviewer.isApproved() ? 1 : 0);
14}
15if (acceptedCount < requiredApprovals) {
16return RepositoryHookResult.rejected("Not enough approved reviewers", acceptedCount +
17" reviewers have approved your pull request. You need " + requiredApprovals +
18" (total) before you may merge.");
19}
20return RepositoryHookResult.accepted();
21}
22}
171<repository-merge-check key="enforce-approvals" name="Enforce Approvals"
2class="com.atlassian.bitbucket.server.examples.EnforceApprovalsMergeCheck">
3<description>
4Enforces that pull requests must have a minimum number of acceptances before they can be merged.
5</description>
6<icon>icons/example.png</icon>
7<scopes>
8<scope>project</scope>
9<scope>repository</scope>
10</scopes>
11<config-form name="Simple Hook Config" key="simpleHook-config">
12<view>hook.guide.example.hook.simple.formContents</view>
13<directory location="/static/"/>
14</config-form>
15<!-- Validators can be declared separately -->
16<validator>com.atlassian.bitbucket.server.examples.ApprovalValidator</validator>
17</repository-merge-check>
Adding configuration
It is possible to add configuration to your hook by specifying a configuration screen. The values of input elements defined in the screen will be saved by the framework and passed to the hook each time it is called. Generally we use AUI templates to generate our controls to keep styling the same, however a simple element <input name="x" type="text"/>
would also work.
Hook configuration screens can be defined using Closure Templates. Atlassian is currently running an older version of Closure Templates, so you might find the best documentation in the Internet Archive.
Bitbucket Server provides bitbucket.component.branchSelector
field and input templates that you can include to let users select a branch or tag.
Atlassian User Interface (AUI)
A number of AUI Soy templates are also available to assist in creating a configuration form for your hook. For more information about AUI please read the AUI Docs and try out the live demos in the sandbox. For an a list of available AUI Soy templates that you can use in your own configuration form, consult the AUI Soy source.
Here is the template for the EnforceApprovalsMergeCheck
from the Merge Checks section:
181{namespace hook.guide.example.hook.simple}
2
3/**
4* @param config
5* @param? errors
6*/
7{template .formContents}
8{call aui.form.textField}
9{param id: 'approvals' /}
10{param value: $config['approvals'] /}
11{param labelContent}
12{getText('hook.guide.config.label')}
13{/param}
14{param description: getText('hook.guide.config.description') /}
15{param extraClasses: 'long' /}
16{param errorTexts: $errors ? $errors['approvals'] : null /}
17{/call}
18{/template}
The form defines a single input field approvals. The configured value is available in the hook through context.getSettings().getInt("approvals", 0)
.
Validating hook configuration
The configuration that's entered in on the hook configuration dialog often needs to be validated before it's saved. This can be done by adding a <validator>
element to the repository-hook
or repository-merge-check
in atlassian-plugin.xml
. In the EnforceApprovalsMergeCheck
, the following validator is used:
131public class ApprovalValidator implements SettingsValidator {
2
3
4public void validate( Settings settings, SettingsValidationErrors errors, Scope scope) {
5try {
6if (settings.getInt("approvals", 0) <= 0) {
7errors.addFieldError("approvals", "Number of approvals must be greater than zero");
8}
9} catch (NumberFormatException e) {
10errors.addFieldError("approvals", "Number of approvals must be a number");
11}
12}
13}
Advanced Topics
Synchronous PostReceiveHooks
PostReceiveHook.postUpdate
is usually invoked asynchronously a short time after the change has been completed. As a result, the postUpdate
handling is non-blocking; the client does not have to wait for the hooks to complete their postUpdate
processing. Sometimes, it is necessary to run synchronously. This is currently only supported for pushes by annotating the PostReceiveHook
with @SynchronousPreferred
. Such hooks will be invoked synchronously when possible (e.g. for pushes), and asynchronously when it is not.
Hooks that should only be invoked synchronously, should be annotated with @SynchronousPreferred(asyncSupported = false)
.
Writing to the git client output
RepositoryHookRequest
provides access to the git client output and error streams through getScmHookDetails()
. This will only return a value for pushes. For all other triggers, this will return empty()
.
201/**
2* Hook that writes a warning to the git client when it detects a push to the master branch
3*/
4asyncSupported = false) (
5public class PushToMasterWarnHook implements PostRepositoryHook<RepositoryPushHookRequest> {
6
7
8public void postUpdate( PostRepositoryHookContext context,
9RepositoryPushHookRequest request) {
10request.getScmHookDetails().ifPresent(scmDetails -> {
11request.getRefChanges().forEach(refChange -> {
12if ("refs/heads/master".equals(refChange.getRef().getId()) &&
13refChange.getType() == RefChangeType.UPDATE) {
14scmDetails.out().println("You should create a pull request and " +
15"get some input from your team mates!");
16}
17});
18});
19}
20}
This hook will only be invoked synchronously because it is marked @SynchronousPreferred(asyncSupported = false)
. Furthermore, it will only be called for pushes because it implements PostRepositoryHook<RepositoryPushEvent>
.
Example output:
91~/tmp/example (master ✔) ᐅ git push origin master
2Counting objects: 3, done.
3Delta compression using up to 8 threads.
4Compressing objects: 100% (2/2), done.
5Writing objects: 100% (3/3), 281 bytes | 0 bytes/s, done.
6Total 3 (delta 1), reused 0 (delta 0)
7remote: You should create a pull request and get some input from your team mates!
8To ssh://bitbucket.dev.local:7999/bb/example.git
9d5730ac..191d0c4 master -> master