Greasy Fork

Greasy Fork is available in English.

AzDO PR dashboard improvements

Adds sorting and categorization to the PR dashboard.

当前为 2019-05-06 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);
            }
        });
    });
}