You can write a behaviour that will convert a text field to a select or a multi-select, on initialisation of the form.

You can also specify the available options for the field, including a picker function that will perform "as you type" searching.

gallery

It’s easier to explain this with some examples:

You may be wondering why you wouldn’t just add these items as options to a regular select or multi-select. The reason is that there are currently around 30 million repositories in GitHub, so adding those to JIRA is not practical, plus the list increases by several thousand every day.

But if you have a manageable list of options, and you can synchronise them with your select field options, this will give you a couple of additional advantages:

  • ability to rename options

  • ability to disable options

A primary use for this technique is to store a reference to another issue, or issues, either local or remote. Currently, we just store the issue key, which of course can change if the issue is moved to another project, which means searching on the new key will not give you the correct results.

An upcoming release of ScriptRunner will include configurable fields that will handle issue references, both local and remote, which will be searchable even if the issue key changes. Unfortunately it didn’t make this release, but we are interested to see if people want to make use of it.

To get round this you can create an issue update listener to convert the custom field values to standard issue links, then null out the custom field. This will give more structure to a workflow that requires linking issues, for example better validation, and event handling (currently issue linking does not raise any event you can catch.

The forthcoming release will provide much better view templates, for example linked issue summary, link to GitHub repository etc.

Walkthrough - Pick from JIRA issues

We will hook up a text field to display a searchable list of issues that come from a JQL query. The scenario would be something like enforcing the association of an end-user Incident issue type with a Root Cause issue type.

The first step is to validate that your JQL query works, e.g.:

issuetype = 'Root Cause' and resolution is empty

Next, create a behaviour, and add an initialiser function containing something similar to the following:

getFieldByName("TextFieldA").convertToSingleSelect([ (1)
    ajaxOptions: [
        url : getBaseUrl() + "/rest/scriptrunner-jira/latest/issue/picker",
        query: true, // keep going back to the sever for each keystroke

        // this information is passed to the server with each keystroke
        data: [
            currentJql  : "project = SSPA ORDER BY key ASC", (2)
            label       : "Pick high priority issue in Support project", (3)
            showSubTasks: false, (4)

            // specify maximum number of issues to display, defaults to 10
            // max       : 5,
        ],
        formatResponse: "issue" (5)
    ],
    css: "max-width: 500px; width: 500px", (6)
])
1 Convert a custom field called TextFieldA, but this can be any short text field, even the Summary
2 Specify the JQL query - customise to suit
3 Label that appears at the top of the dropdown
4 Set to true to also return sub-tasks
5 When returning issues this must be "issue"
6 Make the single select the same width as a multi-select
The JQL query you provide is not validated, check it first. If it’s invalid, no issues will be displayed.

Apply the behaviour to the project (and/or issue type) that you are testing with.

The field to which you applied the issue picker should then look like:

pick issue
You could use a post-function or listener to create this as an actual issue link.
If you want to modify the JQL query depending on other field inputs see this example.

Walkthrough - External REST Service

Create and Test Endpoint

This example deals with picking from a list that comes from an external provider, in this case GitHub.

The browser cannot make requests directly to the GitHub REST API due to anti-XSS measures, so we will write a REST endpoint that will effectively proxy the request on to GitHub. We will need to manipulate the request and response slightly.

Step 1 is to create a new REST endpoint, containing the following code:

import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import groovy.json.JsonBuilder
import groovy.transform.BaseScript
import groovyx.net.http.ContentType
import groovyx.net.http.HTTPBuilder
import groovyx.net.http.Method

import javax.ws.rs.core.MultivaluedMap
import javax.ws.rs.core.Response

@BaseScript CustomEndpointDelegate delegate

githubRepoQuery(httpMethod: "GET") { MultivaluedMap queryParams -> (1)

    def query = queryParams.getFirst("query") as String

    def rt = [:]
    if (query) {
        def httpBuilder = new HTTPBuilder("https://api.github.com")
        def repos = httpBuilder.request(Method.GET, ContentType.JSON) {
            uri.path = "/search/repositories"
            uri.query = [q:"$query in:name", sort: "stars", order:"desc"]
            headers."User-Agent" = "My JIRA" (2)

             response.failure = { resp, reader ->
                 log.warn("Failed to query GitHub API: " + reader.text)
             }
        }

        def repoNames = repos["items"]*."full_name"
        rt = [
            items: repoNames.collect { String repo ->
                [
                    value: repo,
                    html : repo.replaceAll(/(?i)$query/) { "<b>${it}</b>" }, (3)
                    label: repo,
                ]
            },
            total: repos["total_count"],
            footer: "Choose repo... (${repoNames.size()} of ${repos["total_count"]} shown...)"
        ]
    }

    return Response.ok(new JsonBuilder(rt).toString()).build()
}
1 githubRepoQuery forms the final part of the REST URL
2 Github API requires this header, the value is not important
3 Highlight matched terms in a case-insensitive manner
We are not authenticating to the GitHub API so only public repositories will be found. You can add authentication details if you have private repositories. Note also that without authentication you are rate limited to 60 requests per hour.

The next step is to test this API alone. In your browser, go to:

<jira-base-url>/rest/scriptrunner/latest/custom/githubRepoQuery?query=tetris

You should see a response containing the first 30 matching items, sorted by popularity. Scroll down and check the total and footer keys are also correct. You might notice that the html value of each item has <b> tags to highlight the word tetris.

JSONView for Chrome will format the response nicely, as in the following image. Other plugins are available for other browsers.

github response

Try changing tetris to another string.

If this is working properly, you can move on to hooking it up to a text field.

Behaviour

As in the previous example, create a behaviour and set the initialiser code to:

getFieldByName("TextFieldB").convertToMultiSelect([ (1)
    ajaxOptions: [
        url : getBaseUrl() + "/rest/scriptrunner/latest/custom/githubRepoQuery", (2)
        query: true, // keep going back to the sever for each keystroke
        minQueryLength: 4, (3)
        keyInputPeriod: 500, (4)
        formatResponse: "general", (5)
    ]
])
1 Convert a custom field called TextFieldB, this time to a multiselect
2 The URL of the endpoint that we tested above
3 Don’t make any query until the user has typed four characters
4 Wait 500ms after the user has stopped typing before looking for repos
5 When showing arbitrary, non-issue data, this must be "general"

Testing should produce something similar to:

tetris

Walkthrough - Choose from a database table

In this example we’ll configure the picker to read from a database query. You would use this if you can get read-only access to the target database and there is no suitable REST API. For example, allowing the selection of a customer name where the customer list is in a CRM database.

The example worked through here reads the JiraEventType table from the current JIRA. This is pointless, but is used because it’s something you will be able to test with, and is almost identical to querying any other database.

Configure a REST endpoint using the following code:

import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.config.database.DatabaseConfigurationManager
import com.atlassian.jira.config.database.JdbcDatasource
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import groovy.json.JsonBuilder
import groovy.sql.GroovyRowResult
import groovy.sql.Sql
import groovy.transform.BaseScript

import javax.ws.rs.core.MultivaluedMap
import javax.ws.rs.core.Response
import java.sql.Driver

@BaseScript CustomEndpointDelegate delegate

eventTypes(httpMethod: "GET") { MultivaluedMap queryParams ->

    def query = queryParams.getFirst("query") as String
    def rt = [:]

    def datasource = ComponentAccessor.getComponent(DatabaseConfigurationManager).getDatabaseConfiguration().getDatasource() as JdbcDatasource
    def driver = Class.forName(datasource.getDriverClassName()).newInstance() as Driver

    def props = new Properties()
    props.setProperty("user", datasource.getUsername())
    props.setProperty("password", datasource.getPassword())

    def conn = driver.connect(datasource.getJdbcUrl(), props)
    def sql = new Sql(conn) (1)

    try {
        sql
        def rows = sql.rows("select name from jiraeventtype where name ilike ?", ["%${query}%".toString()]) (2)

        rt = [
            items : rows.collect { GroovyRowResult row ->
                [
                    value: row.get("name"),
                    html: row.get("name").replaceAll(/(?i)$query/) { "<b>${it}</b>" },
                    label: row.get("name"),
                ]
            },
            total: rows.size(),
            footer: "Choose event type... "
        ]

    } finally {
        sql.close()
        conn.close()
    }

    return Response.ok(new JsonBuilder(rt).toString()).build()
}
1 Configure driver - see connecting to databases for more information
2 The SQL statement which should use the provided search parameter. Note that ilike is specific to Postgres
If you actually want to run a SQL query against the current JIRA database you can use this method instead.

Verify that it works by opening:

<jira-base-url>/rest/scriptrunner/latest/custom/eventTypes?query=work

Finally connect it to a text field in your behaviour initialiser:

getFieldByName("TextFieldC").convertToMultiSelect([
    ajaxOptions: [
        url : getBaseUrl() + "/rest/scriptrunner/latest/custom/eventTypes",
        query: true,
        formatResponse: "general"
    ]
])

Verify it works:

db picker

Walkthrough - Pick Issue from remote JIRA

This example demonstrates linking a remote JIRA issue with the current issue, but where the remote issue must match a JQL query (performed on the remote instance).

You might use this if you have an internal JIRA and a customer-facing JIRA, and you want to enforce selecting a remote issue which has the same Customer as on the internal instance. For the walkthrough we use Atlassian’s public-facing JIRA instance, and restrict the remote issue list to issues affecting Bitbucket Server with the Enterprise component.

Set up a REST endpoint with the following code:

import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import groovy.json.JsonBuilder
import groovy.transform.BaseScript
import groovyx.net.http.ContentType
import groovyx.net.http.HTTPBuilder
import groovyx.net.http.Method

import javax.ws.rs.core.MultivaluedMap
import javax.ws.rs.core.Response

@BaseScript CustomEndpointDelegate delegate

pickRemoteIssue() { MultivaluedMap queryParams ->
    def query = queryParams.getFirst("query") as String

    def jqlQuery = "project = BSERV and component = Enterprise" (1)

    def httpBuilder = new HTTPBuilder("https://jira.atlassian.com")

    def response = httpBuilder.request(Method.GET, ContentType.JSON) {
        uri.path = "/rest/api/2/issue/picker"
        uri.query = [currentJQL: jqlQuery, query: query, showSubTasks: true, showSubTaskParent:true]

        response.failure = { resp, reader ->
            log.warn("Failed to query JIRA API: " + reader.errorMessages)
            return
        }
    }

    response.sections.each { section ->
        section.issues.each {
            // delete the image tag, because the issue picker is hard-coded
            // to prepend the current instance base URL.
            it.remove("img")
        }
    }

    return Response.ok(new JsonBuilder(response).toString()).build()
}
1 Constraint query - optionally you could specify it in the ajaxOptions code for better reusability
Your JIRA server must allow HTTP connections outwards…​ you may need to configure your httpProxy settings.

Verify this works by opening this URL in your browser:

<jira-base-url>/rest/scriptrunner/latest/custom/pickRemoteIssue?query=branch

You should see the sub-list of issues from the JQL query that contain the word branch in the summary.

Configure your behaviour initialiser to use this endpoint:

getFieldByName("TextFieldD").convertToMultiSelect([
    ajaxOptions: [
        url : getBaseUrl() + "/rest/scriptrunner/latest/custom/pickRemoteIssue",
        query: true,
        formatResponse: "issue"
    ]
])

Should result in something like:

pick remote issue
This works because jira.atlassian.com allows anonymous querying. If you have an application link set up between the two instances you will be better off using that to execute the remote requests, as it will allow impersonation of the current user.

Pick Confluence Space

This example uses the Confluence remote API to search for a space.

You must have a working application link to Confluence for this to work.

Note that the searching uses the same logic as when you search in the Confluence space directory, and searches on space name, description and label. You need to type a complete word from any one of these to get the correct results.

REST endpoint code:

import com.atlassian.applinks.api.ApplicationLink
import com.atlassian.applinks.api.ApplicationLinkService
import com.atlassian.applinks.api.application.confluence.ConfluenceApplicationType
import com.atlassian.sal.api.component.ComponentLocator
import com.atlassian.sal.api.net.Request
import com.atlassian.sal.api.net.Response as SalResponse
import com.atlassian.sal.api.net.ResponseException
import com.atlassian.sal.api.net.ResponseHandler
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
import groovy.transform.BaseScript
import org.apache.commons.lang3.StringUtils

import javax.ws.rs.core.MultivaluedMap
import javax.ws.rs.core.Response

@BaseScript CustomEndpointDelegate delegate

listConfluenceSpaces() { MultivaluedMap queryParams ->

    def query = queryParams.getFirst("query") as String

    def applicationLinkService = ComponentLocator.getComponent(ApplicationLinkService)
    def ApplicationLink confluenceLink = applicationLinkService.getPrimaryApplicationLink(ConfluenceApplicationType)

    assert confluenceLink
    def authenticatedRequestFactory = confluenceLink.createImpersonatingAuthenticatedRequestFactory()

    def confResponse = null

    authenticatedRequestFactory
        .createRequest(Request.MethodType.GET, "rest/spacedirectory/1/search.json?query=${URLEncoder.encode(query)}&type=global&status=current")
        .addHeader("Content-Type", "application/json")
        .execute(new ResponseHandler<SalResponse>() {
        @Override
        void handle(SalResponse response) throws ResponseException {
            confResponse = new JsonSlurper().parse(response.getResponseBodyAsStream())
        }
    })

    def rt = [
        items : confResponse.spaces.collect { space ->

            def html = "${space.key} (${space.name})"
            if (query) {
                html = html.replaceAll(/(?i)$query/) { "<b>${it}</b>" }
            }

            [
                value: space.label,
                html : html,
                label: space.key,
                icon: space.logo.href,

            ]
        },
        total : confResponse.totalSize,
        footer: "Choose Confluence space...",
    ]

    return Response.ok(new JsonBuilder(rt).toString()).build()
}

Behaviour initialiser code:

getFieldByName("TextFieldE").convertToMultiSelect([
    ajaxOptions: [
        url : getBaseUrl() + "/rest/scriptrunner/latest/custom/listConfluenceSpaces",
        query: true,
        formatResponse: "general"
    ]
])

Should result in something like:

pick confl space

Dynamically Changing the Picker Query

Advanced ScriptRunner skills required for this example

In the first example we saw how you could turn a text field into a dropdown that allowed users to pick an issue from the results of a JQL query that we set once, in the initialiser.

Great, but what if the validation query should be formed based on other inputs? That is to say, what if you needed the user to link to an issue from different JQL queries, depending on what other information was present on the form?

In this simple example we will work through, the form has a project-picker custom field, and a text field that will take the value of an issue the user selects. The JQL query needs to be of the form project = <selectedProject> and …​.

In our example, the project picker has the name ProjectPicker, and the field that will be converted to an issue-picking single select has the name TextFieldA.

To do this, we don’t use an initialiser - instead we add an on change server-side script that will convert TextFieldA to a single-select issue picker - the JQL query will be formed from the value of the ProjectPicker field.

reconvert

Add code similar to this to the project picker field, or whatever are the inputs that should drive the JQL query:

def selectedProject = getFieldById(getFieldChanged()).value as Project
def jqlSearchField = getFieldByName("TextFieldA")

if (selectedProject) {
    jqlSearchField.setReadOnly(false).setDescription("Select an issue in the ${selectedProject.name} project")

    jqlSearchField.convertToSingleSelect([
        ajaxOptions: [
            url           : getBaseUrl() + "/rest/scriptrunner-jira/latest/issue/picker",
            query         : true,

            data          : [
                currentJql  : "project = ${selectedProject.key} ORDER BY key ASC", (1)
                label       : "Pick high priority issue in ${selectedProject.name} project",
            ],
            formatResponse: "issue"
        ],
        css        : "max-width: 500px; width: 500px",
    ])
}
else {
    // selected project was null - disable control
    jqlSearchField.convertToShortText()
    jqlSearchField.setReadOnly(true).setDescription("Please select a project before entering the issue")
}
1 build JQL query based on other field inputs

You may notice a problem…​ currently, the value of the issue picker is not cleared if it becomes invalid for the new JQL query. This may change in the future, but right now the best solution to this is to add an on change validator for the issue picker field. Alternatively, you could add a workflow validator if this is on a workflow function.

So, when the project picker is changed and it makes the selected issue invalid, you will see:

reconvert-invalid

This is done by adding the following server-side script for the issue picker field, in our case called TextFieldA:

def selectedIssueField = getFieldById(getFieldChanged())
def selectedIssue = selectedIssueField.value as String
log.debug("selectedIssue changed: ${selectedIssue}")
def selectedProject = getFieldByName("ProjectPicker").value as Project

if (selectedIssue && selectedProject) {

    def jqlQueryBuilder = JqlQueryBuilder.newBuilder()
    def searchService = ComponentAccessor.getComponent(SearchService)
    def user = ComponentAccessor.jiraAuthenticationContext.getLoggedInUser()

    def query = jqlQueryBuilder.where().project(selectedProject.id).and().issue(selectedIssue).buildQuery() (1)
    if (searchService.searchCount(user, query) == 1) { (2)
        selectedIssueField.clearError()
    }
    else {
        selectedIssueField.setError("Issue not found in the selected project")
    }
}
1 build a query corresponding to that used for the picker
2 check the currently selected issue is found in that query

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.