// ==UserScript== // @name AzDO Pull Request Improvements // @version 2.22.0 // @author Alejandro Barreto (National Instruments) // @description Adds sorting and categorization to the PR dashboard. Also adds minor improvements to the PR diff experience, such as a base update selector and per-file checkboxes. // @license MIT // @namespace https://github.com/alejandro5042 // @homepageURL https://alejandro5042.github.io/azdo-userscripts/ // @supportURL https://alejandro5042.github.io/azdo-userscripts/SUPPORT.html // @contributionURL https://github.com/alejandro5042/azdo-userscripts // @include https://dev.azure.com/* // @include https://*.visualstudio.com/* // @run-at document-end // @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/jquery-once/2.2.3/jquery.once.min.js#sha256-HaeXVMzafCQfVtWoLtN3wzhLWNs8cY2cH9OIQ8R9jfM= // @require https://cdnjs.cloudflare.com/ajax/libs/lscache/1.3.0/lscache.js#sha256-QVvX22TtfzD4pclw/4yxR0G1/db2GZMYG9+gxRM9v30= // @require https://cdnjs.cloudflare.com/ajax/libs/date-fns/1.30.1/date_fns.min.js#sha256-wCBClaCr6pJ7sGU5kfb3gQMOOcIZNzaWpWcj/lD9Vfk= // @require https://cdn.jsdelivr.net/npm/lodash@4.17.11/lodash.min.js#sha256-7/yoZS3548fXSRXqc/xYzjsmuW3sFKzuvOCHd06Pmps= // @downloadURL none // ==/UserScript== (function () { 'use strict'; // All REST API calls should fail after a timeout, instead of going on forever. $.ajaxSetup({ timeout: 5000 }); // Find out who is our current user. In general, we should avoid using pageData because it doesn't always get updated when moving between page-to-page in AzDO's single-page application flow. Instead, rely on the AzDO REST APIs to get information from stuff you find on the page or the URL. Some things are OK to get from pageData; e.g. stuff like the user which is available on all pages. const pageData = JSON.parse(document.getElementById('dataProviders').innerHTML).data; const currentUser = pageData['ms.vss-web.page-data'].user; // Because of CORS, we need to make sure we're querying the same hostname for our AzDO APIs. const azdoApiBaseUrl = `${window.location.origin}${pageData['ms.vss-tfs-web.header-action-data'].suiteHomeUrl}`; // Set a namespace for our local storage items. lscache.setBucket('acb-azdo/'); // Call our event handler if we notice new elements being inserted into the DOM. This happens as the page is loading or updating dynamically based on user activity. We throttle new element events to avoid using up CPU when AzDO is adding a lot of elements during a short time (like on page load). document.addEventListener('DOMNodeInserted', _.throttle(onPageDOMNodeInserted, 400)); // This is "main()" for this script. Runs periodically when the page updates. function onPageDOMNodeInserted(event) { // The page may not have refreshed when moving between URLs--sometimes AzDO acts as a single-page application. So we must always check where we are and act accordingly. if (/\/(pullrequest)\//i.test(window.location.pathname)) { addCheckboxesToFiles(); addBaseUpdateSelector(); makePullRequestDiffEasierToScroll(); } else if (/\/(_pulls|pullrequests)/i.test(window.location.pathname)) { sortPullRequestDashboard(); } } function makePullRequestDiffEasierToScroll() { addStyleOnce('pr-diff-improvements', /* css */ ` .vc-change-summary-files .file-container { /* Make the divs float but clear them so they get stacked on top of each other. We float so that the divs expand to take up the width of the text in it. Finally, we remove the overflow property so that they don't have scrollbars and also such that we can have sticky elements (apparently, sticky elements don't work if the div has overflow). */ float: left; clear: both; min-width: 95%; overflow: initial; } .vc-change-summary-files .file-row { /* Let the file name section of each diff stick to the top of the page if we're scrolling. */ position: sticky; top: 0; z-index: 100000; padding-bottom: 10px; background: var(--background-color,rgba(255, 255, 255, 1)); } .vc-change-summary-files .vc-diff-viewer { /* We borrowed padding from the diff to give to the bottom of the file row. So adjust accordingly (this value was originally 20px). */ padding-top: 10px; }`); } // The func we'll call to continuously add checkboxes to the PR file listing, once initialization is over. let addCheckboxesToNewFilesFunc = () => { }; // If we're on specific PR, add checkboxes to the file listing. function addCheckboxesToFiles() { $('.vc-sparse-files-tree').once('add-checkbox-support').each(async function () { addCheckboxesToNewFilesFunc = () => { }; const filesTree = $(this); addStyleOnce('pr-file-checbox-support-css', /* css */ ` :root { /* Set some constants for our CSS. */ --file-to-review-color: var(--communication-foreground); } button.file-complete-checkbox { /* Make a checkbox out of a button. */ cursor: pointer; width: 15px; height: 15px; line-height: 15px; margin: -3px 8px 0px 0px; padding: 0px; background: var(--palette-black-alpha-6); border-radius: 3px; border: 1px solid var(--palette-black-alpha-10); vertical-align: middle; display: inline-block; font-size: 0.75em; text-align: center; color: var(--text-primary-color); } button.file-complete-checkbox:hover { /* Make a checkbox out of a button. */ background: var(--palette-black-alpha-10); } button.file-complete-checkbox.checked:after { /* Make a checkbox out of a button. */ content: "✔"; } .vc-sparse-files-tree .tree-row.file-to-review-row, .vc-sparse-files-tree .tree-row.file-to-review-row .file-name { /* Highlight files I need to review. */ color: var(--file-to-review-color); } .vc-sparse-files-tree .tree-row.file-to-review-row .file-owners-role { /* Style the role of the user in the files table. */ font-weight: bold; padding: 7px 10px; position: absolute; z-index: 100; float: right; } .file-to-review-diff { /* Highlight files I need to review. */ border-left: 3px solid var(--file-to-review-color) !important; padding-left: 7px; } .files-container.hide-files-not-to-review .file-container:not(.file-to-review-diff) { /* Fade the header for files I don't have to review. */ opacity: 0.2; } .files-container.hide-files-not-to-review .file-container:not(.file-to-review-diff) .item-details-body { /* Hide the diff for files I don't have to review. */ display: none; }`); // Get the current iteration of the PR. const pr = await getPullRequest(); const currentPullRequestIteration = (await $.get(`${pr.url}/iterations?api-version=5.0`)).count; // Get the current checkbox state for the PR at this URL. const checkboxStateId = `pr-file-iteration6/${window.location.pathname}`; // Stores the checkbox state for the current page. A map of files => iteration it was checked. const filesToIterationReviewed = lscache.get(checkboxStateId) || {}; // Handle clicking on file checkboxes. filesTree.on('click', 'button.file-complete-checkbox', function (event) { const checkbox = $(this); // Toggle the look of the checkbox. checkbox.toggleClass('checked'); // Save the iteration number the file was checked in our map. To save space, if it is unchecked, simply remove the entry. if (checkbox.hasClass('checked')) { filesToIterationReviewed[checkbox.attr('name')] = currentPullRequestIteration; } else { delete filesToIterationReviewed[checkbox.attr('name')]; } // Save the current checkbox state to local storage. lscache.set(checkboxStateId, filesToIterationReviewed, 60 * 24 * 21); // Stop the click event here to avoid the checkbox click from selecting the PR row underneath, which changes the active diff in the right panel. event.stopPropagation(); }); // Get owners info for this PR. const ownersInfo = await getNationalInstrumentsPullRequestOwnersInfo(pr.url); // If we have owners info, add a button to filter out diffs that we don't need to review. if (ownersInfo && ownersInfo.currentUserFileCount > 0) { $('.changed-files-summary-toolbar').once('add-other-files-button').each(function () { $(this) .find('ul') .prepend('') .click((event) => { $('.files-container').toggleClass('hide-files-not-to-review'); }); }); } addCheckboxesToNewFilesFunc = function () { // If we have owners info, tag the diffs that we don't need to review. if (ownersInfo && ownersInfo.currentUserFileCount > 0) { $('.file-container .file-path').once('filter-files-to-review').each(function () { const filePathElement = $(this); const path = filePathElement.text().replace(/\//, ''); filePathElement.closest('.file-container').toggleClass('file-to-review-diff', ownersInfo.isCurrentUserResponsibleForFile(path)); }); } $('.vc-sparse-files-tree .vc-tree-cell').once('add-complete-checkbox').each(function () { const fileCell = $(this); const fileRow = fileCell.closest('.tree-row'); const typeIcon = fileRow.find('.type-icon'); // Don't put checkboxes on rows that don't represent files. if (!/bowtie-file\b/i.test(typeIcon.attr('class'))) { return; } const name = fileCell.attr('content'); // The 'content' attribute contains the file operation; e.g. "/src/file.cs [edit]". const iteration = filesToIterationReviewed[name] || 0; // Create the checkbox before the type icon. $('