AzDO PR dashboard improvements

Adds sorting and categorization to the PR dashboard.

As of 2019-05-06. See the latest version.

// ==UserScript==

// @name         AzDO PR dashboard improvements
// @version      2.8.3
// @author       National Instruments
// @description  Adds sorting and categorization to the PR dashboard.
// @license      MIT

// @namespace    https://ni.com
// @homepageURL  https://github.com/alejandro5042/azdo-userscripts
// @supportURL   https://github.com/alejandro5042/azdo-userscripts

// @contributionURL  https://github.com/alejandro5042/azdo-userscripts

// @include      https://dev.azure.com/*
// @include      https://*.visualstudio.com/*

// @run-at       document-start
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js#sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=
// @require      https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.9.1/underscore-min.js#sha256-G7A4JrJjJlFqP0yamznwPjAApIKPkadeHfyIwiaa9e0=

// ==/UserScript==

// Update if we notice new elements being inserted into the DOM. This happens when AzDO loads the PR dashboard. Debounce new elements by a short time, in case they are being added in a batch.
$(document).bind('DOMNodeInserted', _.debounce(() => {
    // If we're on a pull request page, attempt to sort it.
    if(/\/(_pulls|pullrequests)/i.test(window.location.pathname)) {
        sortPullRequestDashboard();
    }
}, 500));

function sortPullRequestDashboard() {
    // Find the reviews section for this user.
    var myReviews = $("[aria-label='Assigned to me'][role='region']");
    if (myReviews.length == 0) {
         // We're on the overall dashboard (e.g. https://dev.azure.com/*/_pulls) which has a different HTML layout...
         myReviews = $("[aria-label='Assigned to me']").parent();
    }
    if (myReviews.length == 0) {
        // We are not on a page that has a PR dashboard.
        console.log("No PR dashboard found at: " + window.location);
        return;
    }

    // Don't update if we see evidence of us having run.
    if (myReviews.attr('data-reviews-sorted') == 'true') {
        return;
    }
    myReviews.attr('data-reviews-sorted', 'true');

    // Sort the reviews in reverse; aka. show oldest reviews first then newer reviews.
    myReviews.append(myReviews.find("[role='listitem']").get().reverse());

    // Define what it means to be a notable PR after you have approved it.
    var peopleToNotApproveToCountAsNotableThread = 2;
    var commentsToCountAsNotableThread = 4;
    var wordsToCountAsNotableThread = 300;
    var notableUpdateDescription = `These are pull requests you've already approved, but since then, any of following events have happened:&#013    1) At least ${peopleToNotApproveToCountAsNotableThread} people voted Rejected or Waiting on Author&#013    2) A thread was posted with at least ${commentsToCountAsNotableThread} comments&#013    3) A thread was posted with at least ${wordsToCountAsNotableThread} words&#013Optional: To remove PRs from this list, simply vote again on the PR (even if it's the same vote).`;

    // Create review sections with counters.
    myReviews.append("<details class='reviews-incomplete-blocked' style='display: none; margin: 10px 30px' open><summary style='padding: 10px; cursor: pointer; color: var(--text-secondary-color)'>Incomplete but blocked (<span class='review-subsection-counter'>0</span>)</summary></details>");
    myReviews.append("<details class='reviews-drafts' style='display: none; margin: 10px 30px' open><summary style='padding: 10px; cursor: pointer; color: var(--text-secondary-color)'>Drafts (<span class='review-subsection-counter'>0</span>)</summary></details>");
    myReviews.append("<details class='reviews-waiting' style='display: none; margin: 10px 30px'><summary style='padding: 10px; cursor: pointer; color: var(--text-secondary-color)'>Completed as Waiting on Author (<span class='review-subsection-counter'>0</span>)</summary></details>");
    myReviews.append("<details class='reviews-rejected' style='display: none; margin: 10px 30px'><summary style='padding: 10px; cursor: pointer; color: var(--text-secondary-color)'>Completed as Rejected (<span class='review-subsection-counter'>0</span>)</summary></details>");
    myReviews.append(`<details class='reviews-approved-notable' style='display: none; margin: 10px 30px' open><summary style='padding: 10px; cursor: pointer; color: var(--text-secondary-color)'>Completed as Approved / Approved with Suggestions (<abbr title="${notableUpdateDescription}">with notable activity</abbr>) (<span class='review-subsection-counter'>0</span>)</summary></details>`);
    myReviews.append("<details class='reviews-approved' style='display: none; margin: 10px 30px'><summary style='padding: 10px; cursor: pointer; color: var(--text-secondary-color)'>Completed as Approved / Approved with Suggestions (<span class='review-subsection-counter'>0</span>)</summary></details>");

    // If we have browser local storage, we can save the open/closed setting of these subsections.
    if (localStorage) {
        // Load the subsection open/closed setting if it exists.
        myReviews.children("details").each((index, item) => {
            var detailsElement = $(item);
            var isSubsectionOpen = localStorage.getItem(`userscript/azdo-pr-dashboard/is-subsection-open/${detailsElement.attr('class')}`);
            if (isSubsectionOpen == 1) {
                detailsElement.attr('open', 'open');
            } else if (isSubsectionOpen == 0) {
                detailsElement.removeAttr('open');
            }
        });

        // Save the subsection open/closed setting on toggle.
        myReviews.children("details").on("toggle", (e) => {
            var detailsElement = $(e.target);
            localStorage.setItem(`userscript/azdo-pr-dashboard/is-subsection-open/${detailsElement.attr('class')}`, detailsElement.attr('open') == 'open' ? 1 : 0);
        });
    }

    // Because of CORS, we need to make sure we're querying the same hostname for our AzDO APIs.
    var apiUrlPrefix;
    if (window.location.hostname == 'dev.azure.com') {
        apiUrlPrefix = `https://${window.location.hostname}${window.location.pathname.match(/^\/.*?\//ig)[0]}`;
    } else {
        apiUrlPrefix = `https://${window.location.hostname}`;
    }

    // Find the user's name.
    var me = $(".vss-Persona").attr("aria-label");

    // Loop through the PRs that we've voted on.
    $(myReviews).find(`[role="listitem"]`).each((index, item) => {
        var row = $(item);
        if (row.length == 0) {
            return;
        }

        // Get the PR id.
        var pullRequestUrl = row.find("a[href*='/pullrequest/']").attr('href');
        if (pullRequestUrl == undefined) {
            return;
        }
        var pullRequestId = pullRequestUrl.substring(pullRequestUrl.lastIndexOf('/') + 1);

        // Hide the row while we are updating it.
        row.hide(150);

        // Get complete information about the PR.
        // See: https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20requests/get%20pull%20request%20by%20id?view=azure-devops-rest-5.0
        $.ajax({
            url: `${apiUrlPrefix}/_apis/git/pullrequests/${pullRequestId}?api-version=5.0`,
            type: 'GET',
            cache: false,
            success: (pullRequestInfo) => {
                // AzDO has returned with info on this PR.

                var missingVotes = 0;
                var waitingOrRejectedVotes = 0;
                var neededVotes = 0;
                var myVote = 0;

                // Count the number of votes.
                $.each(pullRequestInfo.reviewers, function(i, reviewer) {
                    neededVotes++;
                    if (reviewer.displayName == me) {
                        myVote = reviewer.vote;
                    }
                    if (reviewer.vote == 0) {
                        missingVotes++;
                    }
                    if (reviewer.vote < 0) {
                        waitingOrRejectedVotes++;
                    }
                });

                // Any tasks that need to complete in order to calculate the right subsection.
                var subsectionAsyncTask = null;

                // See what section this PR should be filed under and style the row, if necessary.
                var subsection = "";
                if (pullRequestInfo.isDraft) {
                    subsection = '.reviews-drafts';
                } else if (myVote == -5) {
                    subsection = '.reviews-waiting';
                } else if (myVote < 0) {
                    subsection = '.reviews-rejected';
                } else if (myVote > 0) {
                    subsection = '.reviews-approved';

                    // If the user approved the PR, see if we need to resurface it as a notable PR.
                    // See: https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20request%20threads/list?view=azure-devops-rest-5.0
                    subsectionAsyncTask = $.ajax({
                        url: `${pullRequestInfo.url}/threads?api-version=5.0`,
                        type: 'GET',
                        cache: false,
                        success: (pullRequestThreads) => {
                            // AzDO has returned with threads for this PR.

                            var threadsWithLotsOfComments = 0;
                            var threadsWithWordyComments = 0;
                            var newNonApprovedVotes = 0;

                            // Loop through the threads in reverse time order (newest first).
                            $.each(pullRequestThreads.value.reverse(), function(i, thread) {
                                // If the thread is deleted, let's ignore it and move on to the next thread.
                                if (thread.isDeleted) {
                                    return true;
                                }

                                // See if this thread represents a non-approved vote.
                                if (thread.properties.hasOwnProperty("CodeReviewThreadType")) {
                                    if (thread.properties.CodeReviewThreadType["$value"] == "VoteUpdate") {
                                        // Stop looking at threads once we find the thread that represents our vote.
                                        var votingUser = thread.identities[thread.properties.CodeReviewVotedByIdentity["$value"]].displayName;
                                        if (votingUser == me) {
                                            return false;
                                        }

                                        if (thread.properties.CodeReviewVoteResult["$value"] < 0) {
                                            newNonApprovedVotes++;
                                        }
                                    }
                                }

                                // Count the number of comments and words in the thread.

                                var wordCount = 0;
                                var commentCount = 0;

                                $.each(thread.comments, (j, comment) => {
                                    if (comment.commentType != 'system') {
                                        commentCount++;
                                        wordCount += comment.content.trim().split(/\s+/).length;
                                    }
                                });

                                if (commentCount >= commentsToCountAsNotableThread) {
                                    threadsWithLotsOfComments++;
                                }
                                if (wordCount >= wordsToCountAsNotableThread) {
                                    threadsWithWordyComments++;
                                }
                            });

                            // See if we've tripped any of attributes that would make this PR notable.
                            if (threadsWithLotsOfComments > 0 || threadsWithWordyComments > 0 || newNonApprovedVotes >= peopleToNotApproveToCountAsNotableThread) {
                                subsection = '.reviews-approved-notable';
                            }
                        },
                        error: (jqXHR, exception) => {
                            console.log(`Error at PR ${pullRequestId}: ${jqXHR.responseText}`);
                        }
                    });
                } else {
                    if (waitingOrRejectedVotes > 0) {
                        subsection = '.reviews-incomplete-blocked';
                    } else if (missingVotes == 1) {
                        row.css('background', 'rgba(256, 0, 0, 0.3)');
                    }
                }

                // Wait until we've finished any task that is needed to calculate subsection.
                $.when(subsectionAsyncTask).then(() => {
                    try {
                        // If we identified a section, move the row.
                        if (subsection) {
                            var completedSection = myReviews.children(subsection);
                            completedSection.find('.review-subsection-counter').text(function(i, value) { return +value + 1 });
                            completedSection.find('.review-subsection-counter').removeClass('empty');
                            completedSection.css('display', 'block');
                            completedSection.append(row);
                        }
                    } finally {
                        row.show(150);
                    }
                });
            },
            error: (jqXHR, exception) => {
                console.log(`Error at PR ${pullRequestId}: ${jqXHR.responseText}`);

                // Un-hide the row if we errored out.
                row.show(150);
            }
        });
    });
}