Create
cancel
Showing results for 
Search instead for 
Did you mean: 
Sign up Log in

Allow non-admins to manage Versions/Releases in Jira Data Center

Matt Parks
Community Leader
Community Leader
Community Leaders are connectors, ambassadors, and mentors. On the online community, they serve as thought leaders, product experts, and moderators.
September 8, 2023

I do not pretend that this is the most elegant solution, but it works for my org. I am running Jira Data Center in a two-node cluster. Currently on 8.20.1, Scriptrunner 6.38.0, and JSU Automation Suite 2.32.0

What needs to be configured prior to creating the listener:

  1. A new Issue Type called Version
    1. Workflow just goes straight to Done, with a secondary transition back to Done that clears out the Fix Version (this will make sense later) and sets the Resolution to Done (this is done via a Fast Track transition). It also triggers a new custom event called 'Update Version'. It has the following validators (need both Scriptrunner and JSU plugins)
      1. Start Date must be less than the Release Date
      2. The Action field is required
      3. The following simple scripted validator
        1. import com.atlassian.jira.component.ComponentAccessor
          import com.atlassian.jira.issue.IssueManager
          import com.atlassian.jira.issue.CustomFieldManager
          import com.atlassian.jira.project.version.VersionManager
          import com.atlassian.jira.project.version.Version
          import com.atlassian.jira.issue.Issue
          import com.opensymphony.workflow.InvalidInputException

          def relDateField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectsByName('Release Date').first()
          def startDateField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectsByName('Start Date').first()
          def versNameField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectsByName('Version Name').first()
          def actionField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectsByName('Action').first()
          def versManager = ComponentAccessor.getVersionManager()

          def actionValue = issue.getCustomFieldValue(actionField) as String
          def startDateValue = issue.getCustomFieldValue(startDateField) as Date
          def relDateValue = issue.getCustomFieldValue(relDateField) as Date
          def versNameValue = issue.getCustomFieldValue(versNameField) as String
          def projId = issue.getProjectId()
          def versionList = issue.getFixVersions()
          Date versRelDate
          Date versStartDate

          if (startDateValue && relDateValue)
          {
              return true
          }

          if (!startDateValue && !relDateValue)
          {
              return true
          }

          versionList.each
          {
              if (relDateValue)
              {
                  versStartDate = it.getStartDate()
                  if (versStartDate?.compareTo(relDateValue) >=0 && versStartDate != null)
                  {
                      throw new InvalidInputException("Start Date must be before Release Date on ${it.getName()}")
                      return false
                  }
              }

              if (startDateValue)
              {
                  versRelDate = it.getReleaseDate()
                  if (versRelDate?.compareTo(startDateValue) <=0 && versRelDate != null)
                  {
                      throw new InvalidInputException("Start Date must be before Release Date on ${it.getName()}")
                      return false
                  }
              }
          }

      4. A new scripted validator that confirms that the user who is creating the new Version issue has the 'Version Manager' or Administrators Project Role (note: requires that a new Project Role called 'Version Manager' be created).
        1. import com.atlassian.jira.component.ComponentAccessor
          import com.opensymphony.workflow.InvalidInputException
          import com.atlassian.jira.project.Project
          import com.atlassian.jira.security.roles.ProjectRole
          import com.atlassian.jira.security.roles.ProjectRoleManager


          def projRoleMan = ComponentAccessor.getComponent(ProjectRoleManager.class)
          def projRoleVersMan = projRoleMan.getProjectRole("Version Manager")
          def projRoleAdmin = projRoleMan.getProjectRole("Administrators")
          def proj = issue.getProjectObject()
          def user = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()

          if (projRoleMan.isUserInProjectRole(user, projRoleVersMan, proj) || projRoleMan.isUserInProjectRole(user, projRoleAdmin, proj))
          {
              return true
          }

          throw new InvalidInputException("You do not have permission to create Releases in this project")

    2. A Create/View screen with the following fields (new custom fields marked as such)
      1. Summary
      2. Action (single select with options of Create, Update, Release)
      3. Version Name (single line text)
      4. Fix Version/s
      5. Release Date (date picker)
      6. Start Date (date picker)
      7. Description
    3. An Edit screen that only has the Summary field
  2. A Scriptrunner behavior that triggers off of the 'Action' field with the following server-side script. The purpose is to only show the appropriate fields, depending on what value is selected in the Action field:
    1. def actionField = getFieldByName("Action")
      def versNameField = getFieldByName("Version Name")
      def relDateField = getFieldByName("Release Date")
      def startDateField = getFieldByName("Start Date")
      def fixVersField = getFieldByName("Fix Version/s")

      def actionValue = actionField.getValue()

      if (actionValue == "Create")
      {
          versNameField.setHidden(false)
          versNameField.setRequired(true)
          relDateField.setHidden(true)
          relDateField.setRequired(false)
          startDateField.setHidden(true)
          startDateField.setRequired(false)
          fixVersField.setHidden(true)
          fixVersField.setRequired(false)
      }

      if (actionValue == "Update")
      {
          versNameField.setHidden(false)
          versNameField.setRequired(false)
          relDateField.setHidden(false)
          relDateField.setRequired(false)
          startDateField.setHidden(false)
          startDateField.setRequired(false)
          fixVersField.setHidden(false)
          fixVersField.setRequired(true)
      }

      if (actionValue == "Release")
      {
          versNameField.setHidden(true)
          versNameField.setRequired(false)
          relDateField.setHidden(false)
          relDateField.setRequired(false)
          startDateField.setHidden(false)
          startDateField.setRequired(false)
          fixVersField.setHidden(false)
          fixVersField.setRequired(true)
      }

  3. A Scriptrunner Listener that triggers off of the 'Version Update' event. Everything that happens is based off of the 'Action' field.
    1. If the Action field is set to 'Create', a new Fix Version is created, using the values in the Version Name, Start Date, Release Date, and Description field. It automatically puts it in the same project as the one you used to create the new Version issue
    2. If the Action field is set to Update, it will update the Start/Release Dates along with the Description, based on the values entered in the Version issue type that you just created
    3. If the Action field is set to Release, it will release all of the Versions in the Fix Versions field of the Version issue that you just created. Additionally, it will cycle through all of the issues that have that Fix Version in them and will try to execute the 'Done' transition (basically, if they are in the status right before Done in their workflow, it will transition them to Done for you). If the work item(s) with that Fix Version are NOT in either Done or the status right before Done, they will be removed from the Fix Version. Basically, they weren't ready to go, so they get pulled out. 
    4. import com.atlassian.jira.component.ComponentAccessor
      import com.atlassian.jira.issue.IssueManager
      import com.atlassian.jira.issue.CustomFieldManager
      import com.atlassian.jira.project.version.VersionManager
      import com.atlassian.jira.project.version.Version
      import com.atlassian.jira.issue.Issue
      import com.atlassian.jira.security.JiraAuthenticationContext;
      import com.atlassian.jira.event.type.EventDispatchOption;
      import com.atlassian.jira.bc.issue.search.SearchService;
      import com.atlassian.jira.issue.search.SearchProvider;
      import com.atlassian.jira.jql.parser.JqlQueryParser;
      import com.atlassian.jira.bc.issue.IssueService;
      import com.atlassian.jira.user.ApplicationUser;
      import com.atlassian.jira.web.bean.PagerFilter;
      import com.atlassian.jira.issue.MutableIssue;
      import com.atlassian.jira.user.util.UserUtil;
      import com.atlassian.jira.util.ImportUtils;
      import groovy.transform.Field;
      import com.atlassian.jira.workflow.WorkflowTransitionUtilImpl;
      import com.atlassian.jira.issue.index.IssueIndexingService;
      import com.atlassian.jira.workflow.WorkflowTransitionUtil;
      import com.opensymphony.workflow.loader.ActionDescriptor;
      import com.opensymphony.workflow.loader.StepDescriptor;
      import com.atlassian.jira.workflow.WorkflowManager;  
      import com.opensymphony.workflow.WorkflowContext;
      import com.atlassian.jira.workflow.JiraWorkflow;
      import com.atlassian.jira.util.ErrorCollection;
      import com.atlassian.jira.util.JiraUtils;
      import com.atlassian.jira.issue.DocumentIssueImpl
      import com.atlassian.jira.workflow.IssueWorkflowManager
      @field boolean writeDebugLogs = true;
      @field ApplicationUser user = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser();
      @field SearchService searchService = ComponentAccessor.getComponent(SearchService.class);
      @field IssueManager issueManager = ComponentAccessor.getIssueManager();
      @field UserUtil userUtil = ComponentAccessor.getUserUtil();
      @field IssueIndexingService issueIndexingService = ComponentAccessor.getComponent(IssueIndexingService);
      @field WorkflowManager workflowManager = ComponentAccessor.getComponent(WorkflowManager);
      def issue = event.issue
      def relDateField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectsByName('Release Date').first()
      def startDateField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectsByName('Start Date').first()
      def versNameField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectsByName('Version Name').first()
      def actionField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectsByName('Action').first()
      def versManager = ComponentAccessor.getVersionManager()
      def actionValue = issue.getCustomFieldValue(actionField) as String
      def startDateValue = issue.getCustomFieldValue(startDateField) as Date
      def relDateValue = issue.getCustomFieldValue(relDateField) as Date
      def versNameValue = issue.getCustomFieldValue(versNameField) as String
      def projId = issue.getProjectId()
      def versionList = issue.getFixVersions()
      MutableIssue mutIssue
      if (actionValue == 'Create')
      {
          versManager.createVersion(versNameValue, startDateValue, relDateValue, issue.description, projId, null, false)
      }
      if (actionValue == 'Update')
      {
          versionList.each
          {
              if (issue.description || versNameValue)
              {
                  def updateDesc = issue.description ? issue.description : it.getDescription()
                  def updateVersName = versNameValue ? versNameValue : it.getName()
                  it = versManager.editVersionDetails(it, updateVersName, updateDesc)
                  versManager.update(it)
              }
              
              if (relDateValue)
              {
                  it = versManager.editVersionReleaseDate(it, relDateValue)
                  versManager.update(it)
              }
              
              if (startDateValue)
              {
                  it = versManager.editVersionStartDate(it, startDateValue)
                  versManager.update(it)
              }
          }
      }
      if (actionValue == 'Release')
      {
          versManager.releaseVersions(versionList, true)
          
          versionList.each
          {
              def issuesInVersion = versManager.getIssuesWithFixVersion(it)
              
              issuesInVersion.each
              {
                  def item = issueManager.getIssueObject(it.getKey());
                  mutIssue = (MutableIssue) item
                  tryTransitionIssue(mutIssue, "Done")
              }
          }
          
      }
      int getTransitionIdByName(MutableIssue issue, String transitionName)
      {
          writeLog("Trying to get transition ID for " + transitionName)
          def issueWorkflow = ComponentAccessor.getComponentOfType(IssueWorkflowManager.class);
          Collection<ActionDescriptor> actions = issueWorkflow.getAvailableActions(issue, user);
          
          for (action in actions)
          {
              
              def stepName = action.getName();
              if (stepName != transitionName)
              {
                  writeLog(stepName + " did not match " + transitionName)
                  continue;
              }
              writeLog("Found " + stepName)
              return action.getId();
          }
          
      writeLog("didn't find any step name match")
          if (issue.status.name != "Done")
          {
              issue.setFixVersions(null)
              ComponentAccessor.getIssueManager().updateIssue(user, issue, EventDispatchOption.DO_NOT_DISPATCH, false)
              reindexIssue(issue)
          }
          
          return -1;
      }
      void tryTransitionIssue(MutableIssue issue, String transitionName)
      {  
          writeLog("Trying to transition issue")
          def actionId = getTransitionIdByName(issue, transitionName);
          if (actionId <= 0)
          {
              return;
          }
          
          def workflowTransitionUtil = (WorkflowTransitionUtil) JiraUtils.loadComponent(WorkflowTransitionUtilImpl.class);
          Map<String, String> params = new HashMap<String, String>();
          workflowTransitionUtil.setUserkey(user.getKey());
          workflowTransitionUtil.setAction(actionId);
          workflowTransitionUtil.setIssue(issue);
          workflowTransitionUtil.setParams(params);
          def errs = workflowTransitionUtil.validate();
          if (hasErrors(errs))
          {
              return;
          }
          workflowTransitionUtil.progress();
          
          def commentManager = ComponentAccessor.getCommentManager()
          def commentBody = "Issue has been transitioned to ${transitionName} because the sprint was started"
          commentManager.create(issue, user, commentBody, false)
          
          reindexIssue(issue);
      }
      void reindexIssue(Issue issue)
      {
          boolean wasIndexing = ImportUtils.isIndexIssues();
          ImportUtils.setIndexIssues(true);
          issueIndexingService.reIndex(issueManager.getIssueObject(issue.id));
          ImportUtils.setIndexIssues(wasIndexing);
      }
       
      boolean hasErrors(ErrorCollection errs)
      {
          if (errs == null)
          {
              return false;
          }
          int errCount = 0;
          def errMaps = errs.getErrors();
          def errMsgs = errs.getErrorMessages();
          if (errMsgs != null)
          {
              errCount = errCount + errMsgs.size();
          }
       
          if (errMaps != null)
          {
              errCount = errCount + errMaps.size();
          }
          if (errCount == 0)
          {
              return false;
          }
          for(errMsg in errMsgs)
          {
              log.error(errMsg);
          }
          for(errMap in errMaps)
          {
              log.error("Field ${errMap.key} Error: ${errMap.value}");
          }
          return true;
      }
      void writeLog(String note)
      {
          writeLog(note, writeDebugLogs);
      }
      void writeLog(String note, boolean write)
      {
          if (!write)
          {
              return;
          }
          log.error(note);
      }

1 comment

Comment

Log in or Sign up to comment
Laurie Sciutti
Community Leader
Community Leader
Community Leaders are connectors, ambassadors, and mentors. On the online community, they serve as thought leaders, product experts, and moderators.
September 8, 2023

Awesome, thanks @Matt Parks ! 

ref: workaround for JRASERVER-12891.

TAGS
AUG Leaders

Atlassian Community Events