// ==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 = `Showing first ${maxFilesToShow}:
`; } const fileListing = filesToShow .map(f => `Outlook Auto Response
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 () { $('').html(style).appendTo(this); }); } // Helper function to get the id of the PR that's on screen. function getCurrentPullRequestId() { return window.location.pathname.substring(window.location.pathname.lastIndexOf('/') + 1); } // Don't access this directly -- use getCurrentPullRequestAsync() instead. let currentPullRequest = null; async function getCurrentPullRequestAsync() { if (!currentPullRequest || currentPullRequest.pullRequestId !== getCurrentPullRequestId()) { currentPullRequest = await getPullRequestAsync(); } return currentPullRequest; } // Helper function to get the url of the PR that's currently on screen. async function getCurrentPullRequestUrlAsync() { return (await getCurrentPullRequestAsync()).url; } // Helper function to get the creator of the PR that's currently on screen. async function getCurrentPullRequestCreatedBy() { return (await getCurrentPullRequestAsync()).createdBy; } // Async helper function get info on a single PR. Defaults to the PR that's currently on screen. function getPullRequestAsync(id = 0) { const actualId = id || getCurrentPullRequestId(); return $.get(`${azdoApiBaseUrl}/_apis/git/pullrequests/${actualId}?api-version=5.0`); } // Async helper function to get a specific PR property, otherwise return the default value. async function getPullRequestProperty(prUrl, key, defaultValue = null) { const properties = await $.get(`${prUrl}/properties?api-version=5.1-preview.1`); const property = properties.value[key]; return property ? JSON.parse(property.$value) : defaultValue; } async function pullRequestHasRequiredOwnersPolicyAsync() { const pr = await getCurrentPullRequestAsync(); const url = `${azdoApiBaseUrl}${pr.repository.project.name}/_apis/git/policy/configurations?repositoryId=${pr.repository.id}&refName=${pr.targetRefName}`; return (await $.get(url)).value.some(x => x.isBlocking && x.settings.statusName === 'owners-approved'); } // Helper function to access an object member, where the exact, full name of the member is not known. function getPropertyThatStartsWith(instance, startOfName) { return instance[Object.getOwnPropertyNames(instance).find(x => x.startsWith(startOfName))]; } // Helper function to encode any string into an string that can be placed directly into HTML. function escapeStringForHtml(string) { return string.replace(/[\u00A0-\u9999<>&]/gim, ch => `${ch.charCodeAt(0)};`); } // Async helper function to return reviewer info specific to National Instruments workflows (where this script is used the most). async function getNationalInstrumentsPullRequestOwnersInfo(prUrl) { const reviewProperties = await getPullRequestProperty(prUrl, 'NI.ReviewProperties'); // Not all repos have NI owner info. if (!reviewProperties) { return null; } // Only support the more recent PR owner info version, where full user info is stored in an identities table separate from files. if (reviewProperties.version < 4) { return null; } // Some PRs don't have complete owner info if it would be too large to fit in PR property storage. if (!reviewProperties.fileProperties) { return null; } const ownersInfo = { currentUserFilesToRole: {}, currentUserFileCount: 0, isCurrentUserResponsibleForFile(path) { return Object.prototype.hasOwnProperty.call(this.currentUserFilesToRole, path); }, isCurrentUserResponsibleForFileInFolderPath(folderPath) { return Object.keys(this.currentUserFilesToRole).some(path => path.startsWith(folderPath)); }, reviewProperties, }; // See if the current user is listed in this PR. const currentUserListedInThisOwnerReview = _(reviewProperties.reviewerIdentities).some(r => r.email === currentUser.uniqueName); // Go through all the files listed in the PR. if (currentUserListedInThisOwnerReview) { for (const file of reviewProperties.fileProperties) { // Get the identities associated with each of the known roles. // 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. const owner = reviewProperties.reviewerIdentities[file.owner - 1] || {}; const alternate = reviewProperties.reviewerIdentities[file.alternate - 1] || {}; // handle nulls everywhere // As of 2020-11-16, Reviewer is now a synonym for Expert. We'll look at both arrays and annotate them the same way. const reviewers = file.reviewers ? (file.reviewers.map(r => reviewProperties.reviewerIdentities[r - 1] || {}) || []) : []; const experts = file.experts ? (file.experts.map(r => reviewProperties.reviewerIdentities[r - 1] || {}) || []) : []; // Pick the highest role for the current user on this file, and track it. if (owner.email === currentUser.uniqueName) { ownersInfo.currentUserFilesToRole[file.path] = 'O'; ownersInfo.currentUserFileCount += 1; } else if (alternate.email === currentUser.uniqueName) { ownersInfo.currentUserFilesToRole[file.path] = 'A'; ownersInfo.currentUserFileCount += 1; // eslint-disable-next-line no-loop-func } else if (_(experts).some(r => r.email === currentUser.uniqueName) || _(reviewers).some(r => r.email === currentUser.uniqueName)) { ownersInfo.currentUserFilesToRole[file.path] = 'E'; ownersInfo.currentUserFileCount += 1; } } } return ownersInfo; } main(); }());