// ==UserScript==
// @name AzDO PR dashboard improvements
// @version 2.5.0
// @author National Instruments
// @description Adds sorting and categorization to the PR dashboard.
// @namespace https://ni.com
// @homepageURL 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=
// @downloadURL none
// ==/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();
}
}, 1000));
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());
// Create review sections with counters.
myReviews.append("Incomplete but blocked (0)
");
myReviews.append("Drafts (0)
");
myReviews.append("Completed as Waiting on Author (0)
");
myReviews.append("Completed as Rejected (0)
");
myReviews.append("Completed as Approved / Approved with Suggestions (0)
");
// 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) {
detailsElement.attr('open', isSubsectionOpen == 1 ? 'open' : undefined);
}
});
// 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);
// 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++;
}
});
// 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';
} else {
if (waitingOrRejectedVotes > 0) {
subsection = '.reviews-incomplete-blocked';
} else if (missingVotes == 1) {
row.css('background', 'rgba(256, 0, 0, 0.3)');
}
}
// 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);
}
},
error: (jqXHR, exception) => {
console.log(`Error at PR ${pullRequestId}: ${jqXHR.responseText}`);
}
});
});
}