NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Gitlab review extension-beta // @version 0.5.9 // @description File approving for gitlab! // @author V. Vasin // @include /^https?:\/\/[^/]*gitlab.[^/]+\// // @run-at document-body // @icon https://about.gitlab.com/ico/favicon-192x192.png // @updateURL https://openuserjs.org/meta/ds3v/Gitlab_review_extension-beta.meta.js // @require https://www.gstatic.com/firebasejs/3.6.2/firebase.js // @require https://raw.githubusercontent.com/emn178/js-sha256/730f41e84b76d3525b5a9b505cea549ab0c2a287/build/sha256.min.js // ==/UserScript== (function() { 'use strict'; const STYLES =` .files .diff-file.approved .approve-btn { background : #afa; color: black; } .files.hide-approved .diff-file.approved, .files.hide-filtered .diff-file.filtered-file, .files.approve-script-applied.hide-approved .diff-file.approved, .files.approve-script-applied.hide-filtered .diff-file.filtered-file, .files.approve-script-applied .diff-file.filtered-file .diff-content, .files .diff-file.filtered-file .diff-content, .files .diff-file.filtered-file .approve-btn, .files .diff-file.approved .diff-content { display: none; } .files .diff-file .diff-content .approve-btn { position: absolute; right : 5px; bottom: 5px; opacity: 0.2; background: #afa; color: #000; border: 1px solid #555; box-shadow: 0 0 3px white; } .files .diff-file .diff-content .approve-btn:hover { opacity: 0.9 } .files .diff-file { position:relative; } .files.approve-script-applied .diff-file { display: block; } #utilHolder #approvedCnt { color: green; } #utilHolder #filteredCnt { color: blue; } #utilHolder #filteredCnt:after, #utilHolder #filteredCnt:before { content : '/'; color: gray; display: inline-block; width: 30px; text-align: center; } #utilHolder { position: fixed; top: 3px; left: calc(50% - 210px); z-index: 9999; background: #fff; border: 1px solid #eee; width: 420px; border-radius: 3px; } #utilHolder #progressCnt:before { content : 'Review progress: ' } #utilHolder #progressCnt { margin : 0 15px 0 0; color : red; font-weight: bold; } #utilHolder #progressCnt.half { color: #aa4500; } #utilHolder #progressCnt.done { color : green; } #utilHolder #setting-editor-button { float: left; margin: 5px 10px; font-size: 20px; cursor: pointer; } .hideFiltered, .hideApproved { margin-right:15px; color: black; cursor: pointer; } .hideApproved:before, .hideFiltered:before { margin-right: 5px; } .hideApproved.fa-eye-slash, .hideFiltered.fa-eye-slash { color: gray; } .settings-editor { position: fixed; left: calc(50% - 210px); top : 75px; z-index: 9999; background: #fff; border: 1px solid #eee; width: 420px; border-radius: 3px; padding : 15px; box-shadow: 0 0 5px black; } .settings-editor label { margin : 5px 0; display: block; } .settings-editor .repo-type { display: block; } .settings-editor .repo-type::after { margin : 5px; content: "Use public Firebase" } .settings-editor .buttons { text-align: center; } .settings-editor .buttons .btn { width: 100px; margin : 5px 10px; } .settings-editor .inner-settings { padding-left:20px; } `; function scrollTo(element) { if (localStorage.noscroll === "true") { return; } setTimeout(() => { const offset = element.offset(); if (offset) { $('html, body').animate({scrollTop: offset.top - 155}, 300); } }, 100); } class Statistic { constructor(parent) { this.progress = $("<span id='progressCnt' title='Progress'>0%</span>").appendTo(parent); this.approved = $("<span id='approvedCnt' title='Approved'>0</span>").appendTo(parent); this.filtered = $("<span id='filteredCnt' title='Filtered'>0</span>").appendTo(parent); this.total = $("<span id='totalCnt' title='Total'>0</span>").appendTo(parent); } update() { const files = $('.files .diff-file'), total = files.length || 1, approved = files.filter('.approved').length, filtered = files.filter('.filtered-file:not(.approved)').length, processed = filtered + approved; this.total.text(total); this.approved.text(approved); this.filtered.text(filtered); this.progress .text((processed * 100 / total).toFixed(1) + "%") .toggleClass("half", processed / total >= 0.5) .toggleClass("done", processed === total); } } class Filters { constructor(settings) { this.settings = settings; this.registeredFilters = [ this.filterByPattern.bind(this), this.filterMoved.bind(this), this.filterByHighlightedDiff.bind(this), this.filterEmptyChanges.bind(this), ]; } filter(changedFile) { const failedFilter = this.registeredFilters.find(f => !f.apply(changedFile)); if (failedFilter) { console.log(failedFilter.name); } return failedFilter === undefined; //return this.filterByPattern(changedFile) && this.filterMoved(changedFile) && this.filterDiff(changedFile); } filterByPattern(changedFile) { return !this.settings.filters.some(p => changedFile.fileName.indexOf(p) > -1); } filterMoved(changedFile) { return changedFile.element.find(".nothing-here-block:contains('File moved')").length === 0; } filterByHighlightedDiff(changedFile) { return !this.settings.filterEmptyChanges || changedFile.changedLines .map(l => l.highlightedDiff) .some(diff => diff.length === 0 || diff.replace(/[\s;]+/g, "").length > 0); } // filterByContent(changedLine) { // const value = changedLine.content.trim(); // return this.filterEmptyChanges(value) && this.filterImportChanges(value) && this.filterPackageChanges(value); // } filterEmptyChanges(value) { return !this.settings.filterEmptyChanges || changedFile.changedLines.some(l => l.content.length !== 0); //return !this.settings.filterEmptyChanges || value.length !== 0; } filterImportChanges(value) { return !this.settings.filterImportChanges || value.indexOf("import ") !== 0; } filterPackageChanges(value) { return !this.settings.filterPackageChanges || value.indexOf("package ") !== 0; } } class SettingsEditorButton { constructor(parent, settings) { const editor = new SettingsEditor(settings); this.button = $("<span id='setting-editor-button' class='btn fa fa-cogs'/>") .appendTo(parent) .click(() => editor.show()); } } class ReviewPanel { constructor(settings) { this.panel = $("<div id='utilHolder'/>"); this.settingsButton = new SettingsEditorButton(this.panel, settings); this.statistic = new Statistic(this.panel); this.controls = new ReviewPanelControls(this.panel); } update() { this.statistic.update(); this.controls.update(); } inject() { this.panel.appendTo('body'); this.update(); } } class ReviewPanelControls { constructor(parent) { const holder = $("<div/>").appendTo(parent); this.hideFilteredBtn = $("<label class='hideFiltered fa fa-eye'> Filtered</label>") .appendTo(holder) .click(() => this.toggleFiltered()); this.hideApprovedBtn = $("<label class='hideApproved fa fa-eye'> Approved</label>") .appendTo(holder) .click(() => this.toggleApproved()); } toggleFiltered() { localStorage.hideFiltered = localStorage.hideFiltered !== "true"; this.update(); } toggleApproved() { localStorage.hideApproved = localStorage.hideApproved !== "true"; this.update(); } update() { const hideFiltered = localStorage.hideFiltered === undefined ? true : localStorage.hideFiltered === "true"; const hideApproved = localStorage.hideApproved === "true"; localStorage.hideFiltered = hideFiltered; localStorage.hideApproved = hideApproved; this.hideFilteredBtn.toggleClass("fa-eye-slash", hideFiltered); this.hideApprovedBtn.toggleClass("fa-eye-slash", hideApproved); $('.files') .toggleClass("hide-filtered", hideFiltered) .toggleClass("hide-approved", hideApproved); } } class MergeRequestInjector { constructor(userInfo, filters, reviewPanel, approveStateStorage, unhideStateStorage) { this.reviewPanel = reviewPanel; this.filters = filters; this.approveStateStorage = approveStateStorage; this.unhideStateStorage = unhideStateStorage; this.userInfo = userInfo; } inject() { this.injectStyles(); this.injectActionsToFileList(); this.reviewPanel.inject(); } injectActionsToFileList() { $('.files .diff-file').each((i, element) => { this.injectActionsToFile(new ChangedFile($(element))); }); $('.files').addClass("approve-script-applied"); } injectActionsToFile(file) { const approveButton = new ApproveButton(this.approveStateStorage, this.userInfo, file); approveButton.onClick(() => this.reviewPanel.update()); file.addAction(approveButton.element); file.addActionToDiffContent(new ContentApproveButton(approveButton).element); if (!this.filters.filter(file)) { const unhideButton = new UnhideFilteredButton(this.unhideStateStorage, file); unhideButton.onClick(() => this.reviewPanel.update()); file.addAction(unhideButton.element); } } injectStyles() { $('<style>') .html(STYLES) .appendTo("head"); } } class ContentApproveButton { constructor(approveButton) { this.element = $("<span class='btn fa approve-btn fa-check'/>") .click(() => approveButton.toggleApprove()); } } class ApproveButton { constructor(stateStorage, userInfo, file) { this.element = $("<span>"); this.avatar = $("<img class='avatar avatar-inline s24'>").appendTo(this.element).hide(); this.button = $("<span class='btn fa approve-btn fa-check'/>") .appendTo(this.element) .click(this.toggleApprove.bind(this)); this.file = file; this.approved = false; this.userInfo = userInfo; this.approveUserInfo = userInfo.plain(); this.stateStorage = stateStorage; this.storeId = file.fileId; this.listener = () => {}; this.init(); } init() { this.stateStorage.watch(this.storeId, state => { this.approved = !!state && state.checksum === this.file.checksum; this.approveUserInfo = state && state.user || this.userInfo; this.update(); }); } toggleApprove() { if (!this.userInfo.isAuthorized()) { return; } this.approved = !this.approved; if (this.approved) { this.approveUserInfo = this.userInfo.plain(); this.stateStorage.set(this.storeId, { checksum : this.file.checksum, user : this.approveUserInfo }); this.avatar.attr("src", this.approveUserInfo.avatar); this.file.scrollToNext(); } else { this.stateStorage.set(this.storeId, null); } this.update(); } onClick(listener) { this.listener = listener; } update() { this.file.toggleClass("approved", this.approved); this.avatar .attr("title", "Approved by " + this.approveUserInfo.name) .attr("src", this.approveUserInfo.avatar) .toggle(this.approved); this.button.toggleClass("active", this.approved); this.listener(); } } class UserInfo { constructor() { const link = $(".profile-link"); const avatarUrl = $(".header-user-avatar").eq(0).attr("src"); this.name = link.data("user"); this.url = link.attr("href"); this.avatar = avatarUrl && (avatarUrl.indexOf(location.host) == -1 ? avatarUrl : avatarUrl.replace(/[^:]+:\/\/[^/]+/, "")) || ""; } isAuthorized() { return !!this.name; } plain() { return { name : this.name, url : this.url, avatar : this.avatar, }; } } class UnhideFilteredButton { constructor(stateStorage, file) { this.element = $("<span class='btn fa unhide-btn fa-eye'/>") .click(this.unhide.bind(this)); this.file = file; this.storeId = 'unhidden/' + this.file.fileName; this.stateStorage = stateStorage; this.listener = () => {}; this.init(); } init() { this.stateStorage.watch(this.storeId, value => { if (value === "true") { this.element.hide(); this.file.toggleClass("filtered-file", false); } }); this.file.toggleClass("filtered-file", true); } onClick(listener) { this.listener = listener; } unhide() { this.stateStorage.set(this.storeId, "true"); this.file.toggleClass("filtered-file", false); this.element.hide(); this.listener(); this.file.scrollTo(); } } class ChangedFile { constructor(fileElement) { this.fileName = $('.file-title-name', fileElement).text().replace(/[\n\r]+/gi, ""); this.fileId = sha256(this.fileName); this.element = fileElement; this.actions = fileElement.find(".file-actions"); this.diffContent = this.element.find(".diff-content"); this.changedLines = [...this.diffContent.find(".new.line_content, .old.line_content")] .map(element => $(element).find(".line")) .map(line => { return { element: line, content: line.text().trim(), highlightedDiff: line.find(".idiff").text().trim() }; }); this.checksum = this.calcChecksum(); } calcChecksum() { const newLines = this.changedLines.filter(l => l.element.is(".new .line")).map(l => l.content).join("\n"); const oldLines = this.changedLines.filter(l => l.element.is(".old .line")).map(l => l.content).join("\n"); return sha256(oldLines) + " - " + sha256(newLines); } addAction(element) { this.actions.prepend(element); } addActionToDiffContent(element) { this.diffContent.append(element); } toggleClass(className, value) { this.element.toggleClass(className, value); } scrollTo() { scrollTo(this.element); } scrollToNext() { let target = this.element.nextAll(":not(.filtered-file, .approved)"); if (!target || !target.length) { target = this.element.prevAll(":not(.filtered-file, .approved)"); if (!target || !target.length) { target = $("body"); } } scrollTo(target.eq(0)); } } class Gitlab { constructor(settings, stateStorageFactory) { this.stateStorageFactory = stateStorageFactory; this.filters = new Filters(settings); this.reviewPanel = new ReviewPanel(settings); this.stateStorages = {}; } detectMergeRequestId(href) { const urlData = /\/([^/]+)\/merge_requests\/(\d+)/.exec(href); if (!urlData || urlData.length < 3) { return undefined; } return sha256(location.host) + "/" + urlData[1] + "/" + urlData[2]; } waitForInjecting() { if (!this.mergeRequestId) { return; } if ($('.files .diff-file').length && $(".loading.hide").length) { const userInfo = new UserInfo(); new MergeRequestInjector( userInfo, this.filters, this.reviewPanel, this.stateStorages[this.mergeRequestId], new LocalStateStorage(this.mergeRequestId) ).inject(); } else { setTimeout(this.waitForInjecting.bind(this), 500); } } mergeRequestDiffDetected(mergeRequestId) { if (mergeRequestId) { this.mergeRequestId = mergeRequestId; this.stateStorages[mergeRequestId] = this.stateStorages[mergeRequestId] || this.stateStorageFactory.create(mergeRequestId); this.waitForInjecting(); } } init() { $.ajaxPrefilter((options, originalOptions, jqXHR) => { if (/\/[^/]+\/[^/]+\/merge_requests\/\d+\/diffs\.json.*/.test(options.url)) { options.data = options.data || ""; if ((options.url + options.data).indexOf("expand_all_diffs=1") === -1) { options.data += options.data ? "&" : ""; options.data += "expand_all_diffs=1"; } this.mergeRequestDiffDetected(this.detectMergeRequestId(options.url)); } }); } } class DBStateStorage { constructor(database, prefix) { this.database = database; this.prefix = prefix.replace(/[\.#$\[\]]+/gi, "_"); } key(subkey) { return this.prefix + "/" + subkey.replace(/[\.#$\[\]\\\/]+/gi, "_"); } watch(subkey, resolve) { this.database.ref(this.key(subkey)).on('value', function (snapshot) { resolve(snapshot.val()); }); } set(key, value) { this.database.ref().update({ [this.key(key)] : value }); } } class DBStateStorageFactory { constructor(settings) { DBStateStorageFactory.init(settings); } create(prefix) { return new DBStateStorage(firebase.database(), prefix); } static init(settings) { if (!DBStateStorageFactory.initiated) { DBStateStorageFactory.initiated = true; localStorage.removeItem("firebase:previous_websocket_failure"); firebase.initializeApp({ apiKey: settings.fireBaseApiKey, databaseURL: settings.fireBaseDBUrl }); } } } class LocalStateStorage { constructor(prefix) { this.prefix = prefix; } watch(key, resolve) { var value = localStorage[this.prefix + "/" + key]; resolve(value === undefined ? value : JSON.parse(value)); } set(key, value) { localStorage[this.prefix + "/" + key] = JSON.stringify(value); } } class LocalStateStorageFactory { create(prefix) { return new LocalStateStorage(prefix); } } class Main { static init() { const settings = new Settings(); new Gitlab(settings, this.createStateStorage(settings)).init(); } static createStateStorage(settings) { if (settings.stateStorage === "local") { console.info("Local state storage"); return new LocalStateStorageFactory(); } console.info("DB state storage"); return new DBStateStorageFactory(settings); } } class Settings { constructor() { function get(name) { return localStorage[name] || Settings.defaults[name]; } function getBool(name, trueValue="true") { return get(name) === trueValue; } this.stateStorage = get("stateStorage"); this.isPublicFireBase = ( getBool("fireBaseDBUrl", Settings.defaults.fireBaseDBUrl) && getBool("fireBaseApiKey", Settings.defaults.fireBaseApiKey) ); this.fireBaseApiKey = get("fireBaseApiKey"); this.fireBaseDBUrl = get("fireBaseDBUrl"); this.filterPackageChanges = getBool("filterPackageChanges"); this.filterImportChanges = getBool("filterImportChanges"); this.filterEmptyChanges = getBool("filterEmptyChanges"); this.filters = JSON.parse(get("fileNameFilters")).filter(a => a && a.length > 2); this.enableFileNameFilter = this.filters.length != 0 && getBool("enableFileNameFilter"); } update() { localStorage.stateStorage = this.stateStorage; localStorage.fireBaseApiKey = this.fireBaseApiKey; localStorage.fireBaseDBUrl = this.fireBaseDBUrl; localStorage.filterPackageChanges = this.filterPackageChanges; localStorage.filterImportChanges = this.filterImportChanges; localStorage.filterEmptyChanges = this.filterEmptyChanges; localStorage.enableFileNameFilter = this.enableFileNameFilter; localStorage.fileNameFilters = JSON.stringify(this.filters); } } Settings.defaults = { stateStorage : "db", filterPackageChanges : "true", filterImportChanges : "true", filterEmptyChanges : "true", enableFileNameFilter : "false", fileNameFilters : "[]", fireBaseApiKey : "AIzaSyDh6mkW61kb8HbmRZ6gZQjeuZugWTDpLto", fireBaseDBUrl : "https://public-gitlab-review.firebaseio.com" }; class SettingsEditor { constructor(settings) { function checkbox(holder, toggleable) { const checkbox = $("<input type='checkbox'>") .appendTo(holder) .wrap("<div class='checkbox'></div>") .wrap("<label></label>"); return toggleable ? checkbox.change(() => toggleable.toggle(checkbox.is(":checked"))) : checkbox; } const editor = $("<div class='settings-editor'>").hide(); const repoHolder = $("<div class='repo-holder'/>").appendTo(editor); const filtersHolder = $("<div>").appendTo(editor); const buttons = $("<div class='buttons'>").appendTo(editor); const saveBtn = $("<button class='btn btn-primary'>Save</button>") .appendTo(buttons) .click(() => this.save()); const cancelBtn = $("<button class='btn btn-default'>Cancel</button>") .appendTo(buttons) .click(() => this.close()); this.settings = settings; this.editor = editor; this.firebaseSettings = $("<div/>").addClass("inner-settings"); this.useDbCheckbox = checkbox(repoHolder, this.firebaseSettings).after("Use firebase DB for sharing approves"); this.firebaseSettings.appendTo(repoHolder); this.urlInput = $("<input class='form-control'>") .appendTo(this.firebaseSettings) .wrap("<label>Firebase DB url: </label>"); this.apiKeyInput = $("<input class='form-control'>") .appendTo(this.firebaseSettings) .wrap("<label>Firebase Api key: </label>"); this.filterImportChanges = checkbox(filtersHolder).after("Filter for 'import' changes"); this.filterEmptyChanges = checkbox(filtersHolder).after("Filter for 'empty' changes"); this.fileNameFilterHolder = $("<div/>").addClass("inner-settings"); this.enableFileNameFilter = checkbox(filtersHolder, this.fileNameFilterHolder).after("Filter by file names"); this.fileNameFilterHolder.appendTo(filtersHolder); this.filtersInput = $('<textarea rows=7 class="form-control"></textarea>') .appendTo(this.fileNameFilterHolder) .wrap("<label>File name filters(exclude): </label>"); } show() { this.update(); this.editor.show().appendTo("body"); } update() { this.urlInput.val(this.settings.fireBaseDBUrl); this.apiKeyInput.val(this.settings.fireBaseApiKey); this.filtersInput.val(this.settings.filters.join("\n")); const isDatabaseStateStorage = this.settings.stateStorage === "db"; this.useDbCheckbox.prop("checked", isDatabaseStateStorage); this.firebaseSettings.toggle(isDatabaseStateStorage); this.filterEmptyChanges.prop("checked", this.settings.filterEmptyChanges); this.filterImportChanges.prop("checked", this.settings.filterImportChanges); this.enableFileNameFilter.prop("checked", this.settings.enableFileNameFilter); this.fileNameFilterHolder.toggle(this.settings.enableFileNameFilter); } save() { this.settings.fireBaseDBUrl = this.urlInput.val(); this.settings.fireBaseApiKey = this.apiKeyInput.val(); this.settings.filters = this.filtersInput.val() .split("\n") .map(s => s.trim()) .filter(s => s.length > 0) .filter((v, i, arr) => arr.indexOf(v) === i); this.settings.stateStorage = this.useDbCheckbox.is(":checked") ? "db" : "local"; this.settings.filterImportChanges = this.filterImportChanges.is(":checked"); this.settings.filterPackageChanges = this.filterImportChanges.is(":checked"); this.settings.filterEmptyChanges = this.filterEmptyChanges.is(":checked"); this.settings.enableFileNameFilter = this.enableFileNameFilter.is(":checked"); this.settings.update(); this.close(); } close() { this.editor.hide(); } } Main.init(); })();