Customising the UI
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:
-
show an announcement banner
-
show different banners for admins, non-admins, and users who are not logged in
-
add a panel to the repository listing, in publicly accessible projects only
The use cases for this are endless.
Two key concepts make this possible:
-
web fragments - plugins can add web items, web panels, and web sections. Uniquely in Bitbucket, there are also client-rendered versions of these
-
dynamic modules from Atlassian Pocketknife - a library which allows you to dynamically create and register plugin modules
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:

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:

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:

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 |

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
-
Displaying a banner on the view pull request page, when particular files/directories have been changed in that pull request: https://answers.atlassian.com/questions/39441445/answers/39445352
-
Override delete repository button, move repository to archive project instead: https://answers.atlassian.com/questions/38751040/answers/38753806
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.