AzDO PR dashboard improvements

Adds sorting and categorization to the PR dashboard.

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

  1. // ==UserScript==
  2.  
  3. // @name AzDO PR dashboard improvements
  4. // @version 2.9.1
  5. // @author National Instruments
  6. // @description Adds sorting and categorization to the PR dashboard.
  7. // @license MIT
  8.  
  9. // @namespace https://ni.com
  10. // @homepageURL https://github.com/alejandro5042/azdo-userscripts
  11. // @supportURL https://github.com/alejandro5042/azdo-userscripts
  12.  
  13. // @contributionURL https://github.com/alejandro5042/azdo-userscripts
  14.  
  15. // @include https://dev.azure.com/*
  16. // @include https://*.visualstudio.com/*
  17.  
  18. // @run-at document-start
  19. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js#sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=
  20. // @require https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.9.1/underscore-min.js#sha256-G7A4JrJjJlFqP0yamznwPjAApIKPkadeHfyIwiaa9e0=
  21.  
  22. // ==/UserScript==
  23.  
  24. // 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.
  25. $(document).bind('DOMNodeInserted', _.debounce(() => {
  26. // If we're on a pull request page, attempt to sort it.
  27. if(/\/(_pulls|pullrequests)/i.test(window.location.pathname)) {
  28. sortPullRequestDashboard();
  29. }
  30. }, 500));
  31.  
  32. function sortPullRequestDashboard() {
  33. // Find the reviews section for this user.
  34. var myReviews = $("[aria-label='Assigned to me'][role='region']");
  35. if (myReviews.length == 0) {
  36. // We're on the overall dashboard (e.g. https://dev.azure.com/*/_pulls) which has a different HTML layout...
  37. myReviews = $("[aria-label='Assigned to me']").parent();
  38. }
  39. if (myReviews.length == 0) {
  40. // We are not on a page that has a PR dashboard.
  41. console.log("No PR dashboard found at: " + window.location);
  42. return;
  43. }
  44.  
  45. // Don't update if we see evidence of us having run.
  46. if (myReviews.attr('data-reviews-sorted') == 'true') {
  47. return;
  48. }
  49. myReviews.attr('data-reviews-sorted', 'true');
  50.  
  51. // Sort the reviews in reverse; aka. show oldest reviews first then newer reviews.
  52. myReviews.append(myReviews.find("[role='listitem']").get().reverse());
  53.  
  54. // Define what it means to be a notable PR after you have approved it.
  55. var peopleToNotApproveToCountAsNotableThread = 2;
  56. var commentsToCountAsNotableThread = 4;
  57. var wordsToCountAsNotableThread = 300;
  58. 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).`;
  59.  
  60. // Create review sections with counters.
  61. 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>");
  62. 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>");
  63. 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>");
  64. 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>");
  65. 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>`);
  66. 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>");
  67.  
  68. // If we have browser local storage, we can save the open/closed setting of these subsections.
  69. if (localStorage) {
  70. // Load the subsection open/closed setting if it exists.
  71. myReviews.children("details").each((index, item) => {
  72. var detailsElement = $(item);
  73. var isSubsectionOpen = localStorage.getItem(`userscript/azdo-pr-dashboard/is-subsection-open/${detailsElement.attr('class')}`);
  74. if (isSubsectionOpen == 1) {
  75.                 detailsElement.attr('open', 'open');
  76.             } else if (isSubsectionOpen == 0) {
  77.                 detailsElement.removeAttr('open');
  78.             }
  79. });
  80.  
  81. // Save the subsection open/closed setting on toggle.
  82. myReviews.children("details").on("toggle", (e) => {
  83. var detailsElement = $(e.target);
  84. localStorage.setItem(`userscript/azdo-pr-dashboard/is-subsection-open/${detailsElement.attr('class')}`, detailsElement.attr('open') == 'open' ? 1 : 0);
  85. });
  86. }
  87.  
  88. // Because of CORS, we need to make sure we're querying the same hostname for our AzDO APIs.
  89. var apiUrlPrefix;
  90. if (window.location.hostname == 'dev.azure.com') {
  91. apiUrlPrefix = `https://${window.location.hostname}${window.location.pathname.match(/^\/.*?\//ig)[0]}`;
  92. } else {
  93. apiUrlPrefix = `https://${window.location.hostname}`;
  94. }
  95.  
  96. // Find the user's name.
  97. var pageDataProviders = JSON.parse(document.getElementById('dataProviders').innerHTML);
  98. var user = pageDataProviders.data["ms.vss-web.page-data"].user;
  99. var me = user.displayName;
  100. var userEmail = user.uniqueName;
  101.  
  102. // Loop through the PRs that we've voted on.
  103. $(myReviews).find(`[role="listitem"]`).each((index, item) => {
  104. var row = $(item);
  105. if (row.length == 0) {
  106. return;
  107. }
  108.  
  109. // Get the PR id.
  110. var pullRequestUrl = row.find("a[href*='/pullrequest/']").attr('href');
  111. if (pullRequestUrl == undefined) {
  112. return;
  113. }
  114. var pullRequestId = pullRequestUrl.substring(pullRequestUrl.lastIndexOf('/') + 1);
  115.  
  116. // Hide the row while we are updating it.
  117. row.hide(150);
  118.  
  119. // Get complete information about the PR.
  120. // 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
  121. $.ajax({
  122. url: `${apiUrlPrefix}/_apis/git/pullrequests/${pullRequestId}?api-version=5.0`,
  123. type: 'GET',
  124. cache: false,
  125. success: (pullRequestInfo) => {
  126. // AzDO has returned with info on this PR.
  127.  
  128. var missingVotes = 0;
  129. var waitingOrRejectedVotes = 0;
  130. var neededVotes = 0;
  131. var myVote = 0;
  132.  
  133. // Count the number of votes.
  134. $.each(pullRequestInfo.reviewers, function(i, reviewer) {
  135. neededVotes++;
  136. if (reviewer.displayName == me) {
  137. myVote = reviewer.vote;
  138. }
  139. if (reviewer.vote == 0) {
  140. missingVotes++;
  141. }
  142. if (reviewer.vote < 0) {
  143. waitingOrRejectedVotes++;
  144. }
  145. });
  146.  
  147. // Any tasks that need to complete in order to calculate the right subsection.
  148. var subsectionAsyncTask = null;
  149.  
  150. // See what section this PR should be filed under and style the row, if necessary.
  151. var subsection = "";
  152. var computeSize = false;
  153. if (pullRequestInfo.isDraft) {
  154. subsection = '.reviews-drafts';
  155. computeSize = true;
  156. } else if (myVote == -5) {
  157. subsection = '.reviews-waiting';
  158. } else if (myVote < 0) {
  159. subsection = '.reviews-rejected';
  160. } else if (myVote > 0) {
  161. subsection = '.reviews-approved';
  162.  
  163. // If the user approved the PR, see if we need to resurface it as a notable PR.
  164. // See: https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20request%20threads/list?view=azure-devops-rest-5.0
  165. subsectionAsyncTask = $.ajax({
  166. url: `${pullRequestInfo.url}/threads?api-version=5.0`,
  167. type: 'GET',
  168. cache: false,
  169. success: (pullRequestThreads) => {
  170. // AzDO has returned with threads for this PR.
  171.  
  172. var threadsWithLotsOfComments = 0;
  173. var threadsWithWordyComments = 0;
  174. var newNonApprovedVotes = 0;
  175.  
  176. // Loop through the threads in reverse time order (newest first).
  177. $.each(pullRequestThreads.value.reverse(), function(i, thread) {
  178. // If the thread is deleted, let's ignore it and move on to the next thread.
  179. if (thread.isDeleted) {
  180. return true;
  181. }
  182.  
  183. // See if this thread represents a non-approved vote.
  184. if (thread.properties.hasOwnProperty("CodeReviewThreadType")) {
  185. if (thread.properties.CodeReviewThreadType["$value"] == "VoteUpdate") {
  186. // Stop looking at threads once we find the thread that represents our vote.
  187. var votingUser = thread.identities[thread.properties.CodeReviewVotedByIdentity["$value"]].displayName;
  188. if (votingUser == me) {
  189. return false;
  190. }
  191.  
  192. if (thread.properties.CodeReviewVoteResult["$value"] < 0) {
  193. newNonApprovedVotes++;
  194. }
  195. }
  196. }
  197.  
  198. // Count the number of comments and words in the thread.
  199.  
  200. var wordCount = 0;
  201. var commentCount = 0;
  202.  
  203. $.each(thread.comments, (j, comment) => {
  204. if (comment.commentType != 'system') {
  205. commentCount++;
  206. wordCount += comment.content.trim().split(/\s+/).length;
  207. }
  208. });
  209.  
  210. if (commentCount >= commentsToCountAsNotableThread) {
  211. threadsWithLotsOfComments++;
  212. }
  213. if (wordCount >= wordsToCountAsNotableThread) {
  214. threadsWithWordyComments++;
  215. }
  216. });
  217.  
  218. // See if we've tripped any of attributes that would make this PR notable.
  219. if (threadsWithLotsOfComments > 0 || threadsWithWordyComments > 0 || newNonApprovedVotes >= peopleToNotApproveToCountAsNotableThread) {
  220. subsection = '.reviews-approved-notable';
  221. }
  222. },
  223. error: (jqXHR, exception) => {
  224. console.log(`Error at PR ${pullRequestId}: ${jqXHR.responseText}`);
  225. }
  226. });
  227. } else {
  228. computeSize = true;
  229. if (waitingOrRejectedVotes > 0) {
  230. subsection = '.reviews-incomplete-blocked';
  231. } else if (missingVotes == 1) {
  232. row.css('background', 'rgba(256, 0, 0, 0.3)');
  233. }
  234. }
  235.  
  236. // Compute the size of certain PRs; e.g. those we haven't reviewed yet.
  237. if (computeSize) {
  238. // Make sure we've created a merge commit that we can compute its size.
  239. if (pullRequestInfo.lastMergeCommit) {
  240. // Helper function to add the size to the PR row.
  241. function addPullRequestFileSize(files) {
  242. var content = `<span class="contributed-icon flex-noshrink fabric-icon ms-Icon--FileCode"></span>&nbsp;${files}`;
  243.  
  244. // For the overall PR dashboard.
  245. row.find('div.vss-DetailsList--titleCellTwoLine').parent().append(`<div style='margin: 0px 15px; width: 3em; text-align: left;'>${content}</div>`);
  246.  
  247. // For a repo's PR dashboard.
  248. 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>`);
  249. }
  250.  
  251. // First, try to find NI.ReviewProperties, which contains reviewer info specific to National Instrument workflows (where this script is used the most).
  252. $.ajax({
  253. url: `${pullRequestInfo.url}/properties?api-version=5.1-preview.1`,
  254. type: 'GET',
  255. cache: false,
  256. success: (prProperties) => {
  257. var reviewProperties = prProperties.value["NI.ReviewProperties"];
  258. if (reviewProperties) {
  259. reviewProperties = JSON.parse(reviewProperties.$value);
  260.  
  261. // Count the number of files we are in the reviewers list.
  262. var filesToReview = 0;
  263. reviewProperties.fileProperties.forEach(file => {
  264. file.Reviewers.forEach(reviewer => {
  265. if (reviewer.includes(userEmail)) {
  266. filesToReview++;
  267. }
  268. });
  269. });
  270.  
  271. // 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.
  272. if (filesToReview > 0) {
  273. addPullRequestFileSize(filesToReview);
  274. return;
  275. }
  276. }
  277.  
  278. // If there is no NI.ReviewProperties or if it returns zero files to review, then count the number of files in the merge commit.
  279. $.ajax({
  280. url: `${pullRequestInfo.lastMergeCommit.url}/changes?api-version=5.0`,
  281. type: 'GET',
  282. cache: false,
  283. success: (mergeCommitInfo) => {
  284. var fileCount = 0;
  285. mergeCommitInfo.changes.forEach(item => {
  286. if (!item.item.isFolder) {
  287. fileCount++;
  288. }
  289. });
  290.  
  291. addPullRequestFileSize(fileCount);
  292. }
  293. });
  294. }
  295. });
  296. }
  297. }
  298.  
  299. // Wait until we've finished any task that is needed to calculate subsection.
  300. $.when(subsectionAsyncTask).then(() => {
  301. try {
  302. // If we identified a section, move the row.
  303. if (subsection) {
  304. var completedSection = myReviews.children(subsection);
  305. completedSection.find('.review-subsection-counter').text(function(i, value) { return +value + 1 });
  306. completedSection.find('.review-subsection-counter').removeClass('empty');
  307. completedSection.css('display', 'block');
  308. completedSection.append(row);
  309. }
  310. } finally {
  311. row.show(150);
  312. }
  313. });
  314. },
  315. error: (jqXHR, exception) => {
  316. console.log(`Error at PR ${pullRequestId}: ${jqXHR.responseText}`);
  317.  
  318. // Un-hide the row if we errored out.
  319. row.show(150);
  320. }
  321. });
  322. });
  323. }