// ==UserScript==
// @name AzDO PR dashboard improvements
// @version 2.9.2
// @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://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js#sha256-7/yoZS3548fXSRXqc/xYzjsmuW3sFKzuvOCHd06Pmps=
// ==/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.addEventListener('DOMNodeInserted', _.throttle(onPageDOMNodeInserted, 400));
function onPageDOMNodeInserted(event) {
// If we're on a pull request page, attempt to sort it.
if (/\/(_pulls|pullrequests)/i.test(window.location.pathname)) {
sortPullRequestDashboard();
return;
}
}
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.
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:
 1) At least ${peopleToNotApproveToCountAsNotableThread} people voted Rejected or Waiting on Author
 2) A thread was posted with at least ${commentsToCountAsNotableThread} comments
 3) A thread was posted with at least ${wordsToCountAsNotableThread} words
Optional: 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 pageDataProviders = JSON.parse(document.getElementById('dataProviders').innerHTML);
var user = pageDataProviders.data["ms.vss-web.page-data"].user;
var me = user.displayName;
var userEmail = user.uniqueName;
// 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 = "";
var computeSize = false;
if (pullRequestInfo.isDraft) {
subsection = '.reviews-drafts';
computeSize = true;
} 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' && !comment.isDeleted && comment.content) {
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.error(`Error at PR ${pullRequestId}: ${jqXHR.responseText}`);
}
});
} else {
computeSize = true;
if (waitingOrRejectedVotes > 0) {
subsection = '.reviews-incomplete-blocked';
} else if (missingVotes == 1) {
row.css('background', 'rgba(256, 0, 0, 0.3)');
}
}
// Compute the size of certain PRs; e.g. those we haven't reviewed yet.
if (computeSize) {
// Make sure we've created a merge commit that we can compute its size.
if (pullRequestInfo.lastMergeCommit) {
// Helper function to add the size to the PR row.
function addPullRequestFileSize(files) {
var content = `<span class="contributed-icon flex-noshrink fabric-icon ms-Icon--FileCode"></span> ${files}`;
// For the overall PR dashboard.
row.find('div.vss-DetailsList--titleCellTwoLine').parent().append(`<div style='margin: 0px 15px; width: 3em; text-align: left;'>${content}</div>`);
// For a repo's PR dashboard.
row.find('div.vc-pullrequest-entry-col-secondary').after(`<div style='margin: 15px; width: 3.5em; display: flex; align-items: center; text-align: right;'>${content}</div>`);
}
// First, try to find NI.ReviewProperties, which contains reviewer info specific to National Instrument workflows (where this script is used the most).
$.ajax({
url: `${pullRequestInfo.url}/properties?api-version=5.1-preview.1`,
type: 'GET',
cache: false,
success: (prProperties) => {
var reviewProperties = prProperties.value["NI.ReviewProperties"];
if (reviewProperties) {
reviewProperties = JSON.parse(reviewProperties.$value);
// Count the number of files we are in the reviewers list.
var filesToReview = 0;
reviewProperties.fileProperties.forEach(file => {
file.Reviewers.forEach(reviewer => {
if (reviewer.includes(userEmail)) {
filesToReview++;
}
});
});
// If there aren't any files to review, then we don't have an explicit role and we should fall through to counting all the files.
if (filesToReview > 0) {
addPullRequestFileSize(filesToReview);
return;
}
}
// If there is no NI.ReviewProperties or if it returns zero files to review, then count the number of files in the merge commit.
$.ajax({
url: `${pullRequestInfo.lastMergeCommit.url}/changes?api-version=5.0`,
type: 'GET',
cache: false,
success: (mergeCommitInfo) => {
var fileCount = 0;
mergeCommitInfo.changes.forEach(item => {
if (!item.item.isFolder) {
fileCount++;
}
});
addPullRequestFileSize(fileCount);
}
});
}
});
}
}
// 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.error(`Error at PR ${pullRequestId}: ${jqXHR.responseText}`);
// Un-hide the row if we errored out.
row.show(150);
}
});
});
}