Full sources for all examples in this guide can be found in the Bitbucket Example Plugins repository.
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 Data Center. For instance, hooks are called to determine whether a pull request can be merged, when a branch is created from the browser or when a file is edited in the browser.
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.
Let's use a simple PostRepositoryHook
to explore the hooks mechanism. The following hook simply logs what changes
have been made.
1 2/** * Example hook that logs what changes have been made to a set of refs */ public class LoggingPostRepositoryHook implements PostRepositoryHook<RepositoryHookRequest> { private static final Logger log = LoggerFactory.getLogger(LoggingPostRepositoryHook.class); @Override public void postUpdate(@Nonnull PostRepositoryHookContext context, @Nonnull RepositoryHookRequest hookRequest) { log.info("[{}] {} updated [{}]", hookRequest.getRepository(), hookRequest.getTrigger().getId(), hookRequest.getRefChanges().stream() .map(change -> change.getRef().getId()) .collect(Collectors.joining(", "))); } }
atlassian-plugin.xml fragment
1 2<!-- Simple logging hook --> <repository-hook key="logging-hook" name="Logging Post Hook" i18n-name-key="hook.guide.logginghook.name" class="com.atlassian.bitbucket.server.examples.LoggingPostRepositoryHook"> <description key="hook.guide.logginghook.description" /> <icon>icons/example.png</icon> </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 the 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
1 22017-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]
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
1 2<repository-hook key="logging-hook" name="Logging Post Hook" i18n-name-key="hook.guide.logginghook.name" class="com.atlassian.bitbucket.server.examples.LoggingPostRepositoryHook"> <description key="hook.guide.logginghook.description" /> <icon>icons/example.png</icon> <scopes> <scope>project</scope> <scope>repository</scope> </scopes> </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.
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:
1 2/** * Hook that logs who created a new tag through the REST API */ public class TagCreationLoggingHook implements PostRepositoryHook<TagCreationHookRequest> { private static final Logger log = LoggerFactory.getLogger(TagCreationLoggingHook.class); private final AuthenticationContext authenticationContext; public TagCreationLoggingHook(AuthenticationContext authenticationContext) { this.authenticationContext = authenticationContext; } @Override public void postUpdate(@Nonnull PostRepositoryHookContext context, @Nonnull TagCreationHookRequest request) { ApplicationUser user = authenticationContext.getCurrentUser(); String username = user != null ? user.getName() : "<unknown>"; Tag tag = request.getTag(); log.info("[{}] {} created a new tag: {}, which references {}", request.getRepository(), username, tag.getDisplayId(), tag.getLatestCommit()); } }
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().
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.
1 2public class BranchInReviewHook implements PreRepositoryHook<RepositoryHookRequest> { private final I18nService i18nService; private final PullRequestService pullRequestService; public BranchInReviewHook(I18nService i18nService, PullRequestService pullRequestService) { this.i18nService = i18nService; this.pullRequestService = pullRequestService; } @Nonnull @Override public RepositoryHookResult preUpdate(@Nonnull PreRepositoryHookContext context, @Nonnull RepositoryHookRequest request) { // Find all refs that are about to be deleted Set<String> deletedRefIds = request.getRefChanges().stream() .filter(refChange -> refChange.getType() == RefChangeType.DELETE) .map(refChange -> refChange.getRef().getId()) .collect(Collectors.toSet()); if (deletedRefIds.isEmpty()) { // nothing is going to be deleted, no problem return RepositoryHookResult.accepted(); } // verify whether any of the refs are already in review PullRequestSearchRequest searchRequest = new PullRequestSearchRequest.Builder() .state(PullRequestState.OPEN) .fromRefIds(deletedRefIds) .fromRepositoryId(request.getRepository().getId()) .build(); Page<PullRequest> found = pullRequestService.search(searchRequest, PageUtils.newRequest(0, 1)); if (found.getSize() > 0) { // found at least 1 open pull request from one of the refs that are about to be deleted PullRequest pullRequest = found.getValues().iterator().next(); return RepositoryHookResult.rejected( i18nService.getMessage("hook.guide.branchinreview.summary"), i18nService.getMessage("hook.guide.branchinreview.details", pullRequest.getFromRef().getDisplayId(), pullRequest.getId())); } return RepositoryHookResult.accepted(); } }
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:
1 2return new RepositoryHookResult.Builder() .veto("summary 1", "details 1") .veto("summary 2", "details 2") .build();
Here's the output for the BranchInReviewHook
:
1 2~/tmp/example (feature/PROJECT-1234-perform-magic-tricks ✔) ᐅ git push origin :feature/PROJECT-1234-perform-magic-tricks remote: Branch is in review remote: Offending branch: feature/PROJECT-1234-perform-magic-tricks is in review in pull request #5 and cannot be deleted To ssh://bitbucket.dev.local:7999/bb/example.git ! [remote rejected] feature/PROJECT-1234-perform-magic-tricks (pre-receive hook declined) error: failed to push some refs to 'ssh://git@bitbucket.dev.local:7999/bb/example.git'
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:
1 2/** * Hook that logs when a user performs a force-push */ public class ForcePushLoggingHook implements PostRepositoryHook<RepositoryPushHookRequest> { private static final Logger log = LoggerFactory.getLogger(ForcePushLoggingHook.class); @Override public void postUpdate(@Nonnull PostRepositoryHookContext context, @Nonnull RepositoryPushHookRequest request) { // Only an UPDATE can be a force push, so ignore ADD and DELETE changes Map<String, RefChange> updates = request.getRefChanges().stream() .filter(refChange -> refChange.getType() == RefChangeType.UPDATE) .collect(Collectors.toMap( refChange -> refChange.getRef().getId(), refChange -> refChange)); if (!updates.isEmpty()) { // register a callback to receive any commit that was removed from any ref. This hook is // not interested in newly introduced commits, so it only registers for removed commits. context.registerCommitCallback( new ForcePushDetectingCallback(request.getRepository(), updates), RepositoryHookCommitFilter.REMOVED_FROM_ANY_REF); } } private static class ForcePushDetectingCallback implements RepositoryHookCommitCallback { private final Repository repository; private final Map<String, RefChange> refChangeById; private ForcePushDetectingCallback(Repository repository, Map<String, RefChange> refChangeById) { this.repository = repository; this.refChangeById = refChangeById; } @Override public boolean onCommitRemoved(@Nonnull CommitRemovedDetails commitDetails) { // The callback may be called for a commit that was removed from the repository when a branch is // deleted. Check whether the provided ref is updated before logging MinimalRef ref = commitDetails.getRef(); // Remove the change because each change should be logged once, even if multiple commits were removed RefChange change = refChangeById.remove(ref.getId()); if (change != null) { forcePushes.add(change); } // The hook only needs to receive more commits if there are RefChanges that have not yet been logged return !refChangeById.isEmpty(); } // onEnd is called after the last commit has been provided @Override public void onEnd() { forcePushes.forEach(change -> log.warn("[{}] {} was force pushed from {} to {}", repository, change.getRef().getDisplayId(), change.getFromHash(), change.getToHash())); } }
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:
1 2/** * Hook that blocks any newly introduced commits that have "Work in progress" in the commit message */ public class WorkInProgressHook implements PreRepositoryHook<RepositoryHookRequest> { @Nonnull @Override public RepositoryHookResult preUpdate(@Nonnull PreRepositoryHookContext context, @Nonnull RepositoryHookRequest request) { // hook only wants commits added to the repository context.registerCommitCallback( new WorkInProgressCallback(), RepositoryHookCommitFilter.ADDED_TO_REPOSITORY); // return accepted() here, the callback gets a chance to reject the change when getResult() is called return RepositoryHookResult.accepted(); } private static class WorkInProgressCallback implements PreRepositoryHookCommitCallback { private RepositoryHookResult result = RepositoryHookResult.accepted(); @Nonnull @Override public RepositoryHookResult getResult() { return result; } @Override public boolean onCommitAdded(@Nonnull CommitAddedDetails commitDetails) { Commit commit = commitDetails.getCommit(); String message = commit.getMessage().toLowerCase(); if (message.startsWith("work in progress")) { // use the i18nService to internationalize the messages for public plugins // (that are published to the marketplace) result = RepositoryHookResult.rejected( "Don't push 'in progress' commits!", "Offending commit " + commit.getId() + " on " + commitDetails.getRef().getDisplayId()); // this will block the change, so no need to inspect further commits return false; } return true; } } }
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
:
1 2<!-- Hook that logs all tags created through the REST API. This hook is marked configurable="false" to enable it globally. The hook won't be listed in Repository > Settings > Hooks and cannot be disabled --> <repository-hook key="tag-creation-hook" name="Tag Creation Logging Hook" configurable="false" class="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 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.
1 2public class EnforceApprovalsMergeCheck implements RepositoryMergeCheck { /** * Vetoes a pull-request if there aren't enough approvals. */ @Nonnull @Override public RepositoryHookResult preUpdate(@Nonnull PreRepositoryHookContext context, @Nonnull PullRequestMergeHookRequest request) { int requiredApprovals = context.getSettings().getInt("approvals", 0); int acceptedCount = 0; for (PullRequestParticipant reviewer : request.getPullRequest().getReviewers()) { acceptedCount = acceptedCount + (reviewer.isApproved() ? 1 : 0); } if (acceptedCount < requiredApprovals) { return RepositoryHookResult.rejected("Not enough approved reviewers", acceptedCount + " reviewers have approved your pull request. You need " + requiredApprovals + " (total) before you may merge."); } return RepositoryHookResult.accepted(); } }
1 2<repository-merge-check key="enforce-approvals" name="Enforce Approvals" class="com.atlassian.bitbucket.server.examples.EnforceApprovalsMergeCheck"> <description> Enforces that pull requests must have a minimum number of acceptances before they can be merged. </description> <icon>icons/example.png</icon> <scopes> <scope>project</scope> <scope>repository</scope> </scopes> <config-form name="Simple Hook Config" key="simpleHook-config"> <view>hook.guide.example.hook.simple.formContents</view> <directory location="/static/"/> </config-form> <!-- Validators can be declared separately --> <validator>com.atlassian.bitbucket.server.examples.ApprovalValidator</validator> </repository-merge-check>
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 some of Google's documentation may not be applicable.
Bitbucket Data Center provides
bitbucket.component.branchSelector
field and input templates that you can include to let users select a branch or tag.
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:
1 2{namespace hook.guide.example.hook.simple} /** * @param config * @param? errors */ {template .formContents} {call aui.form.textField} {param id: 'approvals' /} {param value: $config['approvals'] /} {param labelContent} {getText('hook.guide.config.label')} {/param} {param description: getText('hook.guide.config.description') /} {param extraClasses: 'long' /} {param errorTexts: $errors ? $errors['approvals'] : null /} {/call} {/template}
The form defines a single input field approvals. The configured value is available in the hook through
context.getSettings().getInt("approvals", 0)
.
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:
1 2public class ApprovalValidator implements SettingsValidator { @Override public void validate(@Nonnull Settings settings, @Nonnull SettingsValidationErrors errors, @Nonnull Scope scope) { try { if (settings.getInt("approvals", 0) <= 0) { errors.addFieldError("approvals", "Number of approvals must be greater than zero"); } } catch (NumberFormatException e) { errors.addFieldError("approvals", "Number of approvals must be a number"); } } }
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)
.
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()
.
1 2/** * Hook that writes a warning to the git client when it detects a push to the master branch */ @SynchronousPreferred(asyncSupported = false) public class PushToMasterWarnHook implements PostRepositoryHook<RepositoryPushHookRequest> { @Override public void postUpdate(@Nonnull PostRepositoryHookContext context, @Nonnull RepositoryPushHookRequest request) { request.getScmHookDetails().ifPresent(scmDetails -> { request.getRefChanges().forEach(refChange -> { if ("refs/heads/master".equals(refChange.getRef().getId()) && refChange.getType() == RefChangeType.UPDATE) { scmDetails.out().println("You should create a pull request and " + "get some input from your team mates!"); } }); }); } }
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:
1 2~/tmp/example (master ✔) ᐅ git push origin master Counting objects: 3, done. Delta compression using up to 8 threads. Compressing objects: 100% (2/2), done. Writing objects: 100% (3/3), 281 bytes | 0 bytes/s, done. Total 3 (delta 1), reused 0 (delta 0) remote: You should create a pull request and get some input from your team mates! To ssh://bitbucket.dev.local:7999/bb/example.git d5730ac..191d0c4 master -> master
Rate this page: