Controlling when pull requests can be merged

Overview

Teams using Stash will often want to restrict when pull requests can be merged to a branch to align their use of pull requests with their established development process. For example teams that want to ensure a high degree of coverage by reviewers and foster knowledge sharing may want to require that a pull request has a certain number of reviewers before it can be merged. Other teams may want to enforce a quality gate on merging to require that a Bamboo build on the source branch has passed or that a set of code metrics have been satisfied before merging.

Stash supports this through Merge Request Check Plugin Modules.

Repository merge checks

Stash supports a simplified API for hooks that require per-repository configuration. Pre-receive, post-receive and merge-check hooks can all be written in this style. See Repository Hooks and the corresponding tips.

Goals

This tutorial will take you through the steps required to write a plugin containing a Merge Request Check which is designed to prevent pull requests from being merged unless the user performing the merge is an administrator for the target repository.

After completing this tutorial you will have learnt to do the following:

  • Generate a Stash plugin using Atlassian SDK
  • Implement a merge request check in Java
  • Declare a merge request check in your plugin's atlassian-plugin.xml
  • Use various Stash services and the merge request check API
  • Internationalize the messages your plugin emits

Steps

Generate a Stash plugin using the Atlassian SDK

Ensure you have installed the Atlassian SDK on your system as described in this tutorial. Once installed, create a Stash plugin project by running atlas-create-stash-plugin and supplying the following values when prompted:

groupIdartifactIdversionpackage
com.mycompanyis-admin-merge-check1.0-SNAPSHOTcom.mycompany.stash.merge.checks

Prepare your project and delete boilerplate code

Remove the <servlet/> element from src/resources/atlassian-plugin.xml (because we deleted the Servlet class above)

Now you have a basic Stash plugin project with the following files and folders:

  • LICENSE
  • pom.xml
  • README
  • src/main/java/com/mycompany/stash/merge/checks
  • src/main/resources/atlassian-plugin.xml
  • src/test/java/it/com/mycompany/stash/merge/checks/

Merge request checks are part of Stash's SPI (its service provider interface) so you will need to add this as a dependency to your project's pom.xml as follows:

<dependencies>
    ...
    <dependency>
        <groupId>com.atlassian.bitbucket.server</groupId>
        <artifactId>bitbucket-spi</artifactId>
        <version>${bitbucket.version}</version>
        <scope>provided</scope>
    </dependency>
    ...
</dependencies>

Create a merge request check Java class

In order to implement a merge request check you will first need to create a Java class that implements the interface com.atlassian.stash.scm.pull.MergeRequestCheck. This check will ensure that whoever is merging the pull request is an administrator of the target repository so let's call it IsAdminMergeCheck:

package com.mycompany.stash.merge.checks;

import com.atlassian.stash.scm.pull.MergeRequest;
import com.atlassian.stash.scm.pull.MergeRequestCheck;

import javax.annotation.Nonnull;

public class IsAdminMergeCheck implements MergeRequestCheck {
    @Override
    public void check(@Nonnull MergeRequest request) {
        //TODO: implement me
    }
}

The MergeRequestCheck interface has a single method check(MergeRequest) that you must implement. This method is called when Stash is either in the process of handling a request to merge a pull request or it wants to display to the user whether the pull request can be merged. The argument to this method is a MergeRequest object. A MergeRequest encapsulates the current request to merge and allows you to retrieve the pull request via getPullRequest() and to veto the merge via veto(String summaryMessage, String detailedMessage). If your check method does not call veto(...) before it returns then Stash will let the merge proceed (that is, as long as no other merge request check calls veto(...) and there are no merge conflicts).

So let's go ahead and implement the check(MergeRequest) method.

Since our check wants to ensure only users with administrative permissions for the pull request's target repository can merge, we will need to ask Stash if the current user has such permission. Stash's PermissionService can answer this question for us so let's add that to the class: add a constructor to IsAdminMergeCheck with a single parameter of type PermissionService and assign it a field permissionService. This constructor argument will be injected by Stash at runtime when your merge request check is instantiated.

package com.mycompany.stash.merge.checks;

import com.atlassian.stash.scm.pull.MergeRequest;
import com.atlassian.stash.scm.pull.MergeRequestCheck;
import com.atlassian.stash.user.PermissionService;

import javax.annotation.Nonnull;

public class IsAdminMergeCheck implements MergeRequestCheck {
    private final PermissionService permissionService;

    public IsAdminMergeCheck(PermissionService permissionService) {
        this.permissionService = permissionService;
    }

    @Override
    public void check(@Nonnull MergeRequest request) {
        //TODO: implement me
    }
}

The PermissionService has many methods on it to determine whether users have permissions on various Stash objects. The method that we will be using is hasRepositoryPermission(Repository, Permission). This takes a repository and a permission and return true if the user for the current request has the permission or false if not. Since we want to check if the current user is a repository admin, the permission we should use is Permission.REPO_ADMIN. But where do we find the target repository for the pull request? The MergeRequest.getPullRequest() will give us the pull request being merged and if we call getToRef().getRepository() on this we will get the repository the pull request is being merged into.

Adding this to our method we have:

@Override
public void check(@Nonnull MergeRequest request) {
    if (!permissionService.hasRepositoryPermission(request.getPullRequest().getToRef().getRepository(), Permission.REPO_ADMIN)) {
        //TODO: implement me
    }
}

The only thing left in this class is to implement what we want to do if the user is not a repository admin. In this case we want to call MergeRequest.veto(String summaryMessage, String detailedMessage) to tell Stash we wish to veto the merge. The summaryMessage should be a message explaining the problem at a high level and in as few words as possible (while still being clear). The detailedMessage should be a longer message giving more information and any context that may be helpful to the user in making them understand why the merge was vetoed and what they might be able to change to fix the situation.

When generating messages that will be shown to the user, it is always good practice to use Stash's internationalization service (I18nService) so that the localised version appropriate to the user's preferred locale is chosen. I18nService.getText(String key, String fallbackMessage) will return you a localised version message for the supplied key or the fallbackMessage if no message could be found. So let's add I18nService to our class like we did with the PermissionService:

package com.mycompany.stash.merge.checks;

import com.atlassian.stash.i18n.I18nService;
import com.atlassian.stash.scm.pull.MergeRequest;
import com.atlassian.stash.scm.pull.MergeRequestCheck;
import com.atlassian.stash.user.Permission;
import com.atlassian.stash.user.PermissionService;

import javax.annotation.Nonnull;

public class IsAdminMergeCheck implements MergeRequestCheck {
    private final PermissionService permissionService;
    private final I18nService i18nService;

    public IsAdminMergeCheck(PermissionService permissionService, I18nService i18nService) {
        this.permissionService = permissionService;
        this.i18nService = i18nService;
    }

    @Override
    public void check(@Nonnull MergeRequest request) {
        if (!permissionService.hasRepositoryPermission(request.getPullRequest().getToRef().getRepository(), Permission.REPO_ADMIN)) {
            //TODO: implement me
        }
    }
}

Now we reference all the services we require to do our task so let's finish the implementation of the check method:

package com.mycompany.stash.merge.checks;

import com.atlassian.stash.i18n.I18nService;
import com.atlassian.stash.scm.pull.MergeRequest;
import com.atlassian.stash.scm.pull.MergeRequestCheck;
import com.atlassian.stash.user.Permission;
import com.atlassian.stash.user.PermissionService;

import javax.annotation.Nonnull;

public class IsAdminMergeCheck implements MergeRequestCheck {
    private final PermissionService permissionService;
    private final I18nService i18nService;

    public IsAdminMergeCheck(PermissionService permissionService, I18nService i18nService) {
        this.permissionService = permissionService;
        this.i18nService = i18nService;
    }

    @Override
    public void check(@Nonnull MergeRequest request) {
        if (!permissionService.hasRepositoryPermission(request.getPullRequest().getToRef().getRepository(), Permission.REPO_ADMIN)) {
            String summaryMsg = i18nService.getText("mycompany.plugin.merge.check.notrepoadmin.summary",
                                                    "Only repository administrators may approve pull requests");
            String detailedMsg = i18nService.getText("mycompany.plugin.merge.check.notrepoadmin.detailed",
                                                     "The user merging the pull request must be an administrator of the target repository");
            request.veto(summaryMsg, detailedMsg);
        }
    }
}

Implement atlassian-plugin.xml

Having removed the <servlet/> element earlier, our atlassian-plugin.xml should look like this:

<atlassian-plugin key="${project.groupId}.bitbucket-docs" name="${project.name}" plugins-version="2">
    <plugin-info>
        <description>${project.description}</description>
        <version>${project.version}</version>
        <vendor name="${project.organization.name}" url="${project.organization.url}" />
    </plugin-info>
</atlassian-plugin>

First we will need to import into our plugin the Stash services our merge request check needs. From the code above we know we need the I18nService and the PermissionService. So let's add two <component-import/> elements making these available to our plugin:

<atlassian-plugin key="${project.groupId}.bitbucket-docs" name="${project.name}" plugins-version="2">
    <plugin-info>
        <description>${project.description}</description>
        <version>${project.version}</version>
        <vendor name="${project.organization.name}" url="${project.organization.url}" />
    </plugin-info>

    <component-import key="permissionService" interface="com.atlassian.stash.user.PermissionService"/>
    <component-import key="i18nService" interface="com.atlassian.stash.i18n.I18nService"/>
</atlassian-plugin>

Next we will want to declare an instance of our merge request check Java class to make it available as a component within our plugin. This is achieved through the <component/> element. You need to supply a key for your component that is unique within the plugin and supply the fully qualified class name of the Java class.

<atlassian-plugin key="${project.groupId}.bitbucket-docs" name="${project.name}" plugins-version="2">
    <plugin-info>
        <description>${project.description}</description>
        <version>${project.version}</version>
        <vendor name="${project.organization.name}" url="${project.organization.url}" />
    </plugin-info>

    <component-import key="permissionService" interface="com.atlassian.stash.user.PermissionService"/>
    <component-import key="i18nService" interface="com.atlassian.stash.i18n.I18nService"/>

    <component key="isAdminMergeCheck" class="com.mycompany.stash.merge.checks.IsAdminMergeCheck"/>
</atlassian-plugin>

We then need to tell Stash that our component should be registered as a merge request check. This is done through the <merge-check/> element. You again need to supply a key for the merge check that is unique within the plugin and you also need to supply the Java class name for it to instantiate.

If you do supply the Java class name then Stash will create a new instance of your merge check every time it is required (ie for every merge request) and will not use the singleton component you have just declared. This may be what you want but most often it is not - you will want to instantiate your merge check only once and have Stash use that singleton instance each time. To have Stash use this singleton instance you should supply the value "bean:isAdminMergeCheck" which is a reference to the component you declared above.

<atlassian-plugin key="${project.groupId}.bitbucket-docs" name="${project.name}" plugins-version="2">
    <plugin-info>
        <description>${project.description}</description>
        <version>${project.version}</version>
        <vendor name="${project.organization.name}" url="${project.organization.url}" />
    </plugin-info>

    <component-import key="permissionService" interface="com.atlassian.stash.user.PermissionService"/>
    <component-import key="i18nService" interface="com.atlassian.stash.i18n.I18nService"/>

    <component key="isAdminMergeCheck" class="com.mycompany.stash.merge.checks.IsAdminMergeCheck"/>
    <merge-check key="isAdmin" class="bean:isAdminMergeCheck"/>
</atlassian-plugin>

Finally you may want to add a <resource/> element so that Stash will find any localized versions of the veto messages you might send. More information can be found here on how to internationalise your Atlassian plugin.

Note that Stash will use fallback messages supplied to the I18nService.getText(key, fallbackMessage) method so if you do not anticipate your plugin being used in a context other than the language your fallback messages are written in you may skip this step.

<atlassian-plugin key="${project.groupId}.bitbucket-docs" name="${project.name}" plugins-version="2">
    <plugin-info>
        <description>${project.description}</description>
        <version>${project.version}</version>
        <vendor name="${project.organization.name}" url="${project.organization.url}" />
    </plugin-info>

    <component-import key="permissionService" interface="com.atlassian.stash.user.PermissionService"/>
    <component-import key="i18nService" interface="com.atlassian.stash.i18n.I18nService"/>

    <component key="isAdminMergeCheck" class="com.mycompany.stash.merge.checks.IsAdminMergeCheck"/>
    <merge-check key="isAdmin" class="bean:isAdminMergeCheck"/>

    <resource type="i18n" name="IsAdminMergeCheck" location="com.mycompany.stash.merge.checks"/>
</atlassian-plugin>

Create a plugin resource bundle (optional)

If you added the <resource/> element to your atlassian-plugin.xml above you will want to create a resource bundle for each language and locale you intend to support. For instance if you wished to support the German language and locale you would add the following file to your project:

  • src/main/resources/com/mycompany/stash/merge/checks/IsAdminMergeCheck_de_DE.properties

Its contents would look something like:

mycompany.plugin.merge.check.notrepoadmin.summary=Nur Repository-Administratoren können Pull Requests akzeptieren
mycompany.plugin.merge.check.notrepoadmin.detailed=Um einen Pull Request akzeptieren zu können, muss der Benutzer ein Administrator des Ziel-Repositories sein

Note that you do not need to provide a resource bundle for the locale and language your fallback messages are written in.

Try it out!

From the command line run atlas-run. Then you should connect to http://localhost:7990/bitbucket, logging in as admin/admin and create a project, repository, import some code with multiple branches and create a pull request between them. To trigger your merge check's error message you should create a new user who has contributor permissions to the repository (so that the merge button is displayed on the pull request page for them) but who is not an administrator of the repository. When this user visits the pull request page you should notice the merge button is greyed out and hovering over this button displays a tooltip with the detailed message you supplied.

Congratulations on building your first merge request check!

Conclusion

In this tutorial you have learnt how to create a merge request check in Java, how to use various Stash services to implement your merge check, how to configure your atlassian-plugin.xml to declare your merge check and also how to provide internationalization and localization support for the messages you send to the user.