ScriptRunner is primarily concerned with the back-end, but also has the capabilibility to customise the web UI through dynamically adding web fragments.

Simple examples of customising the UI are:

The use cases for this are endless.

Two key concepts make this possible:

ScriptRunner contains the dynamic modules library, and this is also available to script authors.

Overview

Add ?web.items&web.panels&web.sections to any URL in Bitbucket. This will show you the locations where you can add new content. For example, on the project listing page:

project list

Let’s say we wanted to add a new announcement banner. In that case, we would want to take note of the web panel location - bitbucket.notification.banner.header. The context items are the objects we will have available to us should we want to write a condition for the item, which we’ll come to later.

The next step is to create some XML describing the plugin module. The format of that is available here, although for simple cases the examples below should be sufficient.

In our case we will just be registering static content, as opposed to a velocity template.

Finally, we need to use dynamic modules to register this new plugin module, and later, optionally, unregister it.

Here is a complete example which you can run from the Script Console:

package com.onresolve.bitbucket.groovy.test.docs.webitems

import com.onresolve.licensing.DynamicModulesComponent
import com.onresolve.scriptrunner.runner.ScriptRunnerImpl

def dynamicModulesComponent = ScriptRunnerImpl.getPluginComponent(DynamicModulesComponent.class) (1)

def moduleName = "announcement-panel" (2)

def writer = new StringWriter()

writer.write("""
    <web-panel key='announcement-panel' location='bitbucket.notification.banner.header'>
      <resource name='view' type='static'>
        <![CDATA[
            <div class='aui-banner aui-banner-error' role='banner' aria-hidden='false'>
            <strong>Maintenance!</strong>: Stash will be down this weekend!</div>
        ]]>
      </resource>
    </web-panel>
""") (3)

dynamicModulesComponent.unregister(moduleName) (4)
dynamicModulesComponent.register(writer) (5)
1 grab a reference to the dynamic modules component
2 give our panel a unique ID so that we can unregister it later
3 write the xml to a java.io.Writer
4 remove any previous module with the same key, so we can rerun this script
5 register the new plugin component
If you are rerunning the same script it’s important to unregister the component first, otherwise you will end up with several web panels at the same location

Run this script in the console then refresh the front page, it should look like this:

anno banner
There is a small delay on the first page load immediately after modifying plugin modules, as the batch CSS and javascript needs to be rebuilt.

At some later stage you might want to remove the panel, which can be done like this:

package com.onresolve.bitbucket.groovy.test.docs.webitems

import com.onresolve.licensing.DynamicModulesComponent
import com.onresolve.scriptrunner.runner.ScriptRunnerImpl

def dynamicModulesComponent = ScriptRunnerImpl.getPluginComponent(DynamicModulesComponent.class)
dynamicModulesComponent.unregister("announcement-panel") (1)
1 use the same module ID you used when registering it

Persistence of modules

Plugin modules registered dynamically do not survive a restart of the application. If you want to persist your web item, you can add the registration script to the startup package under one of your script roots.

An easy way to do this is to put it in <stash_home>/scripts/startup.

If you do that, make sure the script has as its first line: package startup.

Using an XML Builder

Putting the XML for the module into a String is okay, but, it’s a bit messy working with XML inside a string in a script. We could easily forget a closing tag, and CDATA sections are a bit of a kludge. We’d rather use a MarkupBuilder - this will give us a compile error if our XML is not well-formed.

We’re actually going to use two builders in this example - one for the actual HTML content of the panel, and one for the plugin module’s XML. This saves us having to worry overly much about escaping the HTML within the XML. There is one other difference to the previous example, which is that we’ll use the IsLoggedInCondition condition to only show the banner to logged in users.

You can show the banner to only users who are not logged in by inverting the condition - see the documentation on conditions for details.
package com.onresolve.bitbucket.groovy.test.docs.webitems

import com.onresolve.licensing.DynamicModulesComponent
import com.onresolve.scriptrunner.runner.ScriptRunnerImpl
import groovy.xml.MarkupBuilder

def dynamicModulesComponent = ScriptRunnerImpl.getPluginComponent(DynamicModulesComponent)

def moduleName = "announcement-panel"

def writer = new StringWriter()
def builder = new MarkupBuilder(writer)

// remove any previous module with the same key, so we can rerun
dynamicModulesComponent.unregister(moduleName)

def contentWriter = new StringWriter()
def contentBuilder = new MarkupBuilder(contentWriter)

contentBuilder."div"(class: "aui-banner aui-banner-error", role: "banner", "aria-hidden": false) {
    mkp.yieldUnescaped("<strong>Maintenance!</strong>: Stash will be down this weekend!")
}

builder."web-panel"(
    key: moduleName, location: "bitbucket.notification.banner.header"
) {
    condition(class: "com.atlassian.bitbucket.web.conditions.IsLoggedInCondition")
    resource(name: "view", type: "static") {
        mkp.yield(contentWriter.toString())
    }
}

dynamicModulesComponent.register(writer)

Conditionally Displaying a Panel

Let’s build on these examples to only display a message on all repositories of some list of projects. Again, first we need to identify our location for the message:

repo list

We’ll choose the highlighted panel, as it’s available on all the pages for a repository, eg source, commits, branches, etc.

To register the panel we will use the following script:

import com.onresolve.licensing.DynamicModulesComponent
import com.onresolve.scriptrunner.runner.ScriptRunnerImpl
import com.onresolve.scriptrunner.runner.web.DelegatingCondition
import groovy.xml.MarkupBuilder

def dynamicModulesComponent = ScriptRunnerImpl.getPluginComponent(DynamicModulesComponent.class)

def moduleName = "project-panel-info"

def writer = new StringWriter()
def builder = new MarkupBuilder(writer)

// remove any previous module with the same key, so we can rerun
dynamicModulesComponent.unregister(moduleName)

def contentWriter = new StringWriter()
def contentBuilder = new MarkupBuilder(contentWriter)

contentBuilder."div"(class: "aui-message aui-message-warning") {
    p(class: "title") {
        strong("Caution! Read-only")
    }
    p {
        mkp.yieldUnescaped("Access to repositories in this project has been blocked " +
            "due to investigation by the Financial Conduct Authority.")
    }
}

builder."web-panel"(
    key: moduleName, location: "bitbucket.web.repository.banner"
) {
    condition(class: DelegatingCondition.name) { (1)
        param(name: "delegate", "com/onresolve/bitbucket/groovy/test/docs/webitems/OnlySomeProjectsCondition.groovy") (2)
    }
    resource(name: "view", type: "static") {
        mkp.yield(contentWriter.toString())
    }
}

dynamicModulesComponent.register(writer)
1 always use this class - it will delegate to your script
2 provide the path to a class that you write, determining whether the panel will be shown or not

As mentioned, we write a script available under one of our script roots, that determines whether to show the project or not. In this case we only want the panel to appear when the parent project is TEST or MBS.

So our OnlySomeProjectsCondition.groovy looks like:

package com.onresolve.bitbucket.groovy.test.docs.webitems

import com.atlassian.plugin.PluginParseException
import com.atlassian.plugin.web.Condition
import com.atlassian.bitbucket.repository.Repository
import groovy.util.logging.Log4j

@Log4j
class OnlySomeProjectsCondition implements Condition { (1)
    @Override
    void init(Map<String, String> params) throws PluginParseException {
        (2)
    }

    @Override
    boolean shouldDisplay(Map<String, Object> context) {
        def repository = context.repository as Repository (3)
        return repository.project.key in ["TEST", "MBS"]
    }
}
1 you must implement com.atlassian.plugin.web.Condition
2 if you have any complex initialisation to do, this is the place to do it
3 our condition check - return true to show the panel, false otherwise
project panel
To know what will be available to you in the context parameter of shouldDisplay, recall the Context Items shown on the screen overlay when viewing web panels. Dump the class name to the logs so you can find the javadoc.
Your condition is only compiled when you register it, not every time it’s called. If you change the condition script, you will need to re-register the web element.

Scheduling Various Announcement Banners

Proper notification of maintenance windows to your users is critical for large sites. However, it’s easy to forget to put up a banner, and to remove it afterwards. Better to schedule these tasks to happen automatically when you are planning your maintenance.

The following script is a more complicated example, that schedules three tasks which will do in order:

  • set an information banner

  • set a more strongly-worded warning banner

  • remove the banner

In the example it sets these three tasks at one-minute intervals, beginning in one minute, so that you can preview the changes.

In practice, as commented in the code, you may set the first banner a week before downtime, the warning banner a day before downtime, and removing the banner after the maintenance window has finished.

package com.onresolve.bitbucket.groovy.test.docs.webitems

import com.atlassian.sal.api.component.ComponentLocator
import com.atlassian.scheduler.JobRunner
import com.atlassian.scheduler.JobRunnerRequest
import com.atlassian.scheduler.JobRunnerResponse
import com.atlassian.scheduler.SchedulerService
import com.atlassian.scheduler.config.*
import com.onresolve.licensing.DynamicModulesComponent
import com.onresolve.scriptrunner.runner.ScriptRunnerImpl
import groovy.xml.MarkupBuilder

def schedulerService = ComponentLocator.getComponent(SchedulerService)

Calendar calendar = Calendar.getInstance()
calendar.add(Calendar.MINUTE, 1)
def bannerStartTime = calendar.getTime()

calendar.add(Calendar.MINUTE, 1)
def warningBannerStartTime = calendar.getTime()

calendar.add(Calendar.MINUTE, 1)
def bannerOffTime = calendar.getTime()

/*
// For real usage you'd use:

def dateFormat = new SimpleDateFormat("dd/MMM/yyyy hh:mm")
bannerStartTime = dateFormat.parse("20/JUL/2015 20:00")
warningBannerStartTime = dateFormat.parse("21/JUL/2015 20:00")
bannerOffTime = dateFormat.parse("24/JUL/2015 20:00")
 */

def content = [:]
def contentWriter = new StringWriter()
def contentBuilder = new MarkupBuilder(contentWriter)
contentBuilder."div"(class: "aui-banner aui-banner-error", role: "banner", "aria-hidden": false) {
    mkp.yieldUnescaped("<strong>Maintenance!</strong>: Stash will be inaccessible this weekend!")
}
content.put("TWO_DAYS", contentWriter.toString())

contentWriter.buffer.setLength(0)
contentBuilder."div"(class: "aui-banner aui-banner-error", role: "banner", "aria-hidden": false) {
    mkp.yieldUnescaped("<strong>Maintenance!</strong>: Stash will be inaccessible from tomorrow!")
}
content.put("ONE_DAY", contentWriter.toString())

def dynamicModulesComponent = ScriptRunnerImpl.getPluginComponent(DynamicModulesComponent.class)

def moduleName = "announcement-panel"

runner = new JobRunner() {
    @Override
    JobRunnerResponse runJob(JobRunnerRequest jobRunnerRequest) {
        log.debug("running now: ${jobRunnerRequest.jobConfig.parameters}")

        // remove any previous module with the same key, so we can rerun
        dynamicModulesComponent.unregister(moduleName)

        def writer = new StringWriter()
        def builder = new MarkupBuilder(writer)

        builder."web-panel"(
                key: moduleName, location: "bitbucket.notification.banner.header"
        ) {
            condition(class: "com.atlassian.bitbucket.web.conditions.IsLoggedInCondition")
            resource(name: "view", type: "static") {
                mkp.yield(content[jobRunnerRequest.jobConfig.parameters.action])
            }
        }

        dynamicModulesComponent.register(writer)

        return null
    }
}

def jobRunnerKey = JobRunnerKey.of(ScriptRunnerImpl.PLUGIN_KEY + ":ANNO_BANNER_RUNNER-1")
schedulerService.registerJobRunner(jobRunnerKey, runner)
log.debug("Schedule $jobRunnerKey for $bannerStartTime")
schedulerService.scheduleJob(JobId.of(ScriptRunnerImpl.PLUGIN_KEY + ":ANNO_BANNER_JOB-1"), JobConfig.forJobRunnerKey(jobRunnerKey)
        .withRunMode(RunMode.RUN_ONCE_PER_CLUSTER)
        .withSchedule(Schedule.runOnce(bannerStartTime))
        .withParameters([action: "TWO_DAYS"])
)

jobRunnerKey = JobRunnerKey.of(ScriptRunnerImpl.PLUGIN_KEY + ":ANNO_BANNER_RUNNER-2")
schedulerService.registerJobRunner(jobRunnerKey, runner)
log.debug("Schedule $jobRunnerKey for $warningBannerStartTime")
schedulerService.scheduleJob(JobId.of(ScriptRunnerImpl.PLUGIN_KEY + ":ANNO_BANNER_JOB-2"), JobConfig.forJobRunnerKey(jobRunnerKey)
        .withRunMode(RunMode.RUN_ONCE_PER_CLUSTER)
        .withSchedule(Schedule.runOnce(warningBannerStartTime))
        .withParameters([action: "ONE_DAY"])
)

jobRunnerKey = JobRunnerKey.of(ScriptRunnerImpl.PLUGIN_KEY + ":ANNO_BANNER_RUNNER-3")
schedulerService.registerJobRunner(jobRunnerKey, new JobRunner() {
    @Override
    JobRunnerResponse runJob(JobRunnerRequest jobRunnerRequest) {
        log.debug("removing component")
        dynamicModulesComponent.unregister(moduleName)
        null
    }
})

log.debug("Schedule $jobRunnerKey for $bannerOffTime")
schedulerService.scheduleJob(JobId.of(ScriptRunnerImpl.PLUGIN_KEY + ":ANNO_BANNER_JOB-3"), JobConfig.forJobRunnerKey(jobRunnerKey)
        .withRunMode(RunMode.RUN_ONCE_PER_CLUSTER)
        .withSchedule(Schedule.runOnce(bannerOffTime))
)

Further Examples

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.