| // Copyright 2016 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| Gerrit.install(function(self) { |
| |
| |
| /** |
| * Milliseconds between tree status checks. |
| */ |
| var STATUS_INTERVAL_MS = 60 * 1000; |
| |
| |
| /** |
| * Date used in status if real one can't be fetched. |
| */ |
| var UNKNOWN_DATE = new Date(0); |
| |
| |
| /** |
| * Gerrit JSON response prefix. |
| */ |
| var JSON_PREFIX = ')]}\''; |
| |
| |
| /** |
| * Placeholder status when actual one is unknown yet. |
| */ |
| var UNKNOWN_TREE_STATUS = { |
| isOpen: false, |
| generalState: 'unknown', |
| message: 'Tree status unknown.', |
| username: 'Pending', |
| date: UNKNOWN_DATE, |
| url: null |
| }; |
| |
| |
| /** |
| * Placeholder status when we're waiting for an answer. |
| */ |
| var PENDING_TREE_STATUS = { |
| isOpen: false, |
| generalState: 'unknown', |
| message: 'Waiting for tree status...', |
| username: 'Pending', |
| date: UNKNOWN_DATE, |
| url: null |
| }; |
| |
| |
| /** |
| * Placeholder status when the current branch is not the trunk. |
| */ |
| var DISABLED_BRANCH_TREE_STATUS = { |
| isOpen: true, |
| generalState: 'unknown', |
| message: 'No tree status for this branch.', |
| username: 'Warning', |
| date: UNKNOWN_DATE, |
| url: null |
| }; |
| |
| |
| // Register CSS classes only once, during plugin loading. |
| var STYLES = { |
| // Base style for div that contains tree status box. |
| outerDiv: Gerrit.css( |
| 'align-items: center;' + |
| 'display: flex;' + |
| 'font-size: 1.1em;' + |
| 'justify-content: center;' + |
| 'margin: 5px 0 8px;' + |
| 'padding: .4em;'), |
| |
| // Additional styles for PolyGerrit UI. |
| outerDivPolyGerrit: Gerrit.css('max-width: 20em;'), |
| |
| // Tree status is not yet known. |
| unknownStatus: Gerrit.css( |
| 'color: black;' + |
| 'background-color: #FFFC6C;'), |
| |
| // Tree is open. |
| openStatus: Gerrit.css( |
| 'color: black;' + |
| 'background-color: #BCE889;'), |
| |
| // Tree is closed. |
| closedStatus: Gerrit.css( |
| 'color: white;' + |
| 'background-color: #E98080;'), |
| |
| confirmSubmitMessage: Gerrit.css( |
| 'color: red;' + |
| 'width: 400px;'), |
| |
| hiddenConfirmSubmitMessage: Gerrit.css( |
| 'display: none;') |
| }; |
| |
| |
| /** |
| * Tree status UI for some single change. |
| * @param {Object} change Change object provided by Gerrit |
| * @param {Object} config Project config this change belongs to |
| */ |
| var TreeStatus = function(change, config) { |
| this.change = change; |
| this.config = config; |
| this.treeStatus = null; |
| |
| this.fetcher = null; |
| this.fetcherCallback = null; |
| this.installed = false; |
| |
| // Build DOM for tree status UI. |
| this.outerDiv = document.createElement('div'); |
| this.outerDiv.classList.add(STYLES.outerDiv); |
| if (window.Polymer) { |
| this.outerDiv.classList.add(STYLES.outerDivPolyGerrit); |
| } |
| this.innerDiv = document.createElement('div'); |
| this.outerDiv.appendChild(this.innerDiv); |
| |
| // Set default state to 'Unknown'. |
| this.setTreeStatus(UNKNOWN_TREE_STATUS); |
| }; |
| |
| |
| /** |
| * Add DOM elements to the document, subscribe to status fetcher notifications. |
| * @param {Object} fetcher StatusFetcher object that fetches tree status |
| */ |
| TreeStatus.prototype.install = function(fetcher) { |
| if (!this.installed) { |
| console.log('Installing tree status for change:', this.change.id); |
| document.getElementById('change_plugins').appendChild(this.outerDiv); |
| this.fetcher = fetcher; |
| if (this.fetcher) { |
| this.fetcherCallback = this.setTreeStatus.bind(this); |
| this.fetcher.subscribe(this.fetcherCallback); |
| } |
| this.installed = true; |
| } |
| }; |
| |
| |
| /** |
| * Remove DOM elements from the document, unsubscribe from status fetcher. |
| */ |
| TreeStatus.prototype.uninstall = function() { |
| if (this.installed) { |
| console.log('Uninstalling tree status for change:', this.change.id); |
| if (this.fetcher) { |
| this.fetcher.unsubscribe(this.fetcherCallback); |
| this.fetcher = null; |
| this.fetcherCallback = null; |
| } |
| if (this.outerDiv.parentNode) |
| this.outerDiv.parentNode.removeChild(this.outerDiv); |
| this.installed = false; |
| } |
| }; |
| |
| |
| /** |
| * Update DOM to display given tree status. |
| * @param {Object} treeStatus Tree status object produced by StatusFetcher |
| */ |
| TreeStatus.prototype.setTreeStatus = function(treeStatus) { |
| this.treeStatus = treeStatus; |
| |
| // Update content. |
| if (!treeStatus.url) { |
| this.innerDiv.textContent = treeStatus.username + ': ' + treeStatus.message; |
| } else { |
| // Create <a></a>. |
| var anchor = document.createElement('a'); |
| anchor.href = treeStatus.url; |
| anchor.target = '_blank'; |
| anchor.innerText = treeStatus.message; |
| // Replace whatever in innerDiv with that link. |
| this.innerDiv.textContent = ''; |
| this.innerDiv.appendChild(anchor); |
| } |
| |
| // Update CSS style. |
| var styleName = (treeStatus.generalState || 'unknown') + 'Status'; |
| var cssClass = STYLES[styleName] || STYLES.unknownStatus; |
| this.outerDiv.className = STYLES.outerDiv + ' ' + cssClass; |
| if (window.Polymer) { |
| this.outerDiv.classList.add(STYLES.outerDivPolyGerrit); |
| } |
| }; |
| |
| |
| /** |
| * Periodically fetches tree status for some project. |
| * @param {string} viewURL URL to link to normally for users to navigate to |
| * @param {string} statusURL URL to fetch status from |
| * @param {boolean} withCredentials True to send cookies with the request |
| */ |
| var StatusFetcher = function(viewURL, statusURL, withCredentials) { |
| this.viewURL = viewURL; |
| this.statusURL = statusURL; |
| this.withCredentials = withCredentials; |
| this.lastKnownStatus = PENDING_TREE_STATUS; |
| |
| this.callbacks = []; |
| this.request = null; |
| this.timer = null; |
| }; |
| |
| |
| /** |
| * Register callback to be called when tree status is fetched. |
| * @param {Function} callback Will be called with single treeStatus argument |
| */ |
| StatusFetcher.prototype.subscribe = function(callback) { |
| this.callbacks.push(callback); |
| }; |
| |
| |
| /** |
| * Removes previously registered callback. |
| * @param {Function} callback Exact same function object that was registered |
| */ |
| StatusFetcher.prototype.unsubscribe = function(callback) { |
| var index = this.callbacks.indexOf(callback); |
| if (index != -1) { |
| this.callbacks.splice(index, 1); |
| } |
| }; |
| |
| |
| /** |
| * Asynchronously fetches (or refetches) tree status and calls callbacks. |
| */ |
| StatusFetcher.prototype.fetch = function() { |
| // Already fetching? |
| if (this.request) |
| return; |
| |
| // Fetch restarts refetch timer (by launching it again when request finishes). |
| if (this.timer) { |
| clearTimeout(this.timer); |
| this.timer = null; |
| } |
| |
| // Launch async GET. |
| this.lastKnownStatus = PENDING_TREE_STATUS; |
| this.request = new XMLHttpRequest(); |
| |
| var that = this; |
| this.request.onreadystatechange = function() { |
| if (this.readyState == 4) { |
| console.log('Fetched', that.statusURL); |
| that.request = null; |
| that.onFetchCompleted.call(that, this); |
| } |
| }; |
| |
| this.request.open('GET', this.statusURL); |
| this.request.withCredentials = this.withCredentials; |
| this.request.send(); |
| }; |
| |
| |
| /** |
| * Called when HTTP request finishes (successfully or not). |
| * @param {Object} request Completed XMLHttpRequest object. |
| */ |
| StatusFetcher.prototype.onFetchCompleted = function(request) { |
| var treeStatus; |
| if (request.status == 200) { |
| try { |
| var result = JSON.parse(request.responseText); |
| treeStatus = { |
| isOpen: result.can_commit_freely, |
| generalState: result.general_state, |
| message: result.message, |
| username: result.username, |
| date: new Date(result.date.replace(' ', 'T')), |
| url: this.viewURL |
| }; |
| } catch (ex) { |
| console.log('Error parsing json: ' + ex); |
| treeStatus = { |
| isOpen: false, |
| generalState: 'unknown', |
| message: String(ex), |
| username: 'ParseError', |
| date: UNKNOWN_DATE, |
| url: this.viewURL |
| }; |
| } |
| } else { |
| var message; |
| var url; |
| if (this.withCredentials) { |
| message = 'Login required'; |
| } else { |
| url = this.viewURL; |
| message = (request.statusText || |
| 'Error ' + request.status + ' requesting tree status'); |
| } |
| treeStatus = { |
| isOpen: false, |
| generalState: 'unknown', |
| message: message, |
| username: 'RequestError', |
| date: UNKNOWN_DATE, |
| url: url |
| }; |
| } |
| |
| // Remember this status until next refetch cycle. |
| this.lastKnownStatus = treeStatus; |
| |
| // Iterate over a copy. Callbacks may modify the list during iteration. |
| var copy = this.callbacks.slice(0); |
| for (var i = 0; i < copy.length; i++) { |
| copy[i](treeStatus); |
| } |
| |
| // Auto refetch status later (if still have subscribers). |
| var that = this; |
| this.timer = setTimeout(function() { |
| that.timer = null; |
| // Known status is too old. Better to forget it. |
| that.lastKnownStatus = PENDING_TREE_STATUS; |
| if (that.callbacks.length) { |
| that.fetch(); |
| } |
| }, STATUS_INTERVAL_MS); |
| }; |
| |
| |
| |
| // Cache of StatusFetchers. Config's treeName -> StatusFetcher. |
| var fetchers = {}; |
| // TreeStatus object that correspond to current change. |
| var installedTreeStatus = null; |
| |
| // Fetches JSON from URL, returns Promise. |
| function fetchJSON(url) { |
| return new Promise(function(resolve, reject) { |
| var xhr = new XMLHttpRequest(); |
| xhr.open('GET', url); |
| xhr.onload = function() { |
| var result = null; |
| if (this.status === 204) { |
| resolve(null); |
| } else if (this.status >= 200 && this.status < 300) { |
| try { |
| result = JSON.parse(xhr.response.substring(JSON_PREFIX.length)); |
| resolve(result); |
| } catch (_) { |
| reject("Unable to parse JSON"); |
| } |
| } else { |
| reject("Got non-200 response"); |
| } |
| }; |
| xhr.onerror = function() { |
| reject("Unhandled error"); |
| }; |
| xhr.send(); |
| }); |
| } |
| |
| /** |
| * Returns configuration for a tree status (status URL to use, login URL, etc.) |
| * @param {string} project Gerrit project name to search config for |
| * @return {Promise} Promise, resolving to project config. |
| */ |
| function getProjectConfig(project) { |
| /* |
| Expected format of a project config: |
| { |
| treeName: English label to describe the project, eg: 'Chrome'. |
| viewURL: URL to the user facing status, eg: 'http://foo/' |
| statusURL: URL to the tree status, eg: 'http://foo/current?format=json' |
| withCredentials: True if status requests require HTTP cookies. |
| disabledBranchPattern: Regex object or string pattern to exclude a branch, |
| enforceCommitQueue: True if project is using Commit Queue |
| } |
| */ |
| // If we've fetched it already, use the config set on the window. |
| var config = window.chumpDetectorConfig; |
| if (config !== undefined) { |
| return Promise.resolve(config); |
| } |
| |
| // Otherwise, fetch it and store it for reuse. |
| var chumpdetectorURL = '/projects/' + encodeURIComponent(project) + |
| '/chumpdetector~config'; |
| return fetchJSON(chumpdetectorURL).then(function(cfg) { |
| window.chumpDetectorConfig = cfg || null; |
| if (!window.chumpDetectorConfig) { |
| console.log('Chump detector is not configured.'); |
| return null; |
| } |
| return getProjectConfig(project); |
| }); |
| } |
| |
| |
| /** |
| * Creates a new one or returns existing StatusFetcher object. |
| * @param {Object} config Project configuration as returned by getProjectConfig |
| * @return {Object} StatusFetcher object |
| */ |
| function getStatusFetcher(config) { |
| if (!fetchers.hasOwnProperty(config.treeName)) { |
| fetchers[config.treeName] = new StatusFetcher(config.viewURL, |
| config.statusURL, |
| config.withCredentials); |
| } |
| return fetchers[config.treeName]; |
| } |
| |
| |
| self.on('showchange', |
| /** |
| * Called by Gerrit when change screen is shown. |
| * @param {Object} change Object with change details |
| * @param {Object} revision Object with revision details |
| */ |
| function(change, revision) { |
| // Uninstall previous one (if any). |
| if (installedTreeStatus) { |
| installedTreeStatus.uninstall(); |
| installedTreeStatus = null; |
| } |
| |
| getProjectConfig(change.project).then(function(config) { |
| if (!config || !config.statusURL) { |
| console.log('No status url for this project.'); |
| return; |
| } else if (config.disabled) { |
| console.log('Chumpdetector is disabled for this project.'); |
| return; |
| } |
| // Install new one if the project is supported. |
| var fetcher; |
| if (config.disabledBranchPattern && |
| new RegExp(config.disabledBranchPattern).test(change.branch)) { |
| console.log('Disabling chump detector based on branch name.'); |
| fetcher = null; |
| } else { |
| console.log('Using status url: ' + config.statusURL); |
| fetcher = getStatusFetcher(config); |
| } |
| |
| function setupTreeStatus() { |
| // Bind tree status to status fetcher, perform initial fetch. |
| installedTreeStatus = new TreeStatus(change, config); |
| installedTreeStatus.install(fetcher); |
| if (fetcher) { |
| installedTreeStatus.setTreeStatus(fetcher.lastKnownStatus); |
| fetcher.fetch(); |
| } else { |
| installedTreeStatus.setTreeStatus(DISABLED_BRANCH_TREE_STATUS); |
| } |
| } |
| |
| // If the configuration specifies loading an image URL first then |
| // request the image. Whether it succeeds or fails immediately |
| // move into setting up the tree status request. This allows users |
| // the opportunity to use the image load to establish cookies the |
| // browser may need in order to make the status request which can |
| // work even without the image request succeeding. |
| if (config.preloadImageURL) { |
| var img = new Image(); |
| img.addEventListener("load", setupTreeStatus); |
| img.addEventListener("error", setupTreeStatus); |
| img.src = config.preloadImageURL; |
| } else { |
| setupTreeStatus(); |
| } |
| }, function() { |
| console.log('Error fetching config.'); |
| }); |
| }); |
| |
| |
| self.on('history', |
| /** |
| * Called by Gerrit whenever document.location changes. |
| * @param {string} token Fragment part of a page URL |
| */ |
| function(token) { |
| // Navigated away from a change page? Uninstall tree status UI. |
| if (token.substring(0, 2) != '/c' && installedTreeStatus) { |
| installedTreeStatus.uninstall(); |
| installedTreeStatus = null; |
| } |
| }); |
| |
| // Add a message div to appear inside the confirm submit dialog. |
| var confirmSubmitMessage = document.createElement('p'); |
| self.hook('confirm-submit-change').onAttached(function(element) { |
| element.appendChild(confirmSubmitMessage); |
| }); |
| |
| /** |
| * Discard any the message in the confirm submit dialog. (No message to show.) |
| */ |
| function disableConfirmSubmitMessage() { |
| confirmSubmitMessage.style = STYLES.hiddenConfirmSubmitMessage; |
| confirmSubmitMessage.textContent = ''; |
| } |
| |
| /** |
| * Set the message that will be shown in the confirm submit tialog. |
| * @param {string} message The message to appear. |
| */ |
| function setConfirmSubmitMessage(message) { |
| confirmSubmitMessage.style = STYLES.confirmSubmitMessage; |
| confirmSubmitMessage.textContent = message; |
| } |
| |
| self.on('submitchange', |
| /** |
| * Called by Gerrit whenever 'Submit' is clicked. |
| * @return {boolean} False to block submit |
| */ |
| function() { |
| // Change is in unsupported project, do not block it. |
| if (!installedTreeStatus) { |
| disableConfirmSubmitMessage(); |
| return true; |
| } |
| |
| var treeStatus = installedTreeStatus.treeStatus; |
| var config = installedTreeStatus.config; |
| |
| // Change is in unsupported branch, do not block it. |
| if (treeStatus == DISABLED_BRANCH_TREE_STATUS) { |
| disableConfirmSubmitMessage(); |
| return true; |
| } |
| |
| // Change should be submitted via CQ (not directly via Gerrit). |
| if (config.enforceCommitQueue) { |
| setConfirmSubmitMessage( |
| 'The ' + config.treeName + ' project uses the commit queue (CQ). ' + |
| 'You should submit your change by clicking "Reply" and setting the ' + |
| '"Commit-Queue" label to the appropriate value.\n' + |
| 'Do you want to commit the change directly, bypassing CQ (dangerous)?'); |
| return true; |
| } |
| |
| // Tree is open, do not block the change. |
| if (treeStatus.isOpen) { |
| disableConfirmSubmitMessage(); |
| return true; |
| } |
| |
| // Unknown tree state. Double check with the user before submitting. |
| if (treeStatus.generalState == 'unknown') { |
| setConfirmSubmitMessage( |
| config.treeName + ' tree status is unknown, submitting this ' + |
| 'change now might be dangerous. Submit anyway?'); |
| return true; |
| } |
| |
| // Tree is closed (or in some entirely unexpected state). Warn the user. |
| setConfirmSubmitMessage( |
| 'The ' + config.treeName + ' tree is closed, submitting this ' + |
| 'change is dangerous. Submit anyway?'); |
| return true; |
| }); |
| |
| }); |