Jira quick actions

Custom quick actions (commit log, work log), check of issues (status, assignee) and show week work log in JIRA 6.x

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Jira quick actions
// @namespace    sremy
// @version      1.12
// @description  Custom quick actions (commit log, work log), check of issues (status, assignee) and show week work log in JIRA 6.x
// @author       Sébastien REMY
// @match        https://*jira*/browse/*
// @match        http://localhost:8080/browse/*
// @require      https://cdn.jsdelivr.net/npm/clipboard@2/dist/clipboard.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/notify.min.js
// @grant        none
// ==/UserScript==

// ** Constants to customize **
// Name of custom field for "Epic link"
const CUSTOM_FIELD_EPIC_LINK = 'customfield_10006';
//const CUSTOM_FIELD_EPIC_LINK = jQuery('div[data-fieldtype="gh-epic-link"]').attr('id')

// Customize these status for the function checkIssueStatus(,) !
const INITIAL_STATUS_LIST = ['TO DO', 'OPEN'];
const NEXT_STATUS_SELECTOR_LIST = ['#action_id_21', '#action_id_41'];


var $ = jQuery; // or https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js

let worklogToClipboard;

let clipboard = new ClipboardJS('#clipboardBtn', {
    text: function(trigger) {
        return $('#key-val').text() + ' ' + $('#summary-val').text();
    }
});

clipboard.on('success', function(e) {
    $.notify("Copied to clipboard. " + e.text, "info");
});

clipboard.on('error', function(e) {
    $.notify("Failed to copy", "error");
});

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function rafAsync() {
    return new Promise(resolve => {
        // Callback function called before the browser performs the next repaint
        requestAnimationFrame(resolve); // faster than set time out
    });
}

// Waits until the element pointed by selector exists on the page
function whenElementExists(selector) {
    if (document.querySelector(selector) === null) {
        return rafAsync().then(() => whenElementExists(selector));
    } else {
        return Promise.resolve(true);
    }
}

/*
async function addWorkLog(duration) {
    jQuery('#log-work').click();
    whenElementExists('#log-work-time-logged') // work log input text
        .then(() => {
        jQuery('#log-work-time-logged')[0].value = duration;
        jQuery('#log-work-submit').click();
    });

    // BUG: query has no time to be sent, overrided by following //
    assignToMe();

    checkIssueStatus();
}*/


function addWorkLogDateRest(worklogDate, duration, callback = {}) {
    // POST /rest/api/2/issue/{issueIdOrKey}/worklog

    worklogDate = worklogDate.replace(/Z/, '+0000');

    //console.log(worklogDate);
    let bodyContent = {
        "timeSpent": duration,
        "started": worklogDate  // "2020-02-29T00:46:47.624+0000"
    };
    const options = {
        method: 'POST',
        body: JSON.stringify(bodyContent),
        headers: {
            'Content-Type': 'application/json'
        }
    };
    const issueId = JIRA.Issue.getIssueId();
    fetch(`/rest/api/2/issue/${issueId}/worklog`, options)
        .then(callback)
        .then(refreshPage);
        //.then(res => res.json())
        //.then(res => console.log(res));

    if(getAssignee() === undefined) {
        assignToMe();
    }

    checkIssueStatus(INITIAL_STATUS_LIST, selectFirstExistingStatus(NEXT_STATUS_SELECTOR_LIST));
}

// Add worklog starting now
function addWorkLogNowRest(duration) {
    addWorkLogDateRest((new Date().toISOString()).replace(/Z/, '+0000'), duration);
}


// Return the selector status present in the page among the list
function selectFirstExistingStatus(statusSelectorList) {
    for(let status of statusSelectorList) {
        if($(status).length > 0) {
            return status;
        }
    }
    return null;
}

// Return true if the string 'searchedString' is contained (ignoring case) in the array 'stringslist'
function isInList(searchedString, stringslist) {
    if (!searchedString) return false;
    for (let item of stringslist) {
        if (item && item.toUpperCase() === searchedString.toUpperCase()) {
            return true;
        }
    }
    return false;
}

// If issue status is still the initial one, I move to the next one
function checkIssueStatus(initialStatusList, nextStatusSelector) {
    if(nextStatusSelector === null)
        return;
    let selectIssueStatus = jQuery('#status-val');
    if(selectIssueStatus.length) {
        let status = selectIssueStatus[0].innerText;
        if(isInList(status, initialStatusList)) {
            let selectNextStatus = $(nextStatusSelector);
            if(selectNextStatus.length === 1) {
                $.notify("Status was still " + status + " => changing to " + selectNextStatus[0].innerText, "warn");
                selectNextStatus.click();
            }
        }
    }
}

// If issue is not assigned to me, I assign to me
function assignToMe() {
    const SELECTOR_ASSIGN_TO_ME = '#assign-to-me';
    let selectAssignToMe = jQuery(SELECTOR_ASSIGN_TO_ME);
    if(selectAssignToMe.length) {
        $.notify("Issue assigned to you", "info");
        selectAssignToMe[0].click();
    }
}

// Get the User assigned to the current issue or return undefined if not assigned
function getAssignee() {
    let spanAssignee = jQuery("#assignee-val");
    if(spanAssignee.length) {
        return spanAssignee.children('span[rel]').attr('rel');
    }
    return undefined;
}

function reqListener () {
  console.log(this.responseText);
}

function jsonReqListener () {
  console.log(this.response);
}

function getCookie(cname) {
  var name = cname + "=";
  var decodedCookie = decodeURIComponent(document.cookie);
  var ca = decodedCookie.split(';');
  for(var i = 0; i <ca.length; i++) {
    var c = ca[i];
    while (c.charAt(0) == ' ') {
      c = c.substring(1);
    }
    if (c.indexOf(name) == 0) {
      return c.substring(name.length, c.length);
    }
  }
  return "";
}

// linkType = 'clones', 'blocks', etc
// issueKeyLinked = JIRA-007
function createLinkRest(linkType, issueKeyLinked) {

    let issueId = $('#key-val').attr('rel'); // example: 10003
    let atlToken = getCookie('atlassian.xsrf.token');
    let params = `inline=true&decorator=dialog&id=${issueId}&jiraAppId=&linkDesc=${linkType}&issueKeys=${issueKeyLinked}&comment=&atl_token=${atlToken}`;
    //console.log(params);

    let req = new XMLHttpRequest();
    //req.addEventListener("load", reqListener);
    req.open("POST", "/secure/LinkJiraIssue.jspa");
    req.addEventListener("load", refreshPage);
    req.setRequestHeader('Content-type', 'application/x-www-form-urlencoded; charset=UTF-8');
    req.send(params);
    refreshPage();
}


// issueKeyLinked = JIRA-007
// Set the constant customField to reflect the Epic custom field (or what you want)
function setEpicRest(epicIssueKey) {
    setCustomFieldRest(CUSTOM_FIELD_EPIC_LINK, epicIssueKey);
}

function setCustomFieldRest(customField, epicIssueKey) {
    let issueId = $('#key-val').attr('rel'); // example: 10003
    let atlToken = getCookie('atlassian.xsrf.token');
    let params = `${customField}=key%3A${epicIssueKey}&issueId=${issueId}&atl_token=${atlToken}&singleFieldEdit=true&fieldsToForcePresent=${customField}`;
    //console.log(params);

    let req = new XMLHttpRequest();
    req.open("POST", "/secure/AjaxIssueAction.jspa");
    req.addEventListener("load", refreshPage);
    req.setRequestHeader('Content-type', 'application/x-www-form-urlencoded; charset=UTF-8');
    req.send(params);
}


/*
issue_list =
[
  {
    "expand": "operations,editmeta,changelog,transitions,renderedFields",
    "id": "10200",
    "self": "http://localhost:8080/rest/api/2/issue/10200",
    "key": "SEB-7",
    "fields": {
      "worklog": {
        "startAt": 0,
        "maxResults": 20,
        "total": 5,
        "worklogs": [
          {
            "self": "http://localhost:8080/rest/api/2/issue/10200/worklog/10300",
            "author": {
              "self": "http://localhost:8080/rest/api/2/user?username=admin",
              "name": "admin",
              "key": "admin",
*/

function filterWorklog(issue_list, author) {
    let worklogByDay = {};
    let issuesWorklogged = {};

    const {start_date, end_date} = getWeekLimit();
    issue_list.forEach(issue => {
        let worklogList = issue.fields.worklog.worklogs
        worklogList.forEach(wl => {
            let wlday = new Date(wl.started)
            if(wl.author.key === author && wlday >= start_date && wlday <= end_date) {
                // keep this worklog & issue
                let dayOfWl = formatSlashDate(wlday);
                let wlDay = worklogByDay[dayOfWl]
                if(wlDay === undefined) {
                    worklogByDay[dayOfWl] = {};
                    worklogByDay[dayOfWl].list = [];
                    worklogByDay[dayOfWl].timeSpent = 0;
                    wlDay = worklogByDay[dayOfWl];
                }
                wl['issue'] = issue;
                wl['issuekey'] = issue.key;
                wl['issueid'] = issue.id;
                wlDay.list.push(wl);
                wlDay.timeSpent += wl.timeSpentSeconds;
                issuesWorklogged[issue.key] = issue.fields.summary;
            }

        });
    });
    //console.log(worklogByDay);

    // Export an issues list KEY: Summary for clipboard
    let summary = '';
    for(let jirakey in issuesWorklogged) {
        summary += jirakey + ': ' + issuesWorklogged[jirakey] + '\n';
    }
    // Choose one of the following: alert dialog or copy to clipboard
    //jQuery('#btn-copy-worklog').on('click', () => showWorkLogSummary(summary));

    if(worklogToClipboard !== undefined) {
        worklogToClipboard.destroy();
    }
    let options = {
        text: function(trigger) {
            $.notify("Copied:\n" + summary, "info");
            return summary;
        }
    };
    worklogToClipboard = new ClipboardJS('#btn-copy-worklog', options);

    return worklogByDay;
}

function showWorkLogSummary(summary) {
    alert(summary);
}

// Returns the date of Monday and Friday of the current week
function getWeekLimit(){
    let monday = new Date();
    monday.setHours(0);
    monday.setMinutes(0);
    monday.setSeconds(0);
    monday.setMilliseconds(0);
    let friday = new Date(monday);
    let monday_day = monday.getDate() - monday.getDay() + 1;
    monday.setDate(monday_day);

    let saturday_day = monday_day + 5;
    friday.setDate(saturday_day);
    friday.setMilliseconds(-1);
    return {start_date: monday, end_date: friday};
}

function jumpNDays(date, daysToAdd) {
    date.setDate(date.getDate() + daysToAdd);
    return date;
}

function formatSlashDate(date)
{// ((date.getMonth() > 8) ? (date.getMonth() + 1) : ('0' + (date.getMonth() + 1))) + '/' + ((date.getDate() > 9) ? date.getDate() : ('0' + date.getDate())) + '/' + date.getFullYear()
    let mois = date.getMonth() + 1;
    return ((date.getDate() > 9) ? date.getDate() : ('0' + date.getDate())) + '/' + ((mois > 9) ? mois : ('0' + mois)) + '/' + date.getFullYear()
}

// Formats date to YYYY-MM-DD
function formatDate(date) {
    let mois = date.getMonth() + 1;
    return date.getFullYear() + '-' + (mois < 10 ? '0' : '') + mois + '-' + (date.getDate() < 10 ? '0' + date.getDate() : date.getDate());
}

function getWeekWorkLog() {
    // GET {{protocol}}://{{host}}/{{basePath}}rest/api/2/search?jql=(worklogDate >= "2020-02-24" AND worklogDate <= "2020-02-28") AND assignee = currentUser() &fields=worklog,summary

    const {start_date, end_date} = getWeekLimit();
    let queryparams = `jql=(worklogDate >= '${formatDate(start_date)}' AND worklogDate <= '${formatDate(end_date)}') AND worklogAuthor = currentUser()&fields=worklog,summary`;
    let req = new XMLHttpRequest();
    req.addEventListener("load", workLogProcessor);
    req.open("GET", "/rest/api/2/search?" + queryparams, true);
    req.responseType = 'json';
    req.setRequestHeader('Content-type', 'application/json; charset=UTF-8');
    req.send();
}

async function fetchLargeWorklog(issue_list) {

    let cumulateWorklog = function(res, issue) {
        //console.log(res);
        issue.fields.worklog = res;
    };

    let queries = [];
    issue_list.forEach(issue => {
        let worklog = issue.fields.worklog;
        if(worklog.total > worklog.maxResults) {

            const options = {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json'
                }
            };
            queries.push( fetch(`/rest/api/2/issue/${issue.key}/worklog`, options)
                .then(res => res.json())
                .then(res => cumulateWorklog(res, issue)) );
        }
    });
    await Promise.all(queries);
}


async function workLogProcessor() {
    let issue_list = this.response['issues'];
    if(issue_list === undefined) {
        console.log('No response. Not logged in?');
        $('#worklog-day-1').append('<div style="font-weight: bold">No response</div>');
        return;
    }
    //console.log(issue_list);

    let worklogByDay = await fetchLargeWorklog(issue_list)
      .then(() => {return filterWorklog(issue_list, JIRA.Users.LoggedInUser.userName())});

    //let worklogByDay = filterWorklog(issue_list, JIRA.Users.LoggedInUser.userName());
    let weekLimit = getWeekLimit();
    let iday = weekLimit.start_date;
    $('#week-start').get(0).innerText = formatSlashDate(weekLimit.start_date);
    $('#week-end').get(0).innerText = formatSlashDate(weekLimit.end_date);

    for(let i = 1; i <= 5; i++) {
        let dayCol = $('#worklog-day-' + i);
        const english_days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
        let dayOfWl = formatSlashDate(iday);

        let spanTimeSpentInDay = '';
        if(worklogByDay[dayOfWl] !== undefined && worklogByDay[dayOfWl].timeSpent !== undefined) {
            let durationInHours = (worklogByDay[dayOfWl].timeSpent / 3600);
            if(durationInHours === 8) {
                spanTimeSpentInDay = `<span class="roundtext greentext">${durationInHours}h</span>`;
            } else {
                spanTimeSpentInDay = `<span class="roundtext redtext">${durationInHours}h</span>`;
            }
        }
        dayCol.append(`<div style="font-weight: bold">${english_days[iday.getDay()]}</div> <div style="border-bottom: 1px solid #ccc; margin-bottom: 15px;">${dayOfWl}${spanTimeSpentInDay}</div>`);
        //console.log(worklogByDay[dayOfWl]);
        if(worklogByDay[dayOfWl] !== undefined) {
            for(let wl of worklogByDay[dayOfWl].list) {
                let link = window.location.toString().replace(/\/[^\/]*$/, '/' + wl.issuekey);
                dayCol.append(`<div style="margin: 5px;"><a href="${link}" title="${wl.issuekey} => ${wl.issue.fields.summary}" >${wl.issuekey}</a>:<span class="roundtext">${wl.timeSpent}</span></div>`);
            }
        }
        jumpNDays(iday, 1);
    }

    let wlDialog = jQuery('#show-worklog-dialog').get(0);
    wlDialog.style.marginTop = -wlDialog.offsetHeight/2 +"px";
}

function showWeekWorkLogDIalog() {


    let htmlDialog = `
    <div id="show-worklog-dialog"
        class="jira-dialog box-shadow jira-dialog-open popup-width-custom jira-dialog-content-ready"
        style="width: 810px; margin-left: -406px; margin-top: -150px;">
        <div class="jira-dialog-heading">
            <div class="aui-toolbar2 qf-form-operations">
                <div class="aui-toolbar2-inner">
                    <div class="aui-toolbar2-secondary">
                        <button id="btn-copy-worklog" class="aui-button">Copy summary</button>
                    </div>
                </div>
            </div>
            <h2 title="Work Log">Work log</h2>
        </div>
        <div class="jira-dialog-content">
            <div class="qf-container">
                <div class="qf-unconfigurable-form">
                    <form name="jiraform" action="#" class="aui">
                        <div class="form-body" style="max-height: 600px;">

                            <div class="qf-field">
                                <div style="margin: 10px;">
                                    <label>Week</label>
                                    <label> from </label>
                                    <label style="color: black;" id="week-start">-</label>
                                    <label> to </label>
                                    <label style="color: black;" id="week-end">-</label>

                                </div>
                            </div>

                            <div class="container">
                                <div class="row">

                                    <div id="worklog-day-1" class="col item">
                                    </div>
                                    <div id="worklog-day-2" class="col item">
                                    </div>
                                    <div id="worklog-day-3" class="col item">
                                    </div>
                                    <div id="worklog-day-4" class="col item">
                                    </div>
                                    <div id="worklog-day-5" class="col item">
                                    </div>

                                </div>
                            </div>

                            <div class="buttons-container form-footer">
                                <div class="buttons">
                                    <span class="icon throbber"></span>
                                    <a href="#" title="Press Esc to cancel" class="cancel"
                                        onclick='let element = document.getElementById("show-worklog-dialog"); element.parentNode.removeChild(element); event.preventDefault();'>Close</a>
                                </div>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
`;

    $('#jira').append(htmlDialog);

    getWeekWorkLog();

}

function refreshPage() {
    JIRA.trigger(JIRA.Events.REFRESH_ISSUE_PAGE, [JIRA.Issue.getIssueId()]);
}

function init() {
    'use strict';
    if(JIRA.Users.LoggedInUser.isAnonymous()) {
        return;
    }

    if(!$('#clipboardBtn').length) {
        $('.toolbar-split-left').append('<button id="clipboardBtn" class="aui-button aui-style">        <svg viewBox="0 0 896 1024" width="15" xmlns="http://www.w3.org/2000/svg">          <path d="M128 768h256v64H128v-64z m320-384H128v64h320v-64z m128 192V448L384 640l192 192V704h320V576H576z m-288-64H128v64h160v-64zM128 704h160v-64H128v64z m576 64h64v128c-1 18-7 33-19 45s-27 18-45 19H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h192C256 57 313 0 384 0s128 57 128 128h192c35 0 64 29 64 64v320h-64V320H64v576h640V768zM128 256h512c0-35-29-64-64-64h-64c-35 0-64-29-64-64s-29-64-64-64-64 29-64 64-29 64-64 64h-64c-35 0-64 29-64 64z" />        </svg>    </button>');
        $('.toolbar-split-left').append('<ul id="worklog-issue_container" class="toolbar-group pluggable-ops">\
<li class="toolbar-item">\
<a id="worklog2h-issue" title="Add 2h in work log" class="toolbar-trigger"><span class="trigger-label">2h</span></a>\
</li>\
<li class="toolbar-item">\
<a id="worklog4h-issue" title="Add 4h in work log" class="toolbar-trigger"><span class="trigger-label">4h</span></a>\
</li>\
<li class="toolbar-item">\
<a id="worklog1d-issue" title="Add 1d in work log" class="toolbar-trigger"><span class="trigger-label">1d</span></a>\
</li>\
</ul>\
');
        jQuery('#worklog2h-issue').on('click', () => addWorkLogNowRest('2h'));
        jQuery('#worklog4h-issue').on('click', () => addWorkLogNowRest('4h'));
        jQuery('#worklog1d-issue').on('click', () => addWorkLogNowRest('1d'));

        $('.toolbar-split-left').append('<ul id="epic-issue_container" class="toolbar-group pluggable-ops">\
<li class="toolbar-item">\
<a id="technic-epic-issue" title="Set Epic" class="toolbar-trigger"><span class="trigger-label">Set Epic</span></a>\
</li>\
</ul>\
');
        jQuery('#technic-epic-issue').on('click', () => setEpicRest('SEB-6'));


        $('.toolbar-split-left').append('<ul id="custom-link-issue_container" class="toolbar-group pluggable-ops">\
<li class="toolbar-item">\
<a id="custom-link-issue" title="Add Link" class="toolbar-trigger"><span class="trigger-label">Add Link</span></a>\
</li>\
</ul>\
')
        jQuery('#custom-link-issue').on('click', () => createLinkRest('blocks', 'SEB-3'));


        $('.toolbar-split-left').append('<ul id="custom-get-weekworklog_container" class="toolbar-group pluggable-ops">\
<li class="toolbar-item">\
<a id="custom-get-weekworklog" title="Get week work log" class="toolbar-trigger"><span class="trigger-label">Show week work log</span></a>\
</li>\
</ul>\
')
        //jQuery('#custom-get-weekworklog').on('click', () => getWeekWorkLog());
        jQuery('#custom-get-weekworklog').on('click', () => showWeekWorkLogDIalog());
    }


    // CSS
    let style = document.createElement('style');
    style.type = 'text/css';
    style.innerHTML =
        `.container {\n
                width: 750px;\n
                padding-right: 15px;\n
                padding-left: 15px;\n
                margin-left: auto;
                margin-right: auto;
                margin-top: 10px;
                margin-bottom: 10px;

            }\n

            .row {
                display: flex;
                flex-wrap: wrap;
            }
            .col {
                flex-basis: 0;
                -webkit-box-flex: 1;
                flex-grow: 1;
                max-width: 100%;
            }
            .roundtext {
                background-color: #c5d4e7; /* lighter than lightsteelblue */
                font-weight: bold;
                border-radius: 4px;
                width: auto; /* Making auto-sizable width */
                height: auto; /* Making auto-sizable height */
                padding: 2px 2px 2px 2px; /* Making space around letters top right bottom left */
                margin: 5px;
                font-size: 13px;
            }
            .greentext {
                background-color: #c2f2c2;
            }
            .redtext {
                background-color: #f9aeae; /* or lightcoral */
            }
          `;
    document.getElementsByTagName('head')[0].appendChild(style);
}


function exportUsefulCommands() {

    let WL = {};
    window.Worklog = WL;

    // Add a custom worklog from browser console, example:
    // Worklog.add('2020-03-28', '4h')
    WL.add =
        function (rawdate = new Date().toISOString(), duration = '1d') {
        const issueId = JIRA.Issue.getIssueId();
        //console.log(issueId + ' ' + date);

        let date;
        if(typeof(rawdate) === 'string') {
            if(rawdate.length === 8 && rawdate.startsWith('20')) { // 20200328
                let year = rawdate.slice(0, 4);
                let month = rawdate.slice(4, 6);
                let day = rawdate.slice(6, 8);
                date = `${year}-${month}-${day}T12:00:00.000+0000`;
            } else { // if (rawdate.length === 10 && rawdate.includes('-'))
                date = new Date(Date.parse(rawdate)).toISOString();
            }

        } else {
            date = rawdate;
        }

        // date format: "2020-03-28T13:18:17.222+0000"
        addWorkLogDateRest(date, duration);
    }

    function isWE(date){
        let day = date.getDay();
        if(day === 0 || day === 6) {
            return true;
        }
        return false;
    }

    // To add 10 days of work logs begining at date in parameter, type in browser console the following instruction:
    // Worklog.addDays("20200222", 10)
    // WE days are not logged
    WL.addDays = function (rawdate = new Date().toISOString(), nbDays) {
        let date;
        let daysWalked = 0;
        if(typeof(rawdate) === 'string') {
            if(rawdate.length === 8 && rawdate.startsWith('20')) { // 20200328
                let year = rawdate.slice(0, 4);
                let month = rawdate.slice(4, 6);
                let day = rawdate.slice(6, 8);
                date = new Date();
                date.setYear(year);
                date.setMonth(month-1);
                date.setDate(day);
            } else { // if (rawdate.length === 10 && rawdate.includes('-'))
                date = new Date(Date.parse(rawdate));
            }

        } else {
            date = rawdate;
        }

        while(daysWalked < nbDays) {
            if(!isWE(date)) {
                daysWalked++;
                console.log(date);
                addWorkLogDateRest(date.toISOString(), '1d');
            }
            jumpNDays(date, 1);
        }
        //return date;
    }
}

exportUsefulCommands();


$(document).ajaxComplete(init);