Jira issue page updated notification

Give Jira issue page an update button, in the top navigation bar, when the issue is updated while the page is shown. Also update the title.

Verze ze dne 24. 04. 2023. Zobrazit nejnovější verzi.

// ==UserScript==
// @name        Jira issue page updated notification
// @namespace   https://greasyfork.org/users/1047370
// @description Give Jira issue page an update button, in the top navigation bar, when the issue is updated while the page is shown.  Also update the title.
// @author      Marnix Klooster <[email protected]>
// @copyright   public domain
// @license     public domain
// @version     0.8
// @homepage    https://greasyfork.org/en/scripts/462479-jira-issue-page-updated-notification
// @include     /^https?://(jira\.[^/]*|[^/]*\.atlassian\.net)/(browse|projects/[^/]+/issues)//
// @require     https://cdn.jsdelivr.net/npm/[email protected]/build/global/luxon.min.js
// @grant       none
// ==/UserScript==

/// TODO list
///
///  * If the 'updated' time on the button is not today anymore, add 'yesterday' or other relative date.
///    This presumably requires triggering an event at local midnight.
///      Alternatively, especially if we keep updating the tooltip regularly (see other TODO item),
///    update the button regularly with the relative time (using Luxon's `.toRelative()`)?
///
///  * Perhaps: Also show the _type_ of changes, in the 'updated' button's tooltip.
///    (It seems this needs both its ...?field=&expand=changelog
///    and the issue's full .../comment list, and perhaps more.)
///      If this is built, it also would make sense to keep watching for issue updates,
///    and keep updating the tooltip (and perhaps the time, see other TODO item).
///
///    Additionally, if (at least) the Description changed, make the button red/bold/highlighted
///    since that other Description change will be lost.
///    (And/or make that a separate userscript, that only looks at Description collisions.)
///
///  * Perhaps: If another update is done, update the time on the button.
///
///    Or show a time range even.  Idea from elsewhere:
///     - Split start and end time string using `s.split(/\s+(?!(?:AM|PM))\b/)`, giving two arrays.
///     - Show the start, but leave out the suffix common to start and end.
///     - Show an `&mdash`.
///     - Show the end, but leave out the prefix common to start and end.
///    That would result in something like
///
///       issue changed 10:12:11 AM — 12:39:55 PM GMT+2
///
///    But I'm not sure how this would interact with the desire to show relative times
///    (see other TODO item).
///
///  * Bug/limitation: From a query result page (https://jira.infor.com/issues/?jql=...)
///    clicking on a specific issue, the page is updated 'in place',
///    so even though the address bar URL changes, this userscript is not activated.
///    See if there is a way to fix that.
///
///  * Robustness in case `aui-nav` element does not exist.
///
///  * Perhaps: Wait longer after an error response, to reduce server load?

/// END of TODO list


"use strict";

/// Configuration settings
/// (also look at @include and @match in the manifest above,
/// which you can usually override in your userscript browser extension configuration)

/// Setting the following too high will overload the Jira server;
/// setting it too low will make this userscript less useful.
var timeBetweenChecksInSeconds = 10;

/// END of Configuration settings


/// Helper functions

/// From https://stackoverflow.com/a/35385518/223837:
/// Construct an DOM element from the given HTML string.
/// The caller must ensure no injection occurs, e.g. using `escapeHTML()` below.
function htmlToElement(html) {
    var template = document.createElement('template');
    html = html.trim(); // Never return a text node of whitespace as the result
    template.innerHTML = html;
    return template.content.firstChild;
}

/// From https://stackoverflow.com/a/22706073/223837:
/// Convert a string to the equivalent HTML source code.
function escapeHTML(str){
    return new Option(str).innerHTML;
}

/// END of Helper functions


// Documentation about the Jira REST API that is used here can be found at
// https://docs.atlassian.com/software/jira/docs/api/REST/latest/
// (currently https://docs.atlassian.com/software/jira/docs/api/REST/9.7.0/),
// specifically
//
// - https://docs.atlassian.com/software/jira/docs/api/REST/latest/#api/2/issue-getIssue
// - https://docs.atlassian.com/software/jira/docs/api/REST/latest/#api/2/issue-getComments
//
// Documentation about the Javascript JIRA object's API doesn't seem to exist.
// There are mostly fragments floating around in forum questions and answers.

(function () {

    /// 'global' variables
    var regularlyChecking = true;

    function regularlyCheckForUpdates() {
        if (!regularlyChecking) {
            console.log(`ERROR: internal inconsistency, somewhere we forgot to set 'regularlyChecking = true'`);
            regularlyChecking = true;
        }
        var issueNumber = JIRA.Issue.getIssueKey(); // TODO: avoid unnecessary failing call below if the result is `null`.

        console.log(`Checking whether issue ${issueNumber} has been recently updated`);
        $.ajax({url:`/rest/api/latest/issue/${issueNumber}?fields=updated`, type:"GET", dataType:"json", contenType: "application/json",
                success: function(response) {
                    // 'last updated' according to the Jira REST API
                    var issueLastUpdated = luxon.DateTime.fromISO(response.fields.updated);
                    // 'last updated' according to this page; could perhaps also be read via the JIRA object? could not find any documentation
                    var pageLastUpdated = luxon.DateTime.fromISO(document.getElementById("updated-val").getElementsByTagName("time")[0].getAttribute("datetime"));
                    console.log(`Issue ${issueNumber} was last updated ${issueLastUpdated}`);
                    console.log(`This page says its data is from ${pageLastUpdated}' (+/- 1 second)`);
                    if (pageLastUpdated.toMillis() + 1000 < issueLastUpdated.toMillis()) { // + 1000 since 'page last updated' has no millisecond information
                        // issue was updated after the page information was refreshed
                        var issueLastUpdatedText = issueLastUpdated.toLocaleString(luxon.DateTime.TIME_WITH_SHORT_OFFSET, {});
                        console.log(`Concluding that issue was updated after last page refresh, just now at ${issueLastUpdatedText}: showing update button and updating title`);

                        // We put the button as the last in the <ul class="aui-nav"> top navigation bar.
                        // (The button is the same as the 'Create' button; `href="#"` is needed for the correct hover color.)
                        var updateButtonElement = htmlToElement(`
                            <li id="marnix_update_page_button">
                                <a
                                    href="#"
                                    class="aui-button aui-button-primary aui-style"
                                    title="Update page"
                                   >Update page (issue changed ${escapeHTML(issueLastUpdatedText)})</a>
                            </li>
                        `);
                        updateButtonElement.addEventListener("click", updateThisPage);
                        document.getElementsByClassName("aui-nav")[0].appendChild(updateButtonElement);

                        document.title = `\u21BB ${document.title}`;

                        // From now on leave the page alone, the `ISSUE_REFRESHED` handler (below) will re-enable the regular check
                        regularlyChecking = false;
                        return;
                    }

                    console.log(`Concluding that there was no recent issue ${issueNumber} update, will check again in a little while.`);
                    setTimeout(regularlyCheckForUpdates, timeBetweenChecksInSeconds*1000);
                },
                error: function(xhr, textStatus, errorThrown) {
                    console.log(`Something went wrong checking for updates of issue ${issueNumber}, will retry in a little while: ${textStatus} : ${errorThrown}`);
                    setTimeout(regularlyCheckForUpdates, timeBetweenChecksInSeconds*1000);
                }
               });
    }

    function updateThisPage() {
        console.log(`Let this page update its information (which also updates the title)`);
        JIRA.trigger(JIRA.Events.REFRESH_ISSUE_PAGE, [JIRA.Issue.getIssueId()]);
        // this event triggers an update, which raises an ISSUE_REFRESHED event,
        // which is caught by the event handler below, which will re-enable the regular check for issue updates
        // (or it triggers a full page reload, sometimes, it seems, e.g. if there is a network issue)
    }

    JIRA.bind(JIRA.Events.ISSUE_REFRESHED, function (e, context) {
        console.log(`Something triggered a refresh of this page`);

        if (!regularlyChecking) {
            console.log(`We will start to look for issue updates again in a little while`);
            setTimeout(regularlyCheckForUpdates, timeBetweenChecksInSeconds*1000);
            regularlyChecking = true;
        }

        var updateButtonElement = document.getElementById('marnix_update_page_button')
        if (updateButtonElement) {
            console.log(`We can remove the update button again`);
            updateButtonElement.remove();

        // no need to revert the document.title, the refresh that just happened has already done that
        }
    });

    JIRA.bind(JIRA.Events.INLINE_EDIT_SAVE_COMPLETE, function (e, context) {
        console.log(`INLINE_EDIT_SAVE_COMPLETE (e.g., inline Description change), force page update`);
        updateThisPage();
    });

    regularlyCheckForUpdates();
})();