Last updatedNov 30, 2018

Rate this page:

Role Members JQL function tutorial

Applicable:

Jira 7.0.0 and later.

Level of experience:

Advanced. You should have completed at least one intermediate tutorial before working through this tutorial. See the list of developer tutorials.

Time estimate:

It should take you approximately 1 hour to complete this tutorial.

Overview of the tutorial

A JQL functions is a powerful tool that enhances Jira's searching functionality. Developers can create their own JQL Functions using JQL function plugin module.

This tutorial shows implementation of a new JQL function called roleMembers. This function returns the users that are members of a particular Jira project role.

The first argument is the name of the role whose members we try to find. It is compulsory. Any other arguments name the projects whose roles should be checked. When no project is specified, all projects that the searcher can see are queried. For example, a call to roleMembers(trole, tproj) will find all the users in the role trole for the project tproj. On the other hand, a call to roleMembers('testrole') returns all the users in the role testrole across all projects that the searcher can see.

About these instructions

You can use any supported combination of operating system and IDE to construct this app. These instructions were written using macOS Sierra and IntelliJ IDEA 2017.3. If you use another combination, you should use the equivalent operations for your specific environment.

This tutorial was last tested with Jira 7.10.0 using Atlassian Plugin SDK 6.3.10.

Before you begin

To complete this tutorial, you need to know the following: 

  1. The basics of Java development: classes, interfaces, methods, how to use the compiler, and so on.
  2. Be familiar with development tools, such as Maven and IDEs.
  3. How to create an Atlassian plugin project using the Atlassian Plugin SDK.
  4. How to configure Jira system settings.

App source

We encourage you to work through this tutorial. If you want to skip ahead or check your work when you are finished, you can find the app source code on Atlassian Bitbucket.

To clone the repository, run the following command:

1
git clone https://bitbucket.org/atlassian_tutorial/role-members-jql-function

Alternatively, you can download the source as a ZIP archive

Step 1. Create the app project

In this step, you'll use an atlas command to generate stub code for your app. The atlas commands are part of the Atlassian Plugin SDK and automate much of the work of app development for you.

  1. Set up the Atlassian Plugin SDK and build a project if you did not do that yet.
  2. Open a Terminal and navigate to the directory where you want to keep your app code.
  3. To create an app skeleton, run the following command:

    1
    atlas-create-jira-plugin
  4. To identify your app, enter the following information.

    group-id

    com.example.plugins.tutorial

    artifact-id

    role-members-jql-function-tutorial

    version

    1.0-SNAPSHOT

    package

    com.example.plugins.tutorial

  5. Navigate to the project directory created in the previous step.

    1
    cd role-members-jql-function-tutorial/
  6. Delete the test directories.

    Setting up testing for your app isn't part of this tutorial. To delete the generated test skeleton, run the following commands:

    1
    2
    rm -rf ./src/test/java
    rm -rf ./src/test/resources/
  7. Delete the unneeded Java class files.

    1
    rm -rf ./src/main/java/com/example/plugins/tutorial/*
  8. Import project in your favorite IDE.

Step 2. Modify the POM and add dependencies

It is a good idea to familiarize yourself with the project configuration file known as the POM (that is, Project Object Model definition file). The POM declares your app's dependencies, build settings, and metadata (information about your app).

Modify the POM as follows:

  1. Navigate to the role-members-jql-function-tutorial directory created by the SDK.
  2. Open the pom.xml file.
  3. Add your company or organization name and your website URL to the organization element:

    1
    2
    3
    4
    <organization>
        <name>Example Company</name>
        <url>http://www.example.com/</url>
    </organization>
  4. Update the name element to something more readable:

    1
    <name>Role members JQL function</name>

    This is the name of your app that will appear on the Manage Add-ons page in the Jira administration console.

  5. Update the description element:

    1
    <description>This plugin demonstrates how to add a jql function to Atlassian Jira</description>
  6. Save the pom.xml file.

Step 3. Add the JQL function module to the app descriptor

After you get familiar with JQL function plugin module reference, do the following steps:

  1. Navigate to src/main/resources/ and open the atlassian-plugin.xml file.
  2. Add the jql-function module as a child of atlassian-plugin.

    1
    2
    3
    4
    5
    6
    <jql-function key="role-members" i18n-name-key="rolefunc.name" name="Role Members Function"
              class="com.example.plugins.tutorial.RoleFunction">
        <description key="rolefunc.description">JQL function to return the members of a particular role</description>
        <fname>roleMembers</fname>
        <list>true</list>
    </jql-function>
    • The class attribute identifies handler implementation class com.example.plugins.tutorial.RoleFunction.
    • The fname element specifies the name of the function that users use in their JQL queries.
    • The list element specifies that our function returns a list of values.
  3. Save the file.

Step 4. Add UI text to the i18n resource file

When you created the app, the SDK generated an i18n resources file for you. This is where UI text comes from. Add a UI text string to it as follows:

  1. Navigate to src/main/resources and open the role-members-jql-function-tutorial.properties resource file.
  2. Add the following property:

    1
    2
    3
    4
    rolefunc.bad.num.arguments={0} function takes one or more arguments
    rolefunc.role.not.exist={0} role does not exist
    rolefunc.project.not.exist=Project with id or key {0} does not exist
    rolefunc.name=Role Members function
  3. Save the file.

Step 5. Create the JqlFunction implementation

Now let's create the JQL function that was referenced in the app descriptor. We're going to make it simple to start with, and build on this class as we go.

  1. Navigate to src/main/java/com/example/plugins/tutorial/ and create a new file named RoleFunction.java.
  2. Add the following code to the file:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    package com.example.plugins.tutorial;
    
    import com.atlassian.jira.JiraDataType;
    import com.atlassian.jira.JiraDataTypes;
    import com.atlassian.jira.jql.operand.QueryLiteral;
    import com.atlassian.jira.jql.query.QueryCreationContext;
    import com.atlassian.jira.plugin.jql.function.AbstractJqlFunction;
    import com.atlassian.jira.user.ApplicationUser;
    import com.atlassian.jira.util.MessageSet;
    import com.atlassian.jira.util.MessageSetImpl;
    import com.atlassian.plugin.spring.scanner.annotation.component.Scanned;
    import com.atlassian.query.clause.TerminalClause;
    import com.atlassian.query.operand.FunctionOperand;
    
    import javax.annotation.Nonnull;
    import java.util.Arrays;
    import java.util.List;
    
    @Scanned
    public class RoleFunction extends AbstractJqlFunction {
    
        @Nonnull
        @Override
        public MessageSet validate(ApplicationUser applicationUser,
                                   @Nonnull FunctionOperand functionOperand,
                                   @Nonnull TerminalClause terminalClause) {
            return new MessageSetImpl();
        }
    
        @Nonnull
        @Override
        public List<QueryLiteral> getValues(@Nonnull QueryCreationContext queryCreationContext,
                                            @Nonnull FunctionOperand functionOperand,
                                            @Nonnull TerminalClause terminalClause) {
            return Collections.emptyList();
        }
    
        @Override
        public int getMinimumNumberOfExpectedArguments() {
            return 1;
        }
    
        @Nonnull
        @Override
        public JiraDataType getDataType() {
            return JiraDataTypes.USER;
        }
    }

    So far, the initial JQL function code doesn't do a lot. But it forms a good foundation for building upon and it gives us a chance to reflect on some concepts. Notice the methods in the class:

    • The getMinimumNumberOfExpectedArguments() basically returns the smallest number of arguments that the function may accept. The value returned from this method must be consistent across method invocations. We need at least 1 argument that is project role.
    • The getDataType() method is called to determine the type of data the function returns. The value tells Jira which JQL clauses the function can be expected to work with. In our case we want to return users.
    • The validate() and getValues() methods are called to validate JQL function arguments append get result respectively. Keep in mind that those functions can't return null.
  3. Save the file.

Step 6. Implement the RoleFunction validation

Before you run query, you need to be sure that arguments passed by user are valid.

In this step, in validate method, you will check that project role and projects exist. Since you will use ProjectService, projects that are not visible for user won't be found.

  1. In the same RoleFunction.java file, update validate() method with the following:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    @Nonnull
    @Override
    public MessageSet validate(ApplicationUser applicationUser,
                              @Nonnull FunctionOperand functionOperand,
                              @Nonnull TerminalClause terminalClause) {
       MessageSet messages = new MessageSetImpl();
       final List<String> arguments = functionOperand.getArgs();
    
       //Make sure we have the correct number of arguments.
       if (arguments.isEmpty()) {
           messages.addErrorMessage(getI18n().getText("rolefunc.bad.num.arguments", functionOperand.getName()));
           return messages;
       }
    
       //Make sure the role is valid.
       final String requestedRole = arguments.get(0);
       ProjectRole role = projectRoleManager.getProjectRole(requestedRole);
       if (role == null) {
           messages.addErrorMessage(getI18n().getText("rolefunc.role.not.exist", requestedRole));
           return messages;
       }
    
       //Make sure the project arguments are valid if provided.
       if (arguments.size() > 1) {
           for (String project : arguments.subList(1, arguments.size())) {
               ProjectService.GetProjectResult result = getProjectResult(project);
               if (!result.isValid()) {
                   result.getErrorCollection().getErrorMessages().forEach(messages::addErrorMessage);
                   return messages;
               }
           }
       }
    
       return messages;
    }

    In the snippet above, we check that:

    • Function has correct number of arguments.
    • Function has a role.
    • Projects exist.

    If validation fails, we add error message to MessageSet. Validation is only successful if there is no error message.

  2. To make validation method work, inject ProjectRoleManager and ProjectService using Atlassian Spring Scanner and implement getProjectResult() method.

    Dependency injection is shown in this example:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @JiraImport
    private final ProjectRoleManager projectRoleManager;
    @JiraImport
    private final ProjectService projectService;
    
    public RoleFunction(ProjectRoleManager projectRoleManager, ProjectService projectService) {
        this.projectRoleManager = projectRoleManager;
        this.projectService = projectService;
    }

    The getProjectResult() has to be able to get project by project key or project ID. Here is a simple implementation:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    private ProjectService.GetProjectResult getProjectResult(String project){
        ProjectService.GetProjectResult result = null;
        try {
            result = projectService.getProjectById(Long.parseLong(project));
        } catch (NumberFormatException e){
            result = projectService.getProjectByKey(project);
        }
        return result;
    }
  3. Save the file.

Step 7. Implement the getValues method

  1. If function passed validation, getValues method will be invoked. Replace the stub code with following:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    @Nonnull
    @Override
    public List<QueryLiteral> getValues(@Nonnull QueryCreationContext queryCreationContext,
                                       @Nonnull FunctionOperand functionOperand,
                                       @Nonnull TerminalClause terminalClause) {
       final List<String> arguments = functionOperand.getArgs();
       //Can't do anything when no argument is specified. This is an error so return empty list.
       if (arguments.isEmpty()) {
           return Collections.emptyList();
       }
    
       final ProjectRole projectRole = projectRoleManager.getProjectRole(arguments.get(0));
       //Role not in system, then do nothing.
       if (projectRole == null) {
           return Collections.emptyList();
       }
    
       final Set<ApplicationUser> users = new HashSet<>();
       //Projects are specified, then look at those projects.
       if (arguments.size() > 1) {
           for (String project : arguments.subList(1, arguments.size())) {
               ProjectService.GetProjectResult result = getProjectResult(project);
               users.addAll(projectRoleManager.getProjectRoleActors(projectRole, result.getProject()).getApplicationUsers());
           }
       } else {
           ServiceOutcome<List<Project>> result = projectService.getAllProjects(queryCreationContext.getApplicationUser());
           for (Project project: result.getReturnedValue()) {
               users.addAll(projectRoleManager.getProjectRoleActors(projectRole, project).getApplicationUsers());
           }
       }
    
       //Convert all the users to query literals.
       final List<QueryLiteral> literals = new ArrayList<>();
       for (ApplicationUser user : users) {
           literals.add(new QueryLiteral(functionOperand, user.getKey()));
       }
    
       return literals;
    }

    We use ProjectRoleManager to get ProjectRoleActors for specified project and project role, and then convert them to ApplicationUser.

  2. Save the file.

Step 8. Build, install, and run the app

Let's start Jira and see what we've got so far.

  1. Open a Terminal and navigate to the app root directory where the pom.xml is located.
  2. Run the following SDK command:

    1
    atlas-run

    This command downloads and starts Jira with your app installed.

  3. Open the Jira instance in a browser and log in with the default admin/admin.

  4. Create new project, issues, and roles.
  5. Perform a JQl query, that is, assignee IN roleMembers(Administrators, TEST)

    JQL example

  6. Create a new user and make sure that the user doesn't have permissions to browse project.

  7. Log in with new user and try to run the same query. You should see an error in result. Here our validate() function works.
  8. Enter a name for your handler (it can be anything because we won't save it this time), and then click Next
    Notice the configuration form for this handler. 

    JQL error example

From here, you can keep Jira running while you continue development of the app. To reload your app, use QuickReload. It reinstalls your app behind the scenes as you work.

To use QuickReload, follow these steps:

  1. Open a new Terminal window and navigate to the app root folder.
  2. To rebuild your app and trigger QuickReload, run atlas-package command.
  3. When build finishes successfully, Jira reloads the app.
  4. Go back to your browser and test your changes (you may need to refresh the browser page first).

Step 9. Implement sanitizer for JQL function

To make the function truly production ready, your RoleFunction also needs to implement ClauseSanitisingJqlFunction. A saved JQL search (filter) can be shared with multiple users. While this functionality is very useful, it also allows information to be leaked. For example, let's say you have a filter that contains assignee in roleMembers(Administrators, Proj) and you share the filter with Janice who cannot see Proj. The search will not return any results, however, Janice will know that a project called Proj exists even though she does not have permission to see it. A JQL function that can expose sensitive information should also implement the optional ClauseSanitisingJqlFunction interface.

The roleMembers function should check that the passed user can see all the project arguments. If all the passed projects are visible, the we can simply return the FunctionOperand as passed. On the other hand, if some of the projects are not visible then the sanitizer should return a new FunctionOperand that has any names replaced with project IDs. This is not a perfect solution as we still leak the fact that there exists a project that the searcher cannot see, however, we no longer leak the project name.

  1. To implement the ClauseSanitisingJqlFunction, use the following code:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    public FunctionOperand sanitiseOperand(final ApplicationUser user, @Nonnull final FunctionOperand functionOperand) {
        final List<String> arguments = functionOperand.getArgs();
    
        //We only sanitise projects, so just return the original function if there are no projects.
        if (arguments.size() <= 1) {
            return functionOperand;
        }
    
        boolean argChanged = false;
        final List<String> newArgs = new ArrayList<>(arguments.size());
        newArgs.add(arguments.get(0));
        for (final String argument : arguments.subList(1, arguments.size())) {
            final Project project = projectManager.getProjectObjByKey(argument);
            if (project != null && !permissionManager.hasPermission(ProjectPermissions.BROWSE_PROJECTS, project, user)) {
                newArgs.add(project.getId().toString());
                argChanged = true;
            } else {
                newArgs.add(argument);
            }
        }
    
        if (argChanged) {
            return new FunctionOperand(functionOperand.getName(), newArgs);
        } else {
            return functionOperand;
        }
    }

    The ProjectManager that we used to get projects doesn't perform permission check and returns all projects that match query. However we use PermissionManager to check if user has Browse permission and replace project name with ID. Use constructor injection for ProjectManager and PermissionManager was it shown previously.

  2. Save the file.

Step 10. Test your sanitizer

  1. Go back to browser and log in to Jira as admin.
  2. Run a JQL query as previously, save the filter, and then share it with logged-in-users.
  3. Log in as your test user without permissions.
  4. Find filter you just created and run it.

  5. Sanitizer replaces project key with project ID.

Next steps

The roleMembers function should probably perform some permissions checks on the project role argument. If such a check was implemented, then the sanitizer would probably have to change to reflect any logic here.

Congratulations, that's it!

Have a treat!

Rate this page: