Environment Booking & Reservation
Context
You have defined your Environments and you would like people to book your Environments for different kind of activities like UAT, demos, technical maintenance, etc.
Prerequisite
Your Environments are already defined in Apwide Golive.
Not yet? Follow our Get started
Basic Use Case
For this use case, we are relying on Jira core features: Jira issue types, Custom Fields, Workflows, etc.
STEP 1
Create a new Jira Issue Type to support your Environment Booking requests
For instance, you can name it “Booking Request”.
Associate your newly created Issue Type with an existing Jira Project, or create a new Jira Project to be used for your Booking Requests (it’s up to you).

STEP 2
Create 3 Jira Custom Fields (or reuse existing ones)
Custom Field of type “Environment” to store the Environments linked to your Booking Requests. You can name it “Environment(s) to book”
Custom Field of type “Date Time Picker” to store the beginning of your Booking Requests. You can name it “Start time”.
Custom Field of type “Date Time Picker” to store the end of your Booking Requests. You can name it “End time”.

Related Jira documentation:
https://confluence.atlassian.com/adminjiraserver/adding-a-custom-field-938847222.html
STEP 3
Create a Screen for your Booking Request Issue Type
Add the fields you defined in STEP 2 in your new screen. It should look like this:

Related Jira documentation:
https://confluence.atlassian.com/adminjiraserver/defining-a-screen-938847288.html
STEP 4
Create a workflow for your Booking Request Issue Type
The workflow is really up to you, you can add as many steps and approvals as you need.
Here is an example of very simple workflow:

Related Jira documentation:
https://confluence.atlassian.com/adminjiraserver/working-with-workflows-938847362.html
STEP 5
Test your Booking Request
From your Jira Project, create a new Booking Request and make sure it works. Adjust if needed.

STEP 6
Create a Booking Requests Calendar
Create a new Golive Timeline (more info: Designing Timelines)
Add a new Issue Calendar by typing “Booking Request” (or the name you choose for your Issue Type):

By default, all Calendar information should be there.
Double-check the fields mapping to make sure they are the Custom Fields you have created before.

After clicking on “Done”, you should see the Booking Requests on your Timeline, with their statuses.

Conclusion
Congrats! Now you have a Calendar displaying your Booking Requests and their statuses.
You can reschedule your Booking Requests on the Timeline with drag-and-drop: the “Start time” and “End time” will be updated and the requester will be notified by Jira.
You can also move the Booking Requests from one Environment Swimlane to another in order to update the Environments booked by the request. Notification will also be sent to the requester.
Advanced: Conflict Management
Go further and list potential booking conflicts in your Booking Request using the ScriptRunner Jira App.

Display potential conflicts in your Booking Requests
1. Add a new Script
After installing the ScriptRunner Jira App, add our script in the Script Editor (code reference below).

New Script in the Script Editor section (check below for the code)
The folder hierarchy (com/apwide/behaviour) should correspond to the import line you have in your script (point 3 below): import com.apwide.behaviour.ConflictChecker
You can ignore the warnings displayed in the script editor, about some classes (environment, ctx) unknown at this stage.
2. Add a new Behaviour
Then, create a new Behaviour and map it with your Booking Request project and issue type.

New entry in the Behaviour section
3. Call the Script from your Behaviour
In your Behaviour, create an Initialiser Script and call the Conflict Checker script we added previously:

Script Parameters:
issueTypeName: your Booking Request issue type name
startDateFieldName: your booking start date/time field name
endDateFieldName: your booking end date/time custom field name
environmentFieldName: your Environment custom field name
helpFieldName: the name of the field under which the warning message should appear (if not defined, it will be your endDateFieldName)
timelineUrl: URL used in the warning message “Open the Timeline” button
dateFormat: date format used for the period (if not defined, it will be your Jira date format)
blocking: do you need to block the request creation (true) or not (false) in case of detected conflict. When the blocking parameter is set to true, the warning banner will be displayed in red:

calendarId (optional): ID of a Scheduled Event Calendar to include in the conflict detection. Calendars' IDs (Integer) can be found in the “Shared Calendars” section (HTML source code). This parameter is optional and does not appear in the script examples below.
In order to check for conflicts each time the fields are updated in your Booking Request, add the 3 fields in your Behaviour:
Environment field
Start time field
End time field
Mark them as “Required” using the first toggle, and add Server-side scripts for each of them (code reference below). All Server-side scripts should be identical as the initialiser: they will all trigger the same ConflictChecker script.

Server-side Script (check below for code) for Environment and Start/End dates fields
Code Reference
Server-side Script (Behaviour)
import com.apwide.behaviour.ConflictChecker
import org.apache.log4j.Level
new ConflictChecker(
ctx: this,
issueTypeName: "Booking Request",
startDateFieldName: "Start time",
endDateFieldName: "End time",
environmentFieldName: "Environment(s) to book",
helpFieldName: "Environment(s) to book",
timelineUrl: "/secure/ApwideGolive.jspa#/home/timeline?view=timeline318",
dateFormat: "MMM d",
blocking: false,
logLevel: Level.DEBUG
).check()
ConflictChecker (Script Editor)
package com.apwide.behaviour
import com.apwide.env.service.EnvironmentServices
import com.apwide.env.plan.PlannedEventSearcher
import com.apwide.env.plan.PlannedEventSearchCriteria
import com.apwide.env.plan.PlannedEvent
import com.apwide.env.environment.search.EnvironmentSearcher
import com.atlassian.jira.bc.issue.search.SearchService
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.entity.WithKey
import com.atlassian.jira.issue.Issue
import com.atlassian.jira.issue.fields.CustomField
import com.atlassian.jira.issue.search.SearchResults
import com.atlassian.jira.jql.parser.DefaultJqlQueryParser
import com.atlassian.jira.user.ApplicationUser
import com.atlassian.jira.web.bean.PagerFilter
import com.atlassian.query.Query
import com.onresolve.jira.groovy.user.FormField
import com.onresolve.scriptrunner.runner.customisers.WithPlugin
import com.atlassian.jira.issue.context.IssueContext
import com.opensymphony.workflow.InvalidInputException
import groovy.util.logging.Log4j
import org.apache.log4j.Level
import org.codehaus.groovy.runtime.typehandling.GroovyCastException
import static com.atlassian.jira.config.properties.APKeys.JIRA_DATE_TIME_PICKER_JAVA_FORMAT
@WithPlugin("com.holydev.env.plugin.jira-holydev-env-plugin")
@Log4j
class ConflictChecker {
static final String JIRA_JQL_DATE_FORMAT = 'yyyy-MM-dd HH:mm'
def ctx
String dateFormat
String issueTypeName
String startDateFieldName
String endDateFieldName
String environmentFieldName
String timelineUrl
String helpFieldName
boolean blocking
Integer calendarId
String validationErrorMessage
Level logLevel
void validate() {
String message = evaluate()
if (!message.isEmpty()) {
log.debug("Validator rejects")
throw new InvalidInputException(validationErrorMessage)
}
}
void check() {
writeHelpText(evaluate())
}
String evaluate() {
log.setLevel(logLevel ?: Level.INFO)
Date startFieldValue = getDateValue(startDateFieldName)
Date endFieldValue = getDateValue(endDateFieldName)
List environmentFieldValue = getEnvironmentsValue(environmentFieldName)
if (!startFieldValue || !endFieldValue || !environmentFieldValue) {
log.debug("No values found")
return ""
}
// JQL query
List<Issue> conflicts = searchForConflicts(startFieldValue, endFieldValue, environmentFieldValue)
List<PlannedEvent> events = searchForConflictsInCalendar(startFieldValue, endFieldValue, environmentFieldValue, calendarId)
if (conflicts.isEmpty() && events.isEmpty()) {
log.debug("No conflicts found")
return ""
}
log.debug("Found ${conflicts.size()} conflicts")
log.debug("Found ${events.size()} events")
def conflictsWarning = ""
def eventsWarning = ""
if (!conflicts.isEmpty()) {
conflictsWarning = """
<p style="padding: 0">There are conflicting Booking Requests:</p>
<table style="margin: 15px 0px; width:100%">
<tr>
<th>Key</th>
<th>Summary</th>
<th>Period</th>
<th>Environment(s)</th>
</tr>
${renderConflictList(conflicts,environmentFieldValue)}
</table>
"""
}
if (!events.isEmpty()) {
eventsWarning = """
<p style="padding: 0">There are conflicting Events:</p>
<table style="margin: 15px 0px; width:100%">
<tr>
<th>Id</th>
<th>Summary</th>
<th>Period</th>
<th>Environment(s)</th>
</tr>
${renderEventConflictList(events,environmentFieldValue)}
</table>
"""
}
return """
<div class="aui-message aui-message-${blocking ? 'error' : 'warning'}">
${conflictsWarning}
${eventsWarning}
<a target="_blank" href="${timelineUrl}" class="aui-button">
<span class="aui-icon aui-icon-small aui-iconfont-search"></span> Open the Timeline
</a>
</div>
"""
}
private String renderEventConflictList(List<PlannedEvent> conflicts, Collection<String> environmentFieldValue) {
return conflicts.collect {event ->
Date startDate = event.getStart()
Date endDate = event.getEnd()
List environments = (List) event.getPlannedEnvironments().findAll{ environment -> (environmentFieldValue?.find{(it+"").equals(environment.environmentId+"")}) }
return """
<tr>
<td>${event.id}</td>
<td>${event.title}</td>
<td>${displayedDate(startDate)} - ${displayedDate(endDate)}</td>
<td>${displayedPlannedEnvironments(environments)}</td>
</tr>
"""
}.join("\n")
}
private String renderConflictList(List<Issue> conflicts, Collection<String> environmentFieldValue) {
CustomField startCustomField = getCustomFieldByName(startDateFieldName)
CustomField endCustomField = getCustomFieldByName(endDateFieldName)
CustomField environmentCustomField = getCustomFieldByName(environmentFieldName)
return conflicts.collect {issue ->
Date startDate = (Date) issue.getCustomFieldValue(startCustomField)
Date endDate = (Date) issue.getCustomFieldValue(endCustomField)
List environments = (List) issue.getCustomFieldValue(environmentCustomField)?.findAll{ environment -> (environmentFieldValue?.find{(it+"").equals(environment.id+"")})}
return """
<tr>
<td><a target="_blank" href="${baseUrl()}/browse/${issue.key}">${issue.key}</a></td>
<td>${issue.summary}</td>
<td>${displayedDate(startDate)} - ${displayedDate(endDate)}</td>
<td>${displayedEnvironments(environments)}</td>
</tr>
"""
}.join("\n")
}
private void writeHelpText(String helpText) {
FormField endField = ctx.getFieldByName(helpFieldName ?: environmentFieldName)
if (blocking) {
endField.setError(helpText)
} else {
endField.setHelpText(helpText)
}
}
private List<PlannedEvent> searchForConflictsInCalendar(Date startFieldValue, Date endFieldValue, List environmentFieldValue, Integer calendarId) {
if (calendarId == null) {
return Collections.emptyList()
}
PlannedEventSearchCriteria criteria = PlannedEventSearchCriteria.builder()
.calendarId(calendarId)
.startBefore(endFieldValue)
.endAfter(startFieldValue)
.build()
List<PlannedEvent> events = plannedEventSearcher().findBy(criteria).getEntity()
List<Integer> environmentIds = environmentFieldValue.collect { Integer.valueOf(it) }
return events.findAll {event ->
if (event.getPlannedEnvironments() == null) {
return false
}
def conflictEnvIds = event.getPlannedEnvironments()
.collect { it.getEnvironmentId() }
.findAll { environmentIds.contains(it) }
return !conflictEnvIds.isEmpty()
}
}
private List<Issue> searchForConflicts(Date startFieldValue, Date endFieldValue, List environmentFieldValue) {
Query query = toQuery(startFieldValue, endFieldValue, environmentFieldValue)
PagerFilter pager = PagerFilter.newPageAlignedFilter(0,50)
SearchResults<Issue> results = searchService().searchOverrideSecurity(loggedInUser(), query, pager)
log.debug("Found ${results.getTotal()} issues corresponding to search")
IssueContext issueContext = ctx.getIssueContext()
if (issueContext instanceof WithKey) {
String issueKey = ((WithKey)issueContext).getKey()
log.debug("Issue context with key ${issueKey}")
return results.getResults().findAll { !it.key.equals(issueKey) }
} else {
log.debug("Issue context without key")
return results.getResults()
}
}
private Query toQuery(Date startFieldValue, Date endFieldValue, List environmentFieldValue) {
String environmentFieldValueString = environmentFieldValue.join(",")
String stringQuery = """
type = "${issueTypeName}"
AND "${startDateFieldName}" < "${jqlDate(endFieldValue)}"
AND "${endDateFieldName}" > "${jqlDate(startFieldValue)}"
AND "${environmentFieldName}" in (${environmentFieldValueString})
"""
log.debug("query is: ${stringQuery}")
return new DefaultJqlQueryParser().parseQuery(stringQuery)
}
private Date getDateValue(String fieldName) {
try {
FormField dateField = ctx.getFieldByName(fieldName)
return dateField ? (Date) dateField.getValue() : null
} catch (GroovyCastException ex) {
return null
}
}
private List getEnvironmentsValue(String fieldName) {
try {
FormField environmentField = ctx.getFieldByName(fieldName)
return environmentField ? (List) environmentField.getValue() : null
} catch (GroovyCastException ex) {
return null
}
}
private String displayedDate(Date date) {
return date.format(displayDateFormat())
}
private String displayedEnvironments(List environments) {
return environments ? environments.collect{ it.name }.join("<br />") : "-"
}
private String displayedPlannedEnvironments(List plannedEnvironments) {
return displayedEnvironments(plannedEnvironments.collect{ envSearcher().getById(it.getEnvironmentId()).getEntity() })
}
private String displayDateFormat() {
def format = dateFormat != null ? dateFormat : ComponentAccessor.getApplicationProperties().getString(JIRA_DATE_TIME_PICKER_JAVA_FORMAT)
log.debug("date format to be used will '${format}'")
return format
}
private String jqlDate(Date date) {
return date.format(JIRA_JQL_DATE_FORMAT)
}
private String baseUrl() {
return ComponentAccessor.getApplicationProperties().getString("jira.baseurl")
}
private CustomField getCustomFieldByName(String name) {
return ComponentAccessor.getCustomFieldManager().getCustomFieldObjectByName(name)
}
private SearchService searchService() {
return ComponentAccessor.getComponent(SearchService.class)
}
private ApplicationUser loggedInUser() {
return ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
}
private EnvironmentServices envServices() {
final Class serviceClass = ComponentAccessor.getPluginAccessor().getClassLoader().findClass("com.apwide.env.service.GoliveEnvironmentServices")
return ComponentAccessor.getOSGiComponentInstanceOfType(serviceClass)
}
private PlannedEventSearcher plannedEventSearcher() {
return envServices().getPlannedEventSearcher()
}
private EnvironmentSearcher envSearcher() {
return envServices().getEnvSearcher()
}
}
Questions?
Jira is very powerful for its workflows, that’s why we have decided to rely on it for our Booking System, instead of implementing our own system. The setup may be a little complex for Jira beginners, that’s why we offer free assistance for this configuration.
If you need our help, contact us.