floodmeadows / Jira create iOS or Services ticket from Android

// ==UserScript==
// @name         Jira create iOS or Services ticket from Android
// @namespace    https://openuserjs.org/users/floodmeadows
// @description  Adds buttons to create a linked iOS or Microservices ticket based on an existing Android ticket. The name of the new story will be based on the name of the current story; it will have a "relates to" link to the current story; the Component will be set to "iOS" or "Microservices"; it will be linked to the same epic and have the same state and assignee as the current story.
// @copyright    2021, floodmeadows (https://openuserjs.org/users/floodmeadows)
// @license      MIT
// @version      0.4
// @include      https://jira.*.uk/browse/*
// @updateURL    https://openuserjs.org/meta/floodmeadows/Jira_create_iOS_or_Services_ticket_from_Android.meta.js
// @downloadURL  https://openuserjs.org/install/floodmeadows/Jira_create_iOS_or_Services_ticket_from_Android.user.js
// @grant        none
// ==/UserScript==

/* jshint esversion: 6 */

//--- Customise these to your Jira project ----//
const customFieldForEpicLink = "customfield_10008"; // see https://stackoverflow.com/questions/24385644/jira-api-get-epic-for-issue
const customFieldForStoryPoints = "customfield_10004"
//---------------------------------------------//

const debug = true;

(function () {
    'use strict';

    addButton("Create iOS ticket","iOS","Android","iOS"); // button text, component, text to search for at end of current story name, text to replace it with in new story
    addButton("Create Services ticket","Microservices","Android","Services");
})();

function addButton(buttonText,component,searchStringInCurrentStoryName,replaceStringInNewStoryName) {
    const newElement = document.createElement("a");
    newElement.setAttribute("href", "#");
    newElement.setAttribute("class", "aui-button toolbar-trigger issueaction-workflow-transition");
    newElement.addEventListener("click", function () { createIssue(component,searchStringInCurrentStoryName,replaceStringInNewStoryName); });
    const text = document.createTextNode(buttonText);
    newElement.appendChild(text);
    const target = document.getElementById('opsbar-opsbar-admin');
    target.appendChild(newElement);
}

function createIssue(component,searchStringInCurrentStoryName,replaceStringInNewStoryName) {
    //--- Get standard info ---//
    const currentUrl = new URL(document.URL);
    const jiraBaseUrl = currentUrl.protocol + '//' + currentUrl.host;
    const createIssueUrl = jiraBaseUrl + '/rest/api/latest/issue';
    const addLinkUrl = jiraBaseUrl + '/rest/api/latest/issueLink';

    // Parse the Jira project key out of the current URL. e.g. if the current issue key is "ABC-1234" then the project key will be ABC.
    const pathArr = location.pathname.split("/");
    const jiraProjectKey = pathArr[pathArr.length-1].split("-")[0];

    const currentIssueKey = document.getElementById("key-val").childNodes[0].nodeValue;
    const currentIssueSummary = document.getElementById("summary-val").childNodes[0].nodeValue;
    const newIssueSummary = newIssueSummaryFromCurrentIssueName(currentIssueSummary,searchStringInCurrentStoryName,replaceStringInNewStoryName);

    const newIssueDescription = `See ${currentIssueKey} for details.`;

    const currentIssueTypeName = document.getElementById('type-val').childNodes[2].nodeValue.trim();
    const issueTypeId = issueTypeIdFromName(currentIssueTypeName);

    const currentIssueEpicBaseDomElement = document.getElementById(`${customFieldForEpicLink}-val`);
    var newIssueEpicLink = null;
    if (currentIssueEpicBaseDomElement === undefined || currentIssueEpicBaseDomElement == null) {
        console.log('Error: Current issue is not assigned to an epic.');
    } else {
        newIssueEpicLink = currentIssueEpicBaseDomElement.children[0].attributes.href.nodeValue.substring(8);
    }

    const currentIssueStoryPointsBaseDomElement = document.getElementById(`${customFieldForStoryPoints}-val`);
    var newIssueStoryPoints = null;
    if (currentIssueStoryPointsBaseDomElement === undefined || currentIssueStoryPointsBaseDomElement == null) {
        console.log('Notice: Current issue has no story points.');
    } else {
        newIssueStoryPoints = Number(currentIssueStoryPointsBaseDomElement.childNodes[0].nodeValue.trim());
    }

    // Copying the sprint value
    // ------------------------
    // It's not possible to set the sprint value directly in issue details, as with other issue properties (e.g. Summary or Story points).
    // Have to find sprint ID, then POST to /rest/agile/1.0/sprint/{sprintId}/issue to move the issue into that sprint
    // See https://developer.atlassian.com/cloud/jira/software/rest/api-group-sprint/#api-rest-agile-1-0-sprint-sprintid-issue-post
    // Issue ID can be found using document.getElementsByClassName('js-find-on-board-sprint')[0].href (e.g. 'https://jira.tools.tax.service.gov.uk/secure/FindOnBoard.jspa?sprintId=19012')
    // Then need to parse the sprintId from the query string param
    // Also need to check if document.getElementsByClassName('js-find-on-board-sprint')[0].parentNode.parentNode.children[0].childNodes[0].nodeValue == "Active Sprint:" || "Future Sprint:"
    // If the nodeValue == "Completed Sprint:" || "Completed Sprints:", or if document.getElementsByClassName('js-find-on-board-sprint') is null or empty, then the current issue is not in a current or future sprint and the new issue should not be moved to a sprint.

    const currentIssueState = document.getElementById('opsbar-transitions_more').children[0].innerHTML;
    const newIssueTransitionName = currentIssueState;

    const currentIssueAssigneeBaseDomElement = document.getElementById('assignee-val').children[0].attributes.rel;
    var newIssueAssignee = null;
    if( currentIssueAssigneeBaseDomElement === undefined || currentIssueAssigneeBaseDomElement == null ) {
        console.log('Notice: Current issue is not assigned to anyone.');
    } else {
        newIssueAssignee = currentIssueAssigneeBaseDomElement.value;
    }
    //---------------------------//

    var headers = new Headers();
    headers.append("Content-Type", "application/json");

    const jsonToCreateNewIssue = JSON.stringify({
        "fields": {
            "project": {
                "key": jiraProjectKey
            },
            "summary": newIssueSummary,
            "description": newIssueDescription,
            "issuetype": {
                "id": issueTypeId
            },
            "components": [{
                "name": component
            }],
            "customfield_10008": newIssueEpicLink, //todo: work out how to use customFieldForEpicLink for the property name
            "customfield_10004": newIssueStoryPoints, //todo: work out how to use customFieldForStoryPoints for the property name
            "assignee": {
                "name": newIssueAssignee
            }
        }
    });

    if (debug) console.log(jsonToCreateNewIssue);

    var requestOptions = {
        method: 'POST',
        headers: headers,
        body: jsonToCreateNewIssue
    };

    fetch(createIssueUrl, requestOptions)
        .then(response => {
            const jsonPromise = response.json()
                .then(data => {
                    const newIssue = data;
                    const newIssueKey = newIssue.key;
                    console.log(newIssueKey);

                    var jsonToAddLink = "";
                    jsonToAddLink = JSON.stringify({
                        "type": {
                            "name": "Relates",
                            "inward": "relates to",
                            "outward": "relates to"
                        },
                        "inwardIssue": {
                            "key": currentIssueKey
                        },
                        "outwardIssue": {
                            "key": newIssueKey
                        }
                    });

                    requestOptions = {
                        method: 'POST',
                        headers: headers,
                        body: jsonToAddLink
                    };

                    fetch(addLinkUrl, requestOptions)
                        .then(response => {
                            console.log(response.text());
                            const updateIssueUrl = `${jiraBaseUrl}/rest/api/latest/issue/${newIssueKey}/transitions`;

                            var jsonToUpdateStatus = "";
                            jsonToUpdateStatus = JSON.stringify({
                                "transition": {
                                    "id": issueTransitionIdFromName(newIssueTransitionName),
                                    "name": newIssueTransitionName
                                }
                            });

                            requestOptions = {
                                method: 'POST',
                                headers: headers,
                                body: jsonToUpdateStatus
                            };

                            fetch(updateIssueUrl, requestOptions)
                            .then(response => {
                                console.log(response.text());
                                if(!debug) window.location.assign(currentUrl);
                            })
                            .catch(error => console.log('error', error));

                        })
                        .catch(error => console.log('error', error));
                });
        })
        .catch(error => console.log('error', error));
}

function newIssueSummaryFromCurrentIssueName(currentIssueSummary,searchString,replaceString) {
    var newIssueName = "";
    if( currentIssueSummary.endsWith(searchString) || currentIssueSummary.endsWith(searchString+')') || currentIssueSummary.endsWith(searchString+']') ) {
		var pos = currentIssueSummary.lastIndexOf(searchString)
        var currentIssueNameBeforePlatformName = currentIssueSummary.substring(0,pos)
        var currentIssueNameAfterPlatformName = currentIssueSummary.substring(pos+searchString.length,currentIssueSummary.length)
        newIssueName = currentIssueNameBeforePlatformName + replaceString + currentIssueNameAfterPlatformName;
    } else {
        newIssueName = `${currentIssueSummary} (${replaceString})`;
    }
    return newIssueName;
}

function issueTypeIdFromName(name) {

    //Todo: make a call to /rest/api/2/issue/createmeta/{projectIdOrKey}/issuetypes and look up the issueTypeId from the response, rather than having the list hard coded here
    const issueTypeIds = new Map([
        ["Bug", "1"],
        ["Technical Story", "10"],
        ["Documentation", "10000"],
        ["UX/UR", "11000"],
        ["Tech-debt", "11301"],
        ["Spike", "12"],
        ["Technical Debt", "13"],
        ["UI Story", "14"],
        ["New Feature", "2"],
        ["Task", "3"],
        ["Improvement", "4"],
        ["Sub-task", "5"],
        ["Epic", "6"],
        ["Story", "7"],
        ["Technical task", "8"]
    ]);
    // The values are specified as text, not integers, because that's how Jira returns them.
    // To get these values for your instance of Jira, you can use https://openuserjs.org/scripts/floodmeadows/Jira_get_issue_types

    if( issueTypeIds.has(name) ) {
        return issueTypeIds.get(name);
    } else {
        console.log("Error: No issue type ID in lookup table for current issue type (" + name + ").");
        return false;
    }
}

function issueTransitionIdFromName(name) {

    const issueTransitionIds = new Map([
        ["Backlog", "11"],
        ["Done", "41"],
        ["In Dev", "61"],
        ["Ready for Dev", "71"],
        ["In PR", "81"],
        ["In Test", "91"],
        ["Ready for Release", "101"],
        ["To Do", "111"],
        ["In Analysis", "121"],
        ["Ready for Refinement", "141"],
        ["Won't Fix", "161"],
        ["In Progress", "171"],
        ["Ready for Test", "191"],
        ["In Review", "261"],
        ["Reporting", "321"],
        ["To be Prioritised", "331"]
    ]);
    // To get these values for your instance of Jira, you can use https://openuserjs.org/scripts/floodmeadows/Jira_get_issue_transitions

    if( issueTransitionIds.has(name) ) {
        return issueTransitionIds.get(name);
    } else {
        console.log("Error: No transition ID for '" + name + "'");
        return false;
    }
}