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();
})();