// ==UserScript== // @name More Awesome Azure DevOps (userscript) // @version 3.7.5 // @author Alejandro Barreto (NI) // @description Makes general improvements to the Azure DevOps experience, particularly around pull requests. Also contains workflow improvements for NI engineers. // @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-body // @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/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= // @require https://cdn.jsdelivr.net/npm/sweetalert2@9.13.1/dist/sweetalert2.all.min.js#sha384-8oDwN6wixJL8kVeuALUvK2VlyyQlpEEN5lg6bG26x2lvYQ1HWAV0k8e2OwiWIX8X // @require https://gist.githubusercontent.com/alejandro5042/af2ee5b0ad92b271cd2c71615a05da2c/raw/45da85567e48c814610f1627148feb063b873905/easy-userscripts.js#sha384-t7v/Pk2+HNbUjKwXkvcRQIMtDEHSH9w0xYtq5YdHnbYKIV7Jts9fSZpZq+ESYE4v // @require https://unpkg.com/@popperjs/core@2.11.7#sha384-zYPOMqeu1DAVkHiLqWBUTcbYfZ8osu1Nd6Z89ify25QV9guujx43ITvfi12/QExE // @require https://unpkg.com/tippy.js@6.3.7#sha384-AiTRpehQ7zqeua0Ypfa6Q4ki/ddhczZxrKtiQbTQUlJIhBkTeyoZP9/W/5ulFt29 // @require https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.8.0/build/highlight.min.js#sha384-g4mRvs7AO0/Ol5LxcGyz4Doe21pVhGNnC3EQw5shw+z+aXDN86HqUdwXWO+Gz2zI // @require https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.14.0/js-yaml.min.js#sha512-ia9gcZkLHA+lkNST5XlseHz/No5++YBneMsDp1IZRJSbi1YqQvBeskJuG1kR+PH1w7E0bFgEZegcj0EwpXQnww== // @resource linguistLanguagesYml https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml?v=1 // @grant GM_getResourceText // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_registerMenuCommand // @downloadURL https://update.greasyfork.icu/scripts/381397/More%20Awesome%20Azure%20DevOps%20%28userscript%29.user.js // @updateURL https://update.greasyfork.icu/scripts/381397/More%20Awesome%20Azure%20DevOps%20%28userscript%29.meta.js // ==/UserScript== (function () { 'use strict'; // All REST API calls should fail after a timeout, instead of going on forever. $.ajaxSetup({ timeout: 5000 }); let currentUser; let azdoApiBaseUrl; // Some features only apply at National Instruments. const atNI = /^ni\./i.test(window.location.hostname) || /^\/ni\//i.test(window.location.pathname); function debug(...args) { // eslint-disable-next-line no-console console.log('[azdo-userscript]', args); } function error(...args) { // eslint-disable-next-line no-console console.error('[azdo-userscript]', args); } function main() { eus.globalSession.onFirst(document, 'body', () => { eus.registerCssClassConfig(document.body, 'Configure PR Status Location', 'pr-status-location', 'ni-pr-status-right-side', { 'ni-pr-status-default': 'Default', 'ni-pr-status-right-side': 'Right Side', }); }); if (atNI) { eus.registerCssClassConfig(document.body, 'Display Agent Arbitration Status', 'agent-arbitration-status', 'agent-arbitration-status-off', { 'agent-arbitration-status-on': 'On', 'agent-arbitration-status-off': 'Off', }); eus.showTipOnce('release-2024-06-06', 'New in the AzDO userscript', `

Highlights from the 2024-06-06 update!

Changes to the build logs view:

See also other changes since our last update notification.


Comments, bugs, suggestions? File an issue on GitHub 🧡

`); } // Start modifying the page once the DOM is ready. if (document.readyState !== 'loading') { onReady(); } else { document.addEventListener('DOMContentLoaded', onReady); } } function onReady() { // 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; currentUser = pageData['ms.vss-web.page-data'].user; debug('init', pageData, currentUser); const theme = pageData['ms.vss-web.theme-data'].requestedThemeId; const isDarkTheme = /(dark|night|neptune)/i.test(theme); // Because of CORS, we need to make sure we're querying the same hostname for our AzDO APIs. azdoApiBaseUrl = `${window.location.origin}${pageData['ms.vss-tfs-web.header-action-data'].suiteHomeUrl}`; // Invoke our new eus-style features. watchPullRequestDashboard(); watchForWorkItemForms(); watchForNewDiffs(isDarkTheme); watchForShowMoreButtons(); watchForBuildResultsPage(); if (atNI) { watchForDiffHeaders(); watchFilesTree(); watchForKnownBuildErrors(pageData); } eus.onUrl(/\/pullrequest\//gi, (session, urlMatch) => { if (atNI) { watchForLVDiffsAndAddNIBinaryDiffButton(session); watchForReviewerList(session); // MOVE THIS HERE: conditionallyAddBypassReminderAsync(); } watchForStatusCardAndMoveToRightSideBar(session); addEditButtons(session); addTrophiesToPullRequest(session, pageData); fixImageUrls(session); }); eus.onUrl(/\/(agentqueues|agentpools)(\?|\/)/gi, (session, urlMatch) => { watchForAgentPage(session, pageData); }); eus.onUrl(/\/(_build)(\?|$)/gi, (session, urlMatch) => { watchForPipelinesPage(session, pageData); }); eus.onUrl(/\/(_git)/gi, (session, urlMatch) => { doEditAction(session); watchForRepoBrowsingPages(session); }); // Throttle page update events to avoid using up CPU when AzDO is adding a lot of elements during a short time (like on page load). const onPageUpdatedThrottled = _.throttle(onPageUpdated, 400, { leading: false, trailing: true }); // Handle any existing elements, flushing it to execute immediately. onPageUpdatedThrottled(); onPageUpdatedThrottled.flush(); // 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. $('body > div.full-size')[0].addEventListener('DOMNodeInserted', onPageUpdatedThrottled); } function watchForStatusCardAndMoveToRightSideBar(session) { if (!document.body.classList.contains('ni-pr-status-right-side')) return; addStyleOnce('pr-overview-sidebar-css', /* css */ ` /* Make the sidebar wider to accommodate the status moving there. */ .repos-overview-right-pane { width: 550px; }`); session.onEveryNew(document, '.page-content .flex-column > .bolt-table-card', status => { $(status).prependTo('.repos-overview-right-pane'); }); } async function fetchJsonAndCache(key, secondsToCache, url, version = 1, fixer = x => x) { let value; const fullKey = `azdo-userscripts-${key}`; const fullVersion = `1-${version}`; let cached; try { cached = JSON.parse(localStorage[fullKey]); } catch (e) { cached = null; } if (cached && cached.version === fullVersion && dateFns.isFuture(dateFns.parse(cached.expiryDate))) { value = cached.value; } else { localStorage.removeItem(fullKey); const response = await fetch(url); if (!response.ok) { throw new Error(`Bad status ${response.status} for <${url}>`); } else { value = await response.json(); value = fixer(value); } const expirationDate = new Date(Date.now() + (secondsToCache * 1000)); localStorage[fullKey] = JSON.stringify({ version: fullVersion, expiryDate: expirationDate.toISOString(), value, }); } return value; } function watchForPipelinesPage(session, pageData) { addStyleOnce('agent-css', /* css */ ` .pipeline-status-icon { margin: 5px !important; padding: 10px; border-radius: 25px; background: var(--search-selected-match-background); color: var(--palette-error); } `); const projectName = pageData['ms.vss-tfs-web.page-data'].project.name; const urlParams = new URLSearchParams(window.location.search); const urlDefinitionId = urlParams.get('definitionId'); if (urlDefinitionId) { // Single Pipeline View session.onEveryNew(document, '.ci-pipeline-details-header', pipelineTitleElement => { setPipelineDefinitionDetails(projectName, urlDefinitionId, pipelineTitleElement, 'div.title-m'); }); } else { // List of Pipelines View session.onEveryNew(document, '.bolt-table-row', pipelineTitleElement => { const href = pipelineTitleElement.href; if (href) { const pipelineHref = new URL(href); const pipelineUrlParams = new URLSearchParams(pipelineHref.search); const pipelineDefinitionId = pipelineUrlParams.get('definitionId'); setPipelineDefinitionDetails(projectName, pipelineDefinitionId, pipelineTitleElement, 'div.bolt-table-cell-content'); } }); } } async function setPipelineDefinitionDetails(projectName, definitionId, pipelineTitleElement, classToAppendTo) { const pipelineDetails = await fetchJsonAndCache( `definitionId${definitionId}`, 0.5, `${azdoApiBaseUrl}/${projectName}/_apis/build/definitions/${definitionId}`, 1, ); const pipelineQueueStatus = pipelineDetails.queueStatus; if (pipelineQueueStatus === 'enabled') { return; } const userIcon = document.createElement('span'); userIcon.title = `Pipeline Status: ${pipelineQueueStatus.toUpperCase()}`; userIcon.className = 'pipeline-status-icon fabric-icon'; userIcon.classList.add({ disabled: 'ms-Icon--Blocked', paused: 'ms-Icon--CirclePause' }[pipelineQueueStatus] || 'ms-Icon--Unknown'); const spanElement = $(pipelineTitleElement).find(classToAppendTo)[0]; spanElement.append(userIcon); } function watchForAgentPage(session, pageData) { addStyleOnce('agent-css', /* css */ ` .agent-icon.offline { width: 250px !important; } .disable-reason { padding: 5px; border-radius: 20px; margin-right: 3px; font-size: 12px; text-decoration: none; background: var(--search-selected-match-background); } input:read-only { cursor: not-allowed; color: #b1b1b1; } .agent-name-span { width: calc(100% - 60px); } .capabilities-holder { font-size: 20px; text-align: left; width: 40px; margin-right: 20px; overflow: hidden; text-overflow: ellipsis; } `); session.onEveryNew(document, '.pipelines-pool-agents.page-content.page-content-top', agentsTable => { // Disable List Virtualization with 'CTRL + ALT + V' document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'v', keyCode: 86, code: 'KeyV', which: 86, altKey: true, ctrlKey: true, shiftKey: false, metaKey: false, })); if (!document.getElementById('agentFilterInput')) { const regexFilterString = new URL(window.location.href).searchParams.get('agentFilter') || ''; const agentFilterBarElement = `
`; $(agentsTable).prepend(agentFilterBarElement); document.getElementById('agentFilterInput').addEventListener('input', filterAgentsDebouncer); document.getElementById('agentFilterInput').addEventListener('keydown', filterAgentsNow); document.getElementById('agentFilterRefresh').addEventListener('click', filterAgentsNow); document.getElementById('copyMatchedAgentsToClipboard').addEventListener('click', copyMatchedAgentsToClipboard); } filterAgents(); }); // Status of agents can change as the table is constantly updating. setInterval(filterAgents, 60000); } function copyMatchedAgentsToClipboard() { if (filterAgents.running) return; let matchString = ''; let total = 0; const agentRows = document.querySelectorAll('a.bolt-list-row.single-click-activation'); agentRows.forEach(agentRow => { const agentCells = agentRow.querySelectorAll('div'); const agentName = agentCells[1].innerText; if ($(agentRow).is(':visible')) { matchString += `${agentName}.*,`; total += 1; } }); navigator.clipboard.writeText(matchString); swal.fire({ icon: 'success', title: `${total} matched agents copied to clipboard!`, showConfirmButton: false, timer: 1500, }); } function filterAgentsNow(event) { if (event.key === 'Enter' || event.type === 'click') { filterAgents.enter = true; filterAgents(); } } function filterAgentsDebouncer() { let timeout; if (!document.hidden) { clearTimeout(timeout); timeout = setTimeout(filterAgents, 1000); } } async function filterAgents() { if (filterAgents.running) return; filterAgents.running = true; let regexFilterString = document.getElementById('agentFilterInput').value.trim(); let previousRegexFilterString = ''; let filterCheckCounter = 0; const minimumNumberOfMatches = 10; while (filterCheckCounter < minimumNumberOfMatches && !filterAgents.enter) { if (previousRegexFilterString === regexFilterString) { filterCheckCounter += 1; } else { filterCheckCounter = 0; } previousRegexFilterString = regexFilterString; regexFilterString = document.getElementById('agentFilterInput').value.trim(); /* eslint-disable no-await-in-loop, no-promise-executor-return */ await new Promise(r => setTimeout(r, 100)); /* eslint-enable no-await-in-loop, no-promise-executor-return */ } let regexFilter = ''; try { regexFilter = new RegExp(regexFilterString, 'i'); } catch (e) { showAllAgents(e); return; } document.getElementById('agentFilterCounter').innerText = 'Filtering...'; document.getElementById('agentFilterInput').readOnly = true; document.getElementById('copyMatchedAgentsToClipboard').disabled = true; // Try to push the filter term if possible. try { const currentUrl = new URL(window.location.href); currentUrl.searchParams.set('agentFilter', regexFilterString); window.history.pushState({}, '', currentUrl.toString()); } catch (e) { error(e); } const agentRows = document.querySelectorAll('a.bolt-list-row.single-click-activation'); const totalCount = agentRows.length; const poolName = Array.from(document.querySelectorAll('div.bolt-breadcrumb-item-text-container')).pop().innerText; const currentPoolId = await fetchJsonAndCache( `azdoPool${poolName}Id`, 12 * 60 * 60, `${azdoApiBaseUrl}/_apis/distributedtask/pools?poolName=${poolName}`, 1, poolInfo => poolInfo.value[0].id, ); const poolAgentsInfo = await fetchJsonAndCache( `azdoPool${poolName}IdAgents`, 6, `${azdoApiBaseUrl}/_apis/distributedtask/pools/${currentPoolId}/agents?includeCapabilities=True&propertyFilters=*`, 2, poolAgentsInfoWithCapabilities => { const filteredAgentInfo = {}; poolAgentsInfoWithCapabilities.value.forEach(agentInfo => { filteredAgentInfo[agentInfo.name] = { id: agentInfo.id, userCapabilities: agentInfo.userCapabilities || {}, properties: agentInfo.properties, }; }); return filteredAgentInfo; }, ); try { $('.hiddenAgentRow').show(); $(agentRows).find('.disable-reason').remove(); $(agentRows).find('.capabilities-holder').remove(); const matchedAgents = {}; agentRows.forEach(agentRow => { const agentCells = agentRow.querySelectorAll('div'); const agentName = agentCells[1].innerText; const disableReason = poolAgentsInfo[agentName].userCapabilities.DISABLE_REASON || null; const rowValue = agentRow.textContent.replace(/[\r\n]/g, '').trim() + disableReason; if (!regexFilter.test(rowValue)) { agentRow.classList.add('hiddenAgentRow'); } else { agentRow.classList.remove('hiddenAgentRow'); matchedAgents[agentName] = agentCells; } }); $('.hiddenAgentRow').hide(); for (const [agentName, agentCells] of Object.entries(matchedAgents)) { if (atNI) { agentCells[1].querySelectorAll('span')[0].classList.add('agent-name-span'); addAgentExtraInformation(agentCells, agentName, currentPoolId, poolAgentsInfo); } } document.getElementById('agentFilterCounter').innerText = `(${Object.keys(matchedAgents).length}/${totalCount})`; } catch (e) { showAllAgents(e); return; } exitFilterAgents(); } function exitFilterAgents() { document.getElementById('agentFilterInput').readOnly = false; document.getElementById('copyMatchedAgentsToClipboard').disabled = false; filterAgents.running = false; filterAgents.enter = false; } function addAgentExtraInformation(agentCells, agentName, currentPoolId, poolAgentsInfo) { const agentInfo = poolAgentsInfo[agentName]; if (agentInfo.userCapabilities) { const disableReason = agentInfo.userCapabilities.DISABLE_REASON || null; if (disableReason) { const userIcon = document.createElement('span'); userIcon.className = 'fabric-icon ms-Icon--Contact'; const hiddenDisableReason = document.createElement('a'); hiddenDisableReason.text = disableReason; hiddenDisableReason.style = 'display: none'; const disableReasonMessage = document.createElement('a'); let reservedBy = disableReason.match(/\[(.*)\]/); if (!reservedBy) { reservedBy = disableReason.match(/^([^-]*)/); } disableReasonMessage.className = 'disable-reason'; disableReasonMessage.text = reservedBy ? reservedBy[1] : disableReason; disableReasonMessage.href = `${azdoApiBaseUrl}/_settings/agentpools?agentId=${agentInfo.id}&poolId=${currentPoolId}&view=capabilities`; disableReasonMessage.prepend(userIcon); disableReasonMessage.prepend(hiddenDisableReason); disableReasonMessage.title = disableReason; disableReasonMessage.style = 'max-width: 200px; text-overflow: ellipsis; overflow: hidden'; $(agentCells[5]).prepend(disableReasonMessage); } } const capabilitiesHolder = document.createElement('div'); capabilitiesHolder.className = 'capabilities-holder'; const participateInRelease = agentInfo.userCapabilities.PARTICIPATE_IN_RELEASE || null; if (participateInRelease === '1') { const participateInReleaseElement = document.createElement('span'); participateInReleaseElement.className = 'capability-icon release-machine fabric-icon ms-Icon--Rocket'; participateInReleaseElement.title = 'PARTICIPATE_IN_RELEASE=1'; capabilitiesHolder.append(participateInReleaseElement); } if (!document.body.classList.contains('agent-arbitration-status-off')) { if (agentInfo.properties && Object.prototype.hasOwnProperty.call(agentInfo.properties, 'under_arbitration')) { const underArbitration = agentInfo.properties.under_arbitration.$value.toLowerCase() === 'true'; const iconType = underArbitration ? 'CirclePause' : 'Airplane'; const arbitrationIcon = document.createElement('span'); arbitrationIcon.className = `capability-icon arbiter fabric-icon ms-Icon--${iconType}`; arbitrationIcon.title = underArbitration ? 'Arbitration Started: ' : 'Last Arbitration: '; arbitrationIcon.title += new Date(agentInfo.properties.arbitration_start.$value * 1000); capabilitiesHolder.append(arbitrationIcon); } } agentCells[1].append(capabilitiesHolder); } function showAllAgents(searchError) { if (searchError) { document.getElementById('agentFilterCounter').innerText = searchError; } $('.hiddenAgentRow').show(); $('.hiddenAgentRow').removeClass('hiddenAgentRow'); exitFilterAgents(); } async function watchForReviewerList(session) { addStyleOnce('pr-reviewer-annotations', /* css */ ` .reviewer-status-message { font-size: 0.7em; margin-left: 2ch; padding: 0px 3px; border-radius: 4px; cursor: pointer; border: 1px solid #ffffff22; float: right; } .reviewer-status-message.ooo { background: var(--status-warning-background); color: var(--status-warning-foreground); } .reviewer-status-message.flag { background: none; border: none; } .reviewer-status-message.owner { background: rgba(var(--palette-primary), 1); color: #fff; } .reviewer-status-message.alternate { background: rgba(var(--palette-primary), 0.3); color: #fff; } .reviewer-status-message.expert { background: rgba(var(--palette-primary), 0.3); color: #fff; } .tippy-box[data-theme~='azdo-userscript'] { padding: 5px 10px; } .tippy-box[data-theme~='azdo-userscript'] li { list-style: disc; } .tippy-box[data-theme~='azdo-userscript'] h1 { background: rgb(255, 255, 255, 0.3); border-radius: 4px; margin-top: 0; padding: 5px 10px; font-size: 1.1em; text-align: center; line-height: 1.8em; } .tippy-box[data-theme~='azdo-userscript'] .user-message { } .owner-dir { opacity: 0.7; font-size: 80%; } .owner-file { display: inline-block; float: right; margin-left: 2ex; }`); const prUrl = await getCurrentPullRequestUrlAsync(); const ownersInfo = await getNationalInstrumentsPullRequestOwnersInfo(prUrl); const dataMaxAgeInDays = 3; let oooInfo; let oooByEmail; try { oooInfo = await fetchJsonAndCache('outOfOfficeLatest', 12 * 60 * 60, `${azdoApiBaseUrl}/_apis/git/repositories/3378df6b-8fc9-41dd-a9d9-16640f2392cb/items?api-version=6.0&path=/data/outOfOfficeLatest.json&version=main`); if (oooInfo.version === 1) { const dataDate = dateFns.parse(oooInfo.date); if (dateFns.differenceInDays(new Date(), dataDate) >= dataMaxAgeInDays) { // This data is too old. It hasn't been updated properly by the pipeline producing it. Avoid annotating. throw new Error(`Data is too old (must be ${dataMaxAgeInDays} days old or less). Data date is: ${dataDate.toISOString()}`); } oooByEmail = _.keyBy(oooInfo.value, 'Email'); } else { throw new Error(`Invalid version: ${oooInfo.version}`); } } catch (e) { oooInfo = null; error(`Cannot annotate out-of-office info on PRs: ${e}`); } let employeeInfo; let employeeByEmail; let me; try { employeeInfo = await fetchJsonAndCache('employeesLatest', 12 * 60 * 60, `${azdoApiBaseUrl}/_apis/git/repositories/3378df6b-8fc9-41dd-a9d9-16640f2392cb/items?api-version=6.0&path=/data/employeesLatest.json&version=main`, 3, employees => { // HACK: Make the data much smaller so it fits in local storage. for (const employee of employees.value) { delete employee.username; delete employee.title; delete employee.manager_email; delete employee.hr_org; switch (employee.status) { case 'Active Assignment': delete employee.status; break; case 'Terminate Assignment': employee.status = 'Ex-Employee'; break; case 'LOA': employee.status = 'Leave of Absence'; break; default: // Keep it. break; } } return employees; }); if (employeeInfo.version === 1) { const dataDate = dateFns.parse(employeeInfo.date); if (dateFns.differenceInDays(new Date(), dataDate) >= dataMaxAgeInDays) { // This data is too old. It hasn't been updated properly by the pipeline producing it. Avoid annotating. throw new Error(`Data is too old (must be ${dataMaxAgeInDays} days old or less). Data date is: ${dataDate.toISOString()}`); } employeeByEmail = _.keyBy(employeeInfo.value, 'email'); me = employeeByEmail[currentUser.uniqueName]; } else { throw new Error(`Invalid version: ${employeeInfo.version}`); } } catch (e) { employeeInfo = null; error(`Cannot annotate employee info on PRs: ${e}`); } session.onEveryNew(document, '.repos-pr-details-page .repos-reviewer', reviewer => { const imageUrl = $(reviewer).find('.bolt-coin-content')[0].src; const reviewerInfos = getPropertyThatStartsWith(reviewer.parentElement.parentElement, '__reactInternalInstance$').return.stateNode.state.values.reviewers; const reviewerInfo = _.find(reviewerInfos, r => imageUrl.startsWith(r.identity.imageUrl)); const email = reviewerInfo.baseReviewer.uniqueName.toLowerCase(); const nameElement = $(reviewer).find('.body-m')[0]; if (ownersInfo) { const reviewerIdentityIndex = _.findIndex(ownersInfo.reviewProperties.reviewerIdentities, r => r.email === email); if (reviewerIdentityIndex >= 0) { // eslint-disable-next-line no-inner-declarations function annotateReviewerRole(label, cssClass, matcher) { const files = _.filter(ownersInfo.reviewProperties.fileProperties, matcher).map(f => f.path); if (files.length > 0) { let prefix = ''; let filesToShow = files.sort(); const maxFilesToShow = 25; if (files.length > maxFilesToShow) { filesToShow = _.take(filesToShow, maxFilesToShow); prefix = `

Showing first ${maxFilesToShow}:

`; } const fileListing = filesToShow .map(f => `
  • ${escapeStringForHtml(f).replace(/^(.*\/)?([^/]+?)$/, '$1$2')}
  • `) .join(''); annotateReviewer(nameElement, cssClass, `${files.length}× ${label}`, `
    ${prefix}${fileListing}
    `, 'none'); } } // Note that the values for file.owner/alternate/experts may contain the value 0 (which is not a valid 1-based index) to indicate nobody for that role. annotateReviewerRole('owner', 'owner', f => f.owner === reviewerIdentityIndex + 1); annotateReviewerRole('alternate', 'alternate', f => f.alternate === reviewerIdentityIndex + 1); annotateReviewerRole('expert', 'expert', f => _.some(f.experts, e => e === reviewerIdentityIndex + 1)); } } if (employeeInfo) { const employee = employeeByEmail[email]; if (employee) { if (me.country !== employee.country) { annotateReviewer(nameElement, 'flag', `${employee.country} flag`, escapeStringForHtml(employee.location_code)); } if (employee.status) { let status = employee.status; if (status === 'Leave Without Pay') { // Be nice and not show this status like this. status = 'On Leave'; } annotateReviewer(nameElement, 'ooo', escapeStringForHtml(status)); } } } if (oooInfo) { const ooo = oooByEmail[email]; if (ooo && dateFns.isFuture(ooo.End)) { let label; if (dateFns.isFuture(ooo.Start)) { if (dateFns.differenceInHours(ooo.Start, new Date()) <= 24) { label = 'Unavailable in <24h'; } else { // Don't show a label. This person is leaving too far into the future. label = null; } } else { label = `Returns in ${dateFns.distanceInWordsToNow(ooo.End)}`; } if (label) { const tooltipHtml = `

    Outlook Auto Response

    Leaving ${dateFns.format(ooo.Start, 'ha ddd, MMM D, YYYY')}
    Returning ${dateFns.format(ooo.End, 'ha ddd, MMM D, YYYY')}

    ${ooo.Text.replace(/\r?\n/ig, '

    ')}

    `; annotateReviewer(nameElement, 'ooo', escapeStringForHtml(label), tooltipHtml); } } } }); } function annotateReviewer(nameElement, cssClass, labelHtml, tooltipHtml, maxWidth = '600px') { const messageElement = $('').addClass(cssClass).html(labelHtml); if (tooltipHtml) { tippy(messageElement[0], { content: tooltipHtml, allowHTML: true, arrow: true, theme: 'azdo-userscript', maxWidth, }); } $(nameElement).append(messageElement); } function addEditButtons(session) { session.onEveryNew(document, '.repos-summary-header > div:first-child .flex-column .secondary-text:nth-child(2)', path => { const end = $(path).closest('.flex-row').find('.justify-end'); const branchUrl = $('.pr-header-branches a:first-child').attr('href'); const url = `${branchUrl}&path=${path.innerText}&_a=diff&azdouserscriptaction=edit`; $('Edit').attr('href', url).appendTo(end); }); session.onEveryNew(document, '.repos-compare-header-commandbar.bolt-button-group', button => { if (eus.seen(button)) return; $(button).before(setupVSCodeButton(() => { if ($('.pr-status-completed').length > 0) { eus.toast.fire({ title: 'AzDO userscript', text: 'Cannot open VSCode on a completed pull request.', icon: 'error', }); return null; } const urlParams = new URLSearchParams(window.location.search); const path = urlParams.get('path') || ''; const branchUrl = `${window.location.origin}${$('.pr-header-branches a').attr('href')}`; const url = `${branchUrl}&path=${path}`; return url; })); }); } async function doEditAction(session) { if (window.location.search.indexOf('azdouserscriptaction=edit') >= 0) { await eus.sleep(1500); $('button#__bolt-edit').click(); $('div#__bolt-tab-diff').click(); } } // This is "main()" for this script. Runs periodically when the page updates. function onPageUpdated() { try { // 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)) { // TODO: BROKEN IN NEW PR UX: applyStickyPullRequestComments(); // TODO: BROKEN IN NEW PR UX: highlightAwaitComments(); addAccessKeysToPullRequestTabs(); if (atNI) { conditionallyAddBypassReminderAsync(); } } if (/\/(pullrequests)/i.test(window.location.pathname)) { addOrgPRLink(); } } catch (e) { eus.toast.fire({ title: 'AzDO userscript error', text: 'See JS console for more info.', icon: 'error', showConfirmButton: true, confirmButtonColor: '#d43', confirmButtonText: ' Get Help!', }).then((result) => { if (result.value) { window.open(GM_info.script.supportURL, '_blank'); } }); throw e; } } enhanceOverallUX(); addStyleOnce('labels', /* css */ ` /* Known bug severities we should style. */ .pr-bug-severity-1 { background: #a008 !important; } .pr-bug-severity-2 { background: #fd38 !important; } /* Align labels to the right and give them a nice border. */ .repos-pr-list .bolt-pill-group { flex-grow: 1; justify-content: flex-end; } .bolt-pill { border: 1px solid #0001; } /* Known labels we should style. */ .pr-annotation:not([title=""]) { cursor: help !important; } .pr-annotation.file-count, .pr-annotation.build-status { background: #fff4 !important; min-width: 8ex; }`); if (atNI) { addStyleOnce('ni-labels', /* css */ ` /* Known labels we should style. */ .bolt-pill[aria-label='draft' i] { background: #8808 !important; } .bolt-pill[aria-label='tiny' i] { background: #0a08 !important; } .bolt-pill[aria-label~='blocked' i] { background: #a008 !important; }`); } addStyleOnce('bypassOwnersPrompt', /* css */ ` .bypass-reminder { display: inline; position: absolute; top: 38px; left: -250px; z-index: 1000; background-color: #E6B307; color: #222; font-weight: bold; padding: 3ch 5ch; font-size: 16px; border-radius: 6px 0px 6px 6px; box-shadow: 4px 4px 4px #18181888; opacity: 0; transition: 0.3s; } .bypass-reminder-container { position: relative; display: inline-flex; flex-direction: column; } .vote-button-wrapper { border: 3px solid transparent; border-radius: 4px 4px 0px 0px; transition: 0.3s; } .vote-button-wrapper:hover { border-color: #E6B307; } .vote-button-wrapper:hover ~ .bypass-reminder { opacity: 1; }`); function watchForWorkItemForms() { eus.globalSession.onEveryNew(document, '.menu-item.follow-item-menu-item-gray', followButton => { followButton.addEventListener('click', async _ => { await eus.sleep(100); // We need to allow the other handlers to send the request to follow/unfollow. After the request is sent, we can annotate our follows list correctly. await annotateWorkItemWithFollowerList(document.querySelector('.discussion-messages-right')); }); }); // Annotate work items (under the comment box) with who is following it. eus.globalSession.onEveryNew(document, '.discussion-messages-right', async commentEditor => { await annotateWorkItemWithFollowerList(commentEditor); }); } async function annotateWorkItemWithFollowerList(commentEditor) { document.querySelectorAll('.work-item-followers-list').forEach(e => e.remove()); const workItemId = commentEditor.closest('.witform-layout').querySelector('.work-item-form-id > span').innerText; const queryResponse = await fetch(`${azdoApiBaseUrl}/_apis/notification/subscriptionquery?api-version=6.0`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ conditions: [ { filter: { type: 'Artifact', eventType: '', artifactId: workItemId, artifactType: 'WorkItem', }, }, ], queryFlags: 'alwaysReturnBasicInformation', }), }); const followers = [...(await queryResponse.json()).value].sort((a, b) => a.subscriber.displayName.localeCompare(b.subscriber.displayName)); const followerList = followers .map(s => `${s.subscriber.displayName}`) .join(', ') || 'Nobody'; const annotation = `
    ${followerList}
    `; commentEditor.insertAdjacentHTML('BeforeEnd', annotation); } function watchForRepoBrowsingPages(session) { // Add a copy branch button. session.onEveryNew(document, '.version-dropdown > button', versionSelector => { if (eus.seen(versionSelector.parent())) return; const copyButton = $(''); const helpButton = $(''); launchDiffButton.on('click', (event) => { const currentUrl = new URL(window.location.href); let iterationIndex = currentUrl.searchParams.get('iteration'); if (iterationIndex) { iterationIndex -= 1; } else { iterationIndex = iterations.length - 1; } const afterCommitId = iterations[iterationIndex].sourceRefCommit.commitId; let beforeCommitId = iterations[0].commonRefCommit.commitId; let baseIndex = currentUrl.searchParams.get('base'); if (baseIndex) { baseIndex -= 1; if (baseIndex >= 0) { beforeCommitId = iterations[baseIndex].sourceRefCommit.commitId; } } const protocolHandlerAddress = `NIBinary.GitDiff:${filePath},${beforeCommitId},${afterCommitId}`; window.location = protocolHandlerAddress; }); helpButton.on('click', (event) => { swal.fire({ title: 'This is a preview feature!', icon: 'warning', text: 'You need to install the "NIBinary.GitDiff.reg" Protocol Handler first. To get it, please talk to Humberto Garza, or search for "Launch NI Binary Git Diff" in the Azure DevOps NI Wiki for instructions.', confirmButtonColor: '#03b585', confirmButtonText: 'Close', }); }); $(boltMessageBarButtons).append(launchDiffButton); $(boltMessageBarButtons).append(helpButton); }); } addStyleOnce('pr-dashboard-css', /* css */ ` table.repos-pr-list tbody > a { transition: 0.2s; } table.repos-pr-list tbody > a.voted-waiting > td > * { opacity: 0.15; } .repos-pr-list-late-review-pill.outlined { border-color: #f00; border-color: var(--status-error-text,rgba(177, 133, 37, 1)); color: #f00; color: var(--status-error-text,rgba(177, 133, 37, 1)); background: var(--status-error-background,rgba(177, 133, 37, 1)); cursor: help; }`); function watchPullRequestDashboard() { eus.onUrl(/\/(_pulls|pullrequests)/gi, (session, urlMatch) => { session.onEveryNew(document, '.repos-pr-section-card', section => { const sectionTitle = section.querySelector('.repos-pr-section-header-title > span').innerText; if (sectionTitle !== 'Assigned to me' && sectionTitle !== 'Created by me') return; session.onEveryNew(section, 'a[role="row"]', (row, addedDynamically) => { // AzDO re-adds PR rows when it updates them with in JS. That's the one we want to enhance. if (!addedDynamically) return; enhancePullRequestRow(row, sectionTitle); // React will re-use this DOM element, so we need to re-enhance. session.onAnyChangeTo(row, () => enhancePullRequestRow(row, sectionTitle)); }); }); }); } function watchForBuildResultsPage() { eus.onUrl(/\/(_build\/results)/gi, (session, urlMatch) => { session.onEveryNew(document, '.bolt-master-panel', panel => { $(panel).css('resize', 'horizontal'); }); }); } async function enhancePullRequestRow(row, sectionTitle) { const pullRequestUrl = new URL(row.href, window.location.origin); const pullRequestId = parseInt(pullRequestUrl.pathname.substring(pullRequestUrl.pathname.lastIndexOf('/') + 1), 10); // Skip if we've already processed this PR. if (row.dataset.pullRequestId === pullRequestId.toString()) return; // eslint-disable-next-line no-param-reassign row.dataset.pullRequestId = pullRequestId; // TODO: If you switch between Active and Reviewed too fast, you may get duplicate annotations. // Remove annotations a previous PR may have had. Recall that React reuses DOM elements. row.classList.remove('voted-waiting'); for (const element of row.querySelectorAll('.repos-pr-list-late-review-pill')) { element.remove(); } for (const element of row.querySelectorAll('.userscript-bolt-pill-group')) { element.remove(); } for (const element of row.querySelectorAll('.pr-annotation')) { element.remove(); } const pr = await getPullRequestAsync(pullRequestId); // Sometimes, PRs lose their styling shortly after the page loads. A slight delay makes this problem go away, 99% of the time. Sucks -- but works and better to have this than not. await eus.sleep(333); if (sectionTitle === 'Assigned to me') { const votes = countVotes(pr); // TODO: If you press the PR menu button, the PR loses it's styling. row.classList.toggle('voted-waiting', votes.userVote === -5); await annotateBugsOnPullRequestRow(row, pr); await annotateFileCountOnPullRequestRow(row, pr); await annotateBuildStatusOnPullRequestRow(row, pr); if (votes.userVote === 0 && votes.missingVotes === 1 && votes.userIsRequired && !votes.userHasDeclined) { annotatePullRequestTitle(row, 'repos-pr-list-late-review-pill', 'Last Reviewer', 'Everyone is waiting on you!'); } if (atNI && votes.userVote === 0) { const prThreadsNewestFirst = (await $.get(`${pr.url}/threads?api-version=5.0`)).value.filter(x => !x.isDeleted).reverse(); const dateAdded = getReviewerAddedOrResetTime(prThreadsNewestFirst, currentUser.uniqueName) || pr.createdDate; const weekDays = differenceInWeekDays(new Date(dateAdded), new Date()); if (weekDays >= 1) { const lastInteraction = getReviewerLastInteractionTime(prThreadsNewestFirst, currentUser.uniqueName); if (!lastInteraction || new Date(dateAdded) > new Date(lastInteraction)) { annotatePullRequestTitle(row, 'repos-pr-list-late-review-pill', `${weekDays} days old`, "# of week days since you've been added or reset. Reviewers are expected to comment or vote within 1 business day."); } } } } else { await annotateBugsOnPullRequestRow(row, pr); await annotateFileCountOnPullRequestRow(row, pr); await annotateBuildStatusOnPullRequestRow(row, pr); } } function differenceInWeekDays(startDate, endDate) { let days = (endDate - startDate) / (1000.0 * 60 * 60 * 24); const date = new Date(startDate); while (date <= endDate) { if (date.getDay() === 0 || date.getDay() === 6) { days -= 1.0; } date.setDate(date.getDate() + 1); } return days < 0 ? 0 : days.toFixed(1); } function getReviewerAddedOrResetTime(prThreadsNewestFirst, reviewerUniqueName) { for (const thread of prThreadsNewestFirst) { if (thread.properties) { if (Object.prototype.hasOwnProperty.call(thread.properties, 'CodeReviewReviewersUpdatedAddedIdentity')) { const addedReviewer = thread.identities[thread.properties.CodeReviewReviewersUpdatedAddedIdentity.$value]; if (addedReviewer.uniqueName === reviewerUniqueName) { return thread.publishedDate; } } else if (Object.prototype.hasOwnProperty.call(thread.properties, 'CodeReviewResetMultipleVotesExampleVoterIdentities')) { if (Object.keys(thread.identities).filter(x => thread.identities[x].uniqueName === reviewerUniqueName)) { return thread.publishedDate; } } } } return null; } function getReviewerLastInteractionTime(prThreadsNewestFirst, reviewerUniqueName) { for (const thread of prThreadsNewestFirst) { // This includes both user comments, threads, and votes (since votes post comments). for (const comment of thread.comments) { if (comment.author.uniqueName === reviewerUniqueName) { return comment.publishedDate; } } } return null; } function countVotes(pr) { const votes = { missingVotes: 0, waitingOrRejectedVotes: 0, userVote: 0, userIsRequired: true, userHasDeclined: false, }; for (const reviewer of pr.reviewers) { if (reviewer.uniqueName === currentUser.uniqueName) { votes.userVote = reviewer.vote; votes.userIsRequired = reviewer.isRequired; votes.userHasDeclined = reviewer.hasDeclined; } if (reviewer.vote === 0) { votes.missingVotes += 1; } else if (reviewer.vote < 0) { votes.waitingOrRejectedVotes += 1; } } return votes; } async function annotateBugsOnPullRequestRow(row, pr) { const workItemRefs = (await $.get(`${pr.url}/workitems?api-version=5.1`)).value; let highestSeverityBug = null; let highestSeverity = 100; // highest sev is lowest number let otherHighestSeverityBugsCount = 0; for (const workItemRef of workItemRefs) { // eslint-disable-next-line no-await-in-loop const workItem = await $.get(`${workItemRef.url}?api-version=5.1`); if (workItem.fields['System.WorkItemType'] === 'Bug') { const severityString = workItem.fields['Microsoft.VSTS.Common.Severity']; if (severityString) { const severity = parseInt(severityString.replace(/ - .*$/, ''), 10); if (severity < highestSeverity) { // lower severity value is higher severity highestSeverity = severity; highestSeverityBug = workItem; otherHighestSeverityBugsCount = 0; } else if (severity === highestSeverity) { otherHighestSeverityBugsCount += 1; } } } } if (highestSeverityBug && highestSeverity <= 2) { let title = highestSeverityBug.fields['System.Title']; if (otherHighestSeverityBugsCount) { title += ` (and ${otherHighestSeverityBugsCount} other)`; } annotatePullRequestLabel(row, `pr-bug-severity-${highestSeverity}`, title, `SEV${highestSeverity}`); } } async function annotateFileCountOnPullRequestRow(row, pr) { let fileCount; if (pr.lastMergeCommit) { fileCount = 0; // See if this PR has owners info and count the files listed for the current user. const ownersInfo = await getNationalInstrumentsPullRequestOwnersInfo(pr.url); if (ownersInfo) { fileCount = ownersInfo.currentUserFileCount; } // If there is no owner info or if it returns zero files to review (since we may not be on the review explicitly), then count the number of files in the merge commit. if (fileCount === 0) { const mergeCommitInfo = await $.get(`${pr.lastMergeCommit.url}/changes?api-version=5.0`); const files = _(mergeCommitInfo.changes).filter(item => !item.item.isFolder); fileCount = files.size(); } } else { fileCount = '⛔'; } const label = ` ${fileCount}`; annotatePullRequestLabel(row, 'file-count', '# of files you need to review', label); } async function annotateBuildStatusOnPullRequestRow(row, pr) { if (!pr.lastMergeCommit) return; const builds = (await $.get(`${pr.lastMergeCommit.url}/statuses?api-version=5.1&latestOnly=true`)).value; if (!(builds && builds.length)) return; let state; if (builds.every(b => b.state === 'succeeded' || b.description.includes('partially succeeded'))) { state = '✔️'; } else if (builds.some(b => b.state === 'pending')) { state = '▶️'; } else { state = '❌'; } const tooltip = _.map(builds, 'description').join('\n'); const label = ` ${state}`; annotatePullRequestLabel(row, 'build-status', tooltip, label); } function annotatePullRequestTitle(row, cssClass, message, tooltip) { const blockingAnnotation = ` `; const title = row.querySelector('.body-l'); title.insertAdjacentHTML('afterend', blockingAnnotation); } function annotatePullRequestLabel(pullRequestRow, cssClass, title, html) { let labels = pullRequestRow.querySelector('.bolt-pill-group-inner'); // The PR may not have any labels to begin with, so we have to construct the label container. if (!labels) { // eslint-disable-next-line prefer-destructuring const labelContainer = $(`
    `)[0]; pullRequestRow.querySelector('.body-l').insertAdjacentElement('afterend', labelContainer); labels = pullRequestRow.querySelector('.bolt-pill-group-inner'); } const label = ` `; labels.insertAdjacentHTML('beforeend', label); } let globalOwnersInfo; function onFilesTreeChange() { const hasOwnersInfo = globalOwnersInfo && globalOwnersInfo.currentUserFileCount > 0; if (!hasOwnersInfo) { return; } $('.repos-changes-explorer-tree .bolt-tree-row').each(function () { const fileRow = $(this); const text = fileRow.find('span.text-ellipsis'); const item = text.parent(); // For non-file/folder items in the tree (e.g. comments), we won't find a text span if (text.length === 0) { return; } /* eslint no-underscore-dangle: ["error", { "allow": ["_owner"] }] */ const pathAndChangeType = getPropertyThatStartsWith(text[0], '__reactInternalInstance$').memoizedProps.children._owner.stateNode.props.data.path; const pathWithLeadingSlash = pathAndChangeType.replace(/ \[[a-z]+\]( renamed from .+)?$/, ''); const path = pathWithLeadingSlash.substring(1); // Remove leading slash. const isFolder = item[0].children[0].classList.contains('repos-folder-icon'); // If we have owners info, mark folders that have files we need to review. This will allow us to highlight them if they are collapsed. const folderContainsFilesToReview = isFolder && globalOwnersInfo.isCurrentUserResponsibleForFileInFolderPath(`${path}/`); fileRow.toggleClass('folder-to-review-row', folderContainsFilesToReview); fileRow.toggleClass('auto-collapsible-folder', !folderContainsFilesToReview); // If we have owners info, highlight the files we need to review and add role info. const isFileToReview = !isFolder && globalOwnersInfo.isCurrentUserResponsibleForFile(path); item.parent().toggleClass('file-to-review-row', isFileToReview); if (isFileToReview) { if (fileRow.find('.file-owners-role').length === 0) { $('
    ').text(`${globalOwnersInfo.currentUserFilesToRole[path]}:`).prependTo(item.parent()); } } else { fileRow.find('.file-owners-role').remove(); } }); } function watchFilesTree() { addStyleOnce('pr-file-tree-annotations-css', ` :root { --file-to-review-color: var(--communication-foreground); } .repos-changes-explorer-tree .file-to-review-row, .repos-changes-explorer-tree .file-to-review-row .text-ellipsis { color: var(--file-to-review-color) !important; transition-duration: 0.2s; } .repos-changes-explorer-tree .folder-to-review-row[aria-expanded='false'], .repos-changes-explorer-tree .folder-to-review-row[aria-expanded='false'] .text-ellipsis { color: var(--file-to-review-color); transition-duration: 0.2s; } .repos-changes-explorer-tree .file-to-review-row .file-owners-role { font-weight: bold; padding: 7px 10px; position: absolute; z-index: 100; float: right; }`); eus.onUrl(/\/pullrequest\//gi, (session, urlMatch) => { session.onEveryNew(document, '.repos-changes-explorer-tree', async tree => { // Get the current iteration of the PR. const prUrl = await getCurrentPullRequestUrlAsync(); // Get owners info for this PR. globalOwnersInfo = await getNationalInstrumentsPullRequestOwnersInfo(prUrl); const hasOwnersInfo = globalOwnersInfo && globalOwnersInfo.currentUserFileCount > 0; if (hasOwnersInfo) { const onFilesTreeChangeThrottled = _.throttle(onFilesTreeChange, 50, { leading: false, trailing: true }); session.onAnyChangeTo(tree, t => { onFilesTreeChangeThrottled(); }); onFilesTreeChangeThrottled(); } }); }); } function watchForDiffHeaders() { addStyleOnce('pr-file-diff-annotations-css', /* css */ ` :root { /* Set some constants for our CSS. */ --file-to-review-header-color: rgba(0, 120, 212, 0.2); } .repos-summary-header > .flex-row.file-to-review-header { /* Highlight files I need to review. */ abackground-color: var(--file-to-review-header-color) !important; transition-duration: 0.2s; } .repos-summary-header > .flex-row.file-to-review-header > .flex-row { background: none; background-color: var(--file-to-review-header-color) !important; } .file-owners-role-header { /* Style the role of the user in the files table. */ font-weight: bold; padding: 7px 10px; }`); // Update expandedFilesCache when an expand-button is clicked // TODO: Make this optional. let expandedFilesCache = {}; document.addEventListener('click', e => { const collapseButton = e.target.closest('.bolt-card-expand-button'); if (collapseButton) { const wasExpanded = collapseButton.getAttribute('aria-expanded') === 'true'; const isExpanded = !wasExpanded; const pathWithLeadingSlash = collapseButton.parentElement.querySelector('.secondary-text.text-ellipsis').textContent; expandedFilesCache[pathWithLeadingSlash] = isExpanded; } }); eus.onUrl(/\/pullrequest\//gi, async (session, urlMatch) => { // Get the current iteration of the PR. const prUrl = await getCurrentPullRequestUrlAsync(); const prCreatedBy = await getCurrentPullRequestCreatedBy(); // Get owners info for this PR. const ownersInfo = await getNationalInstrumentsPullRequestOwnersInfo(prUrl); const hasOwnersInfo = ownersInfo && ownersInfo.currentUserFileCount > 0; const autoCollapse = hasOwnersInfo && currentUser.uniqueName !== prCreatedBy.uniqueName; // Reset the cache for each new PR. expandedFilesCache = {}; session.onEveryNew(document, '.repos-summary-header', diff => { const header = diff.children[0]; const pathWithLeadingSlash = header.querySelector('.secondary-text.text-ellipsis').textContent; const path = pathWithLeadingSlash.substring(1); // Remove leading slash. if (hasOwnersInfo && ownersInfo.isCurrentUserResponsibleForFile(path)) { header.classList.add('file-to-review-header'); $('
    ').text(`${ownersInfo.currentUserFilesToRole[path]}:`).prependTo(header.children[1]); } if (pathWithLeadingSlash in expandedFilesCache) { if (!expandedFilesCache[pathWithLeadingSlash]) { header.querySelector('button[aria-label="Collapse"]').click(); } } else if (autoCollapse) { if (!ownersInfo.isCurrentUserResponsibleForFile(path)) { // TODO: Make this optional. header.querySelector('button[aria-label="Collapse"]').click(); } } }); }); } function watchForKnownBuildErrors(pageData) { addStyleOnce('known-build-errors-css', /* css */ ` .infra-errors-card h3 { margin-top: 0; display: inline-block; } .loading-indicator { margin-left: 3ch; } .task-list { margin-top: 0; } .infra-errors-card ul { margin-bottom: 0; margin-left: 4ch; } .infra-errors-card li { margin-bottom: 0.5em; list-style-type: disc; } .infra-errors-card li li { margin-bottom: 0; list-style-type: disc; opacity: 0.7; } .infra-errors-card li span { margin-bottom: 0.5em; }`); eus.onUrl(/\/_build\/results\?buildId=\d+&view=results/gi, (session, urlMatch) => { session.onEveryNew(document, '.run-details-tab-content', async tabContent => { const runDetails = pageData['ms.vss-build-web.run-details-data-provider']; const projectId = pageData['ms.vss-tfs-web.page-data'].project.id; const buildId = runDetails.id; const pipelineName = runDetails.pipeline.name; const actualBuildId = parseInt(urlMatch[0].match(/\d+/)[0], 10); if (buildId !== actualBuildId) { // eslint-disable-next-line no-restricted-globals location.reload(); } if (!runDetails.issues) { return; // do not even add an empty section } let queryResponse; try { queryResponse = await fetch(`${azdoApiBaseUrl}/DevCentral/_apis/git/repositories/tools/items?path=/report/build_failure_analysis/pipeline-results/known-issues.json&api-version=6.0`); } catch (err) { debug('Could not fetch known issues file from AzDO'); return; } const knownIssues = await queryResponse.json(); if (!knownIssues.version.match(/^1(\.\d+)?$/)) { debug(`Version ${knownIssues.version} of known-issues.json is not one I know what to do with`); return; } if (!(new RegExp(knownIssues.pipeline_match).test(pipelineName))) { return; // do not even add an empty section } const flexColumn = tabContent.children[0]; const summaryCard = flexColumn.children[1]; const newCard = $('
    ')[0]; const newCardContent = $('
    '); newCardContent.appendTo(newCard); summaryCard.insertAdjacentElement('afterend', newCard); $('

    Known Infrastructure Errors

    Loading...').appendTo(newCardContent); // Fetch build timeline (which contains records with log urls) queryResponse = await fetch(`${azdoApiBaseUrl}/${projectId}/_apis/build/builds/${buildId}/timeline?api-version=6.0`); const timeline = await queryResponse.json(); // Fetch build logs, which give us line counts queryResponse = await fetch(`${azdoApiBaseUrl}/${projectId}/_apis/build/builds/${buildId}/logs?api-version=6.0`); const logsJson = (await queryResponse.json()).value; const infraErrorsList = $('
      '); infraErrorsList.appendTo(newCardContent); const tasksWithInfraErrors = []; let numTasksAdded = 0; // For each task with issues for (let i = 0; i < runDetails.issues.length; i += 1) { let infraErrorCount = 0; const taskWithIssues = runDetails.issues[i]; const componentListItem = $(`
    • ${taskWithIssues.taskName}
    • `); const componentSublist = $('
        '); componentSublist.appendTo(componentListItem); // Find the timeline record for the task, then get the log url for (let j = 0; j < timeline.records.length; j += 1) { if (timeline.records[j].task != null && timeline.records[j].id === taskWithIssues.taskId) { const logUrl = timeline.records[j].log.url; const logId = timeline.records[j].log.id; let logLines = 0; for (let k = 0; k < logsJson.length; k += 1) { if (logsJson[k].id === logId) { logLines = logsJson[k].lineCount; break; } } if (logLines > 100000) { const content = '
      • ⚠️Warning: log file too large to parse
      • '; $(content).appendTo(componentSublist); infraErrorCount += 1; break; } // Fetch the log // eslint-disable-next-line no-await-in-loop queryResponse = await fetch(logUrl); // eslint-disable-next-line no-await-in-loop const log = await queryResponse.text(); // Test all patterns against log const knownBuildErrors = knownIssues.log_patterns; for (let k = 0; k < knownBuildErrors.length; k += 1) { if (knownBuildErrors[k].category === 'Infrastructure' && new RegExp(knownBuildErrors[k].pipeline_match).test(pipelineName)) { const matchString = knownBuildErrors[k].match; let matchFlag = 'gi'; if (knownBuildErrors[k].match_flag === 'multiline') { matchFlag = 'gim'; } else if (knownBuildErrors[k].match_flag === 'dotmatchall') { matchFlag = 'gis'; } const matches = log.match(new RegExp(matchString, matchFlag)) || []; if (matches.length) { let content = `${knownBuildErrors[k].cause} (x${matches.length})`; if (knownBuildErrors[k].public_comment) { content = `${content}
        ${knownBuildErrors[k].public_comment}`; } $(`
      • ${content}
      • `).appendTo(componentSublist); infraErrorCount += 1; tasksWithInfraErrors.push(taskWithIssues.taskName); } } } break; } } if (infraErrorCount) { componentListItem.appendTo(infraErrorsList); numTasksAdded += 1; } } if (numTasksAdded === 0) { $('

        None

        ').appendTo(newCardContent); } if (knownIssues.more_info_html) { $(knownIssues.more_info_html).appendTo(newCardContent); } session.onEveryNew(document, '.issues-card-content .secondary-text', secondaryText => { const taskName = secondaryText.textContent.split(' • ')[1]; if (tasksWithInfraErrors.includes(taskName)) { $(' ⚠️POSSIBLE INFRASTRUCTURE ERROR').appendTo(secondaryText); } }); newCardContent.find('.loading-indicator').remove(); }); }); } function watchForNewDiffs(isDarkTheme) { if (isDarkTheme) { addStyleOnce('highlight', ` .hljs { display: block; overflow-x: auto; background: #1e1e1e; color: #dcdcdc; } .hljs-keyword, .hljs-literal, .hljs-name, .hljs-symbol { color: #569cd6; } .hljs-link { color: #569cd6; text-decoration: underline; } .hljs-built_in, .hljs-type { color: #4ec9b0; } .hljs-class, .hljs-number { color: #b8d7a3; } .hljs-meta-string, .hljs-string { color: #d69d85; } .hljs-regexp, .hljs-template-tag { color: #9a5334; } .hljs-formula, .hljs-function, .hljs-params, .hljs-subst, .hljs-title { color: var(--text-primary-color, rgba(0, 0, 0, .7)); } .hljs-comment, .hljs-quote { color: #57a64a; font-style: italic; } .hljs-doctag { color: #608b4e; } .hljs-meta, .hljs-meta-keyword, .hljs-tag { color: #9b9b9b; } .hljs-meta-keyword { font-weight: bold; } .hljs-template-variable, .hljs-variable { color: #bd63c5; } .hljs-attr, .hljs-attribute, .hljs-builtin-name { color: #9cdcfe; } .hljs-section { color: gold; } .hljs-emphasis { font-style: italic; } .hljs-strong { font-weight: 700; } .hljs-bullet, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id, .hljs-selector-pseudo, .hljs-selector-tag { color: #d7ba7d; } .hljs-addition { background-color: #144212; display: inline-block; width: 100%; } .hljs-deletion { background-color: #600; display: inline-block; width: 100%; }`); } else { addStyleOnce('highlight', ` .hljs{display:block;overflow-x:auto;padding:.5em;background:#fff;color:#000}.hljs-comment,.hljs-quote,.hljs-variable{color:green}.hljs-built_in,.hljs-keyword,.hljs-name,.hljs-selector-tag,.hljs-tag{color:#00f}.hljs-addition,.hljs-attribute,.hljs-literal,.hljs-section,.hljs-string,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type{color:#a31515}.hljs-deletion,.hljs-meta,.hljs-selector-attr,.hljs-selector-pseudo{color:#2b91af}.hljs-doctag{color:grey}.hljs-attr{color:red}.hljs-bullet,.hljs-link,.hljs-symbol{color:#00b0e8}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} `); } eus.onUrl(/\/pullrequest\//gi, (session, urlMatch) => { let languageDefinitions = null; session.onEveryNew(document, '.text-diff-container', diff => { if (eus.seen(diff)) return; if (!languageDefinitions) { languageDefinitions = parseLanguageDefinitions(); } // TODO: Handle new PR experience. session.onFirst(diff.closest('.file-container'), '.file-cell .file-name-link', fileNameLink => { const fileName = fileNameLink.innerText.toLowerCase(); const extension = getFileExt(fileName); const leftPane = diff.querySelector('.leftPane > div > .side-by-side-diff-container'); const rightOrUnifiedPane = diff.querySelector('.rightPane > div > .side-by-side-diff-container') || diff; // Guess our language based on our file extension. The GitHub language definition keywords and the highlight.js language keywords are different, and may not match. This loop is a heuristic to find a language match. // Supports languages listed here, without plugins: https://github.com/highlightjs/highlight.js/blob/master/SUPPORTED_LANGUAGES.md let language = null; for (const mode of [extension].concat(languageDefinitions.extensionToMode[extension]).concat(languageDefinitions.fileToMode[fileName])) { if (hljs.getLanguage(mode)) { language = mode; break; } } // If we still don't have a language, try to guess it based on the code. if (!language) { let code = ''; for (const line of rightOrUnifiedPane.querySelectorAll('.code-line:not(.deleted-content)')) { code += `${line.innerText}\n`; } // eslint-disable-next-line prefer-destructuring language = hljs.highlightAuto(code).language; } // If we have a language, highlight it :) if (language) { highlightDiff(language, fileName, 'left', leftPane, '.code-line'); highlightDiff(language, fileName, 'right/unified', rightOrUnifiedPane, '.code-line:not(.deleted-content)'); } }); }); }); } // Gets GitHub language definitions to parse extensions and filenames to a "mode" that we can try with highlight.js. function parseLanguageDefinitions() { const languages = jsyaml.load(GM_getResourceText('linguistLanguagesYml')); const extensionToMode = {}; const fileToMode = {}; for (const language of Object.values(languages)) { const mode = [getFileExt(language.tm_scope), language.ace_mode]; if (language.extensions) { for (const extension of language.extensions) { extensionToMode[extension.substring(1)] = mode; } } if (language.filenames) { for (const filename of language.filenames) { fileToMode[filename.toLowerCase()] = mode; } } } // For debugging: debug(`Supporting ${Object.keys(extensionToMode).length} extensions and ${Object.keys(fileToMode).length} special filenames`); return { extensionToMode, fileToMode }; } function highlightDiff(language, fileName, part, diffContainer, selector) { if (!diffContainer) return; // For debugging: debug(`Highlighting ${part} of <${fileName}> as ${language}`); let stack = null; for (const line of diffContainer.querySelectorAll(selector)) { const result = hljs.highlight(language, line.innerText, true, stack); stack = result.top; // We must add the extra span at the end or sometimes, when adding a comment to a line, the highlighting will go away. line.innerHTML = `${result.value}`; // We must wrap all text in spans for the comment highlighting to work. for (let i = line.childNodes.length - 1; i > -1; i -= 1) { const fragment = line.childNodes[i]; if (fragment.nodeType === Node.TEXT_NODE) { const span = document.createElement('span'); span.innerText = fragment.textContent; fragment.parentNode.replaceChild(span, fragment); } } } } // Fix PR image URLs to match the window URL (whether dev.azure.com/account/ or account.visualstudio.com/) function fixImageUrls(session) { let account; let badPrefix; let goodPrefix; if (window.location.host === 'dev.azure.com') { account = window.location.pathname.match(/^\/(\w+)/)[1]; badPrefix = new RegExp(`^${window.location.protocol}//${account}.visualstudio.com/`); goodPrefix = `${window.location.protocol}//dev.azure.com/${account}/`; } else { const match = window.location.host.match(/^(\w+)\.visualstudio.com/); if (!match) return; account = match[1]; badPrefix = new RegExp(`^${window.location.protocol}//dev.azure.com/${account}/`); goodPrefix = `${window.location.protocol}//${account}.visualstudio.com/`; } session.onEveryNew(document, 'img', img => { const src = img.getAttribute('src'); if (src && src.match(badPrefix)) { // For debugging: debug("Fixing img src", src); img.setAttribute('src', src.replace(badPrefix, goodPrefix)); } }); } // Helper function to get the file extension out of a file path; e.g. `cs` from `blah.cs`. function getFileExt(path) { return /(?:\.([^.]+))?$/.exec(path)[1]; } // Helper function to avoid adding CSS twice into a document. function addStyleOnce(id, style) { $(document.head).once(id).each(function () { $('